Repository navigation
Conversation
…uires` The 23 strict answer schemas in decision.ts become tolerant readers: an unknown field is dropped instead of failing the whole answer, and every known field keeps its type, bounds and checks. An object naming the keys of two arms of one union (a job's pollUrl or running rule) is still refused. An offer may list `requires`, in the `accepts` vocabulary, on the answer or on any spec. A build without a listed feature skips that offer alone: an other offer's spec (and any choice that runs it) is dropped, and when the offer is the answer's own, the answer becomes a `native` with reason code `requires_unmet` naming the missing features. The hook warns for each skipped offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m text The over-cap note reads each listed service's price from its spec's `priceAtomic` only; the regex that read dollar amounts out of an offer's line, and the quoted-text guard beside it, are gone. An offer without specs names no listed price. A spec may set `timeoutS` (whole seconds, 1 to 120, the paid leg's own ceiling): the provider probe's deadline and the least the paid leg waits, in place of `--timeout`. It may set `maxBytes` (1 to 200 MiB): the cap on a binary result and on each linked media file, in place of the 200 MiB every call had. Both are optional and fall back to today's values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`requestDecision` always calls `POST /api/x402-router/route`. A call the
payer skips (`RouteSkipped`) returns `skipped` at once, with no second
request; with no payer, a 402 is a skip with the `no_payer` notice, and a
router charging nothing (the fee off, in development) answers the call.
Deleted: the free-route fallback in `requestDecision` and `freePath` on its
outcomes, the `paid-path-absent` router memo and the payer's 404 branch,
`PAID_PATH_ABSENT`, and the `FEE_REQUIRED`/`isFeeRequired` handling in
hooks.ts and tool.ts (including the tool's ask-twice retry). `routeFor` is
now synchronous and never null; `DecisionRoute` loses `path`.
The `tenjin hook <kind>` command form routes nothing: it tells the person
once per session to run `tenjin install`, and stays silent in a session the
plugin serves. Browse, `{tool}` and outcome reports keep the free route.
Copy and docs: the MCP instructions, `tenjin hook`/`tenjin mcp` help, the
install disclosure and docs/agent-permissions.md no longer describe a free
routing path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in#1046 step 4) The router's 402 is the only price. The payer reads the quote before the wallet is unlocked and pays it when it is at or below `ROUTING_FEE_CEILING_ATOMIC` (3000 atomic USDC, fee.ts), inside the SDK's per-payment cap, the deposit cap, the per-call limit and the daily budget. The per-payment checks run at the quoted amount. A quote above the ceiling is not paid: the call is skipped with `fee_above_ceiling`, reported as blocked, and the once-per-session notice names the quoted price. The payment policy keeps any `batch-settlement` canonical-USDC offer at or below the ceiling; no payee or exact price is pinned. The payer pays only the configured router's paid route: `routeFor(baseUrl).send` refuses any other origin or path unsent, so the ceiling never applies to a provider's 402, which `runPay` holds to that spec's price. A charge below the quote (batch settlement) is recorded by the SDK and builds the next voucher; a test pins it. The fake router can now quote and charge any amount. No fee amount is left in copy: the deposit line names the quoted fee, the unpaid line names none, and the install question, doctor, MCP instructions, README and docs say "a small flat fee". Counts with no quote (calls left in the channel, the funded-enough checks) use the ceiling and say "at least". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oday Three fixture files had drifted from tenjin's origin/main (lib/x402-router/fixtures/wire, 0414118e): - wire-hook-execute-spec.json carried the Exa `type` enum and default from tenjin#988 (`lite`), which is unmerged; the server still sends `auto | fast | instant`. Taken from the server. - wire-lookup-spec-by-id.json lacked the CoinMarketCap `slug` and `symbol` descriptions the server added. Taken from the server. - README.md described ROUTER_VERSION 2026-10-13.1 and one routing route. Taken from the server (2026-10-24.1: the paid route, the harness section, job specs, `running` reports), then extended: this repo is now the canonical set, `harness/` stays client-only, a "Reading an answer" section states the tolerant reader and `requires`, the paid-path section states the fee ceiling, the spec section names `timeoutS` and `maxBytes`, and the client-only `headers` token and its fixture are listed. wire-hook-execute-headers.json stays: it is this client's fixture for the `headers` token, which the server sends only once tenjin#988 lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opening The one-path change reworded that line of docs/agent-permissions.md to start "Every routing call goes to the router's paid route."; the suite-wide skills-text test still looked for the free-path opening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`tolerant` tells the server this build reads answers as a tolerant reader and honors `requires`. Released CLIs (alpha.25 to 31 carry most paid traffic and never auto-update) read strictly, so the server can send `requires`, `honored`, `timeoutS`, `maxBytes` or any later field only to a client that lists this token, and today's shape to the rest. Either repo can then ship first. Today's server ignores the token: it reads `accepts` only through `includes()` of the tokens it knows (catalog.ts `clientRuns` and `routerClient`, discovery `bazaarLane`), logs only known tokens, and bounds the list at 16 tokens of 1 to 32 characters; this build sends 9. Listed in the wire README's tokens and docs/agent-permissions.md; a test pins it in `acceptsFor` with and without the Bazaar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The install consent now reads "Tenjin charges a small flat fee per routing call, never more than $0.003, ...": the ceiling is the amount the person approves, as EIP-1559's max fee is. It renders `ROUTING_FEE_CEILING_ATOMIC`, a CLI constant, so it cannot go stale when the router's price changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server's `warm` form (BackTrackCo/tenjin#1050) is a channel deposit's carrier: `{schemaVersion: 1, warm: true, accepts?, harness?}` on the paid route, answered in code with `{schemaVersion, routerVersion, warm: true}`, no gate and nothing stored, so it settles at $0. The batch-settlement spec has no deposit-only payload, so this is how a deposit moves off the prompt. - `buildWarmBody` and `isWarmAnswer` in decision.ts, the answer read tolerantly like every other. A router without the form answers anything else, which the next commit reads as "no form". - `wire-warm-request.json` and `wire-warm.json`, copied byte for byte from the server branch, with the README's row, file list, paid-path paragraph and `ROUTER_VERSION` 2026-10-29.1. A wire test pins both. - `SkipFacts` on `RouteSkipped` and the skipped outcome (the quote, and for `wallet_low` the balance and the deposit it fell short of), and the two no-fault skip reasons the top-up adds: `topping_up` and `deposit_deferred`. Refs BackTrackCo/tenjin#1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never deposits (tenjin#1046 step 5) A deposit settles on chain in about 5 s and a hook leg has 3.5, so the leg that carried one lost its hint (1 leg in 83 at $0.25). Now only the payer's background top-up deposits: one `warm` call to the paid route, through the same turns, checks, spend ledger and detach as any call, on its own 15 s. The stock SDK adds the deposit to it; nothing is hand-built. When it runs: - `tenjin mcp` starts, the prewarm unlock settles, the router is on for the directory, and the SDK's channel files cannot cover a fee (a new channel included): `topUpIfLow`. - A paid call leaves the channel below one quoted fee (the SDK deposits only once the next voucher passes the balance). - A routing call's SDK asks for a deposit anyway: `depositStrategy` refuses it (`deposit_deferred`) and starts the top-up. A call that meets a top-up in flight in its own process waits for it until the paid call's 1 s reserve before its deadline, then pays a voucher; a call whose own SDK asked waits for the top-up it started and is made once more. The fee is required, so there is no free path to skip to. Out of time is `topping_up`, no fault and no notice. Sizing (`routingDepositAtomic`): the smallest of the 402's `extra.minDeposit` (read with the SDK's `parseAnnouncedMinDeposit`; `CHANNEL_DEPOSIT_ATOMIC` when absent), the wallet and the per-call limit, never below 10 quoted fees or what the next voucher needs, never above the SDK ceiling; the daily budget stays the authorizer's. A wallet below it is `wallet_low`, carrying the balance and the deposit, and the leg's one notice per session names them. The balance is read only here. A refused top-up backs the next off 30 s, 2 min, then 10 min, and the calls that need a deposit meanwhile skip with its reason (contention backs off nothing). A `topping-up` memo naming the wallet keeps a second process from sending its own at the same moment. Its deposit is said once, on the next leg (`route.deposited()`). Either side can ship first: a router that answers `warm` with anything but its answer (an older server's 400, which settles nothing) writes the `warm-absent` memo for an hour, per machine and router origin, and deposits then ride the next paid call with today's 4.5 s budget. A 429 backs off instead. Refs BackTrackCo/tenjin#1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0.25 at a time The install question said "$0.25 at a time, or $0.03 while your balance is under $0.50", the #519 rule the top-up replaces. It now reads "paid from funds it sets aside, at most $0.25 at a time": the SDK ceiling this client enforces, true whatever `extra.minDeposit` the router sends. The README and docs/agent-permissions.md describe the top-up: when it runs, the sizing, the `wallet_low` notice with the balance, the backoff, and the hour of inline deposits on a router without the `warm` form. One changeset. Refs BackTrackCo/tenjin#1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… exchange The server PR (BackTrackCo/tenjin, `vraspar/router-contract-server`) deletes tenjin's copied fixtures: its CI checks out this directory at main and parses every file with a production route schema. So the README no longer says tenjin keeps a copy: it says tenjin's CI reads these from main, which makes a fixture change here a contract change. `ROUTER_VERSION` is 2026-10-29.2. A new section documents the two request-and-answer pairs: `warm` (the background top-up's deposit carrier, and what a server without the form answers) and `tolerant`. The tolerant pair is a browse row's id from a client whose `accepts` lists `tolerant` (`wire-tool-request-by-id-tolerant.json`) and its answer (`wire-lookup-spec-tolerant.json`): `honored` (the parsed request's own fields in the form's order, without `gateHint` and `input`) and the AiSpace clip's spec with `requires: ["jobs"]`. The answer was generated with the server branch's own `capabilitySpec`, `capabilitySkeleton` and `withCountLine` for the AiSpace Hailuo service, as its by-id handler builds it; no service sets `timeoutS` or `maxBytes` yet, so it carries neither. All four new fixtures parse with that branch's route schemas. A wire test pins this build's by-id request to the fixture, and reads the answer with `requires` kept and `honored` dropped. Refs BackTrackCo/tenjin#1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refused top-up backed off 30 s, then 2 min, then 10 min, for every reason. For `wallet_low` that broke onboarding's one sitting: a new user funds the wallet in the browser, comes back, and could wait up to 10 minutes for routing. `wallet_low` now stops at 2 minutes (30 s, 2 min, 2 min, ...); the retry is one background balance read. Other refusals and server errors keep 30 s, 2 min, then 10 min. A test pins both schedules. Refs BackTrackCo/tenjin#1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erver sends The server always adds `specs` to an open-Bazaar `discovered` answer (`discoveredDecision` in app/api/x402-router/route.ts), and every released CLI takes it as optional; wire-hook-discovered.json and wire-lookup-discovered.json lacked it. Each now carries the server-generated spec, one per file, under the answer's own id; the files keep their inline style. wire.test.ts expects `specs` on that answer and pins its id to the answer's. tool.test.ts now covers both: an answer with the spec is shown and kept under its id, and the specless candidate path keeps its own test on a copy with `specs` removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into vraspar/router-background-topup
There was a problem hiding this comment.
🤖 Reviewer · Review PR #533: feat(router): top up the routing channel in the background; a pr… · claude-code · claude-opus-5-5
Deep review at dcfde56 (merge base fce1cc2). Process:
- A context pack, then 13 auditors (12 plus one catch-up round). The ledgers cover every changed and context line of the 123 hunks, and no line was left unaudited.
- 6 file owners and 6 independent reviewers.
- eslint and semgrep (
p/default,p/security-audit) on the changed files were clean, so there were no analyzer leads.
The core design holds up:
- The top-up goes through the same turn queue, ledger commit and authorizer as every other call.
- A waiting call never holds the turn, so nothing deadlocks.
- Carrier and inline deposits get the same
wallet_lowcheck, policy check and authorizer. A router cannot push a deposit past the per-call or daily limits or the $0.25 cap. - Typecheck is clean at this head (recorded).
Three p2s:
- Nothing waits for the background deposit at shutdown.
- A
warmdeposit that outlives its 15 s budget is never announced. - The backoff counter carries over across refusal reasons, so a transient failure right after funding can hold routing off for 10 minutes.
The nits are mostly that cluster's edges: the cross-process memo, transient refusals told as payment_failed, a channel-credit read that can see the wrong channel, the warm fixture not matching what the client sends, and docs that still describe the old inline deposit.
Recorded test run: npx vitest run on the 9 covering router test files failed 1 of 363. routing-payer.test.ts "reserves the last daily deposit only once when two wallets race" got ['budget_reached', 'deposit_deferred']. It passed 6 of 6 when run alone, so it is timing dependent under load. A silent deposit_deferred there means one wallet's call ended neither paid nor refused as expected.
Test gaps (not posted inline):
hooks-fee.test.ts:611androuting-payer.test.ts:717wait out awallet_lowbackoff withTOP_UP_BACKOFF_MS[0]instead ofWALLET_LOW_BACKOFF_MS[0]. They pass only while both are 30 s.fee.test.ts:53androuting-payer.test.ts:223-237cannot see the SDK ceiling: the default per-call limit already equalsMAX_CHANNEL_DEPOSIT_ATOMIC, so removingleast(amount, o.maxAtomic)stays green.routing-payer.test.ts:270("tops up once every ten") no longer pins the interval. Thedeposits === 1check after nine calls was removed.routing-payer.test.ts:710-715says "no second balance read" but never countswalletBalancecalls.- The removed
limit_below_deposittest's check that the ledger stays at 0 (old:550) has no replacement. - Neither
mcp.test.ts:1166-1177nor therouting-payer.test.tsafterEachawaitspayer.idle()beforerm(dir), so the background top-up can race the temp-dir teardown. - The router-off case at
mcp.test.ts:1182can pass vacuously:topUpAtStartisn't tracked byidle(), so a 200 ms sleep is the only guard. - The money check at
wire.test.ts:379-395omitswire-tool-request-by-id-tolerant.json. spend-question.test.ts:14's stated reason ("a funded wallet deposits $0.25") is stale.
Single-source and unverified (not posted inline):
mcp.ts:463-466: on a machine with both a plugin and a settings install, eachtenjin mcptops up at start. Only its own routes can announce the deposit, so the settings server's deposit may never be said.routing-payer.ts:348-350:doneassumes the server'swarm402 quotes at least the routing fee. If it quotes less, the SDK sends no deposit, the backoff resets, and every prompt defers and sendswarmagain.
Refuted: a failed warm-absent memo write leaving an older router stuck. writeRouterMemo swallows the error, but the same unwritable data dir fails commitDeposit (routing-payer.ts:884) before any deposit is sent.
Smaller doc staleness, listed rather than posted:
docs/agent-permissions.md:25: the reason given for the $0.25 per-call floor.decision.ts:830-832: thefeedoc ("this route's calls") versus the?? this.quotedfallback.- The
Call.depositdoc atrouting-payer.ts:323-324, for a deferred call. fixtures/README.md:422-423: the "diagnostics on every non-execute answer" rule, whichwire-warm.jsonbreaks.fixtures/README.md:376-378: the file table, which predates this PR.fee-test-utils.ts:60-64and:116-118: thewarmsandlogdocs.
No lines were left unaudited.
- decision.ts: the union-refusal note names only `pollUrl`/`running`; the browse row is no longer called strict; `maxBytes` says it does not yet cap a job's polled result; `tolerant` gating is the server's once it reads the token (R2); `DecisionRoute` names the `unreached` case. - hooks.ts, gate.ts, tool.ts, mcp.ts: no comment credits `tenjin hook <kind>` with routing, free decisions or a signer-less leg; the request tool pays a routing fee and one payment per take. - hook-command.ts: `STDIN_TIMEOUT_MS` is the stub's own and no longer exported; the wire test that budgeted stdin plus the gate for a leg that no longer exists is gone. - `tenjin hook --help` describes `alive` as live, and only the six routing kinds as left from older setups. - docs: the free docs lookup pays the routing fee, a keychain that does not answer skips the call, safety-model.md drops "deciding costs nothing", and a skipped offer is "no provider paid", the answer's own offer becoming a named skip (same in the changeset). - fixtures README: the `jobs` condition on never-inline services, the stray blank line, the "no fee" paragraph, this client's job-poll rules, the `takes.max`, `requires`, `timeoutS` and `maxBytes` bounds, and three fixture rows for files this directory does not have. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A `requires_unmet` skip says "no provider was paid": the routing fee for that answer was. The footer names it `native tools (offer needs newer tenjin)`, not the router choosing native. - A skip with no-fault reason (`channel_busy`, `busy`, `unlock_timeout`) tells the model in plain words that the next call usually routes, instead of putting the code in its text. - The deposit line says each decision costs "at most" the quote: the router may charge less. - Doctor's routing-fee row names the $0.003 ceiling, as install consent does. - A body over a spec's `maxBytes` under 1 MiB names the cap in bytes, not "0 MB". - A compare or multi-take quote probe waits as long as the spec's `timeoutS`, as the call's own probe does. - The hook-command stub marks its once-per-session notice with its own marker, so it no longer overwrites the wallet notice that "Tenjin wallet funded/unlocked" reads. - fee.test's list of every skip reason includes `fee_above_ceiling`, so the fix-text guard covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom the #532 review - decision.test's "refuses a hook answer carrying a contract" passed only because its fixture hint lacked the id; the hint now names it, and the test pins what tolerant reading does: the offer is read, the contract dropped. - Test names and comments no longer call routing free or the call "going free" (decision, routing-payer, mcp, tool, wire, fee tests), and wire.test names `headers` and `tolerant` as client-side tokens. - Dead helpers: codex-paid-hooks `connect`'s unused fetch parameter, and hooks-fee's `NATIVE` and 200 defaults on its one 402 router. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paid leg's floor was the spec's deadline when one was set, so a `timeoutS` below the CLI timeout (30 s in `tenjin mcp`) cut the request after the signed authorization had left: the seller could settle, and the result was lost. The floor is now the larger of the two: a spec may lengthen the paid leg, never shorten it. The probe still takes the spec's deadline. A test with no advertised bound fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#533) From the aether review of #533, each checked against the code: - The backoff count restarts when the schedule changes. After wallet_low retries had pushed it to 4, one server error on the first top-up after funding waited 10 minutes instead of 30 s. - `track` settles to undefined, so the chain no longer nests every result. - A top-up clears the `topping-up` memo only while it still names its own wallet, so it never deletes another wallet's. - A top-up refused for contention (`channel_busy`, `busy`) now hands the call waiting on it that reason, not `deposit_deferred`. The request tool was telling the agent funds were being set aside when no top-up was running. - Every top-up log line carries `top-up`, a queue timeout's included. The 429 line comes from `note()`, with its real stage, and is reported to the observer. - An inline deposit (router with no `warm` form) gets its longer budget before the memo read, as before C2, so its deadline cannot pass during that read. - The start-of-session top-up reads `router.enabled` from `handlerDeps.cwd`, the request tool's seam, else the process cwd, so its test resolves from its own git root. Tests: - The flaky two-wallet race test minted a new wallet on every `getSigner` call, so the top-up's own unlock signed for a different wallet than the call it served. A probe reproduced the `deposit_deferred` result. It now uses one fixed second wallet, as the wallet layer's signer cache does. - The wallet_low waits use `WALLET_LOW_BACKOFF_MS`. The SDK-ceiling cases raise the per-call limit above the cap. The no-second-balance-read claim is counted. Payers settle before their temp folder is removed. The tolerant request joins the money check. - A new test pins the backoff restart; reverting the restart fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/agent-permissions.md, the README and the changeset:
- The wallet_low backoff stops at 2 minutes.
- The balance stays off the prompt only while the router has the `warm`
form.
- A wallet between ten fees and the hint deposits what it holds.
- Routing stops only once the channel runs out.
- A call waits for the top-up before it is skipped.
- The $0.25 per-call floor's reason.
- What counts as "no `warm` form", listed exactly: a non-warm 2xx, or a 3xx
or 4xx other than 402 and 429. Fixed in the fixtures README and
router-memo.ts.
- In the fixtures README, a `warm` payment may be a voucher, and the
diagnostics rule is about decisions.
- In decision.ts, fee.ts and hooks.ts: the `fee` fallback, `topping_up`'s
next call, where the wallet_low facts come from, and "skipped at once" in
three comments.
- The `WALLET_LOW_BACKOFF_MS` comment says a retry is a whole top-up.
- The fake router's log doc, and the spend-question test's stale reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names `requires` is a backstop: the server sends only tokens a client lists in `accepts` (S2 filters offers by it), so a client meets an unmet token only when that rule broke. When the backstop drops anything the answer's line names (the answer's own offer, a spec whose id the line carries, or any option of its choice set), the line cannot be shown as written, so the whole answer becomes the named `requires_unmet` native. A dropped spec the line does not name is still just dropped. This replaces trimming the choice set while keeping a line that still offered the trimmed options. The fixtures README states the contract rule; the permissions doc and the changeset say the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wire-warm-request.json` carried the server branch's four tokens, but the top-up sends `acceptsFor(false)`: all eight, `tolerant` included. So the real answer carries `honored`, which `wire-warm.json` lacked. tenjin's CI checks the server against these files, so they now hold the real body and answer: eight tokens and `harness`, and `honored` of ["schemaVersion", "warm", "accepts", "harness"] (the order the server's schema parses them in), with `ROUTER_VERSION` 2026-10-29.2. Both parse with the server branch's route schemas. The wire test now builds the body from `acceptsFor(false)`, not from the fixture's own `accepts`, so drift between the two fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…review) `deposited()` added the process-wide top-up amount and reset it, so whichever route read first took it. A `request` call after the start top-up put "Tenjin set aside $0.03 for routing" in the model's result, and the person's next hook leg said nothing. The plan has the next hook leg carry it. The route now keeps them apart. `deposited()` is the route's own calls (an inline deposit on a router with no `warm` form), which the tool still says in its result. The new `toppedUp()` is the top-up's, read only by the hook leg's `decide`, which says it in `systemMessage`. A test runs a tool call after a start top-up: its route takes nothing, and the next hook leg says the deposit. It fails with the old `deposited()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`tenjin doctor` and `tenjin install` probed the free route, which no
longer routes, so a paid route that 404s or quotes above the ceiling
stopped every routing call while the router row read ok. The probe now
sends one request to `POST /api/x402-router/route` in the `warm` form
(`{schemaVersion: 1, warm: true}`), which runs no gate:
- 402: live and charging. Its quote is read, and one above
`ROUTING_FEE_CEILING_ATOMIC` warns that every routing call is skipped;
a 402 with no routing offer this CLI reads warns too.
- 200: live with the fee off. 400: a server with no `warm` form, live.
- 404 or 5xx: a failure, as before.
The x402 paywall answers a request with no payment 402 before the handler
reads the body (`withX402FromHTTPServer` returns the payment error before
calling the route handler), so this holds before tenjin#1050 ships `warm`.
Reading a quote moves to `routing-quote.ts`, shared with the payer, and
the probe loads it only on a 402, keeping x402 out of the chunk install
and the status line share. Doctor ends the routing backoff on any
answered probe, a fee warning included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eview) What a routing call says after the top-up it waited on was refused: - A rate limit or no connection on the top-up is now `top_up_unavailable`: no fault and no notice, as for a normal call. It still backs the next top-up off. It used to be `payment_failed`, which told the person "routing payment failed, run tenjin doctor" for a 429. - The top-up's "unreachable" is never passed on. The call that waited reached the router, so it no longer starts the machine-wide 60 s pause. It gets a skip of its own, built from the reason and facts alone. The backoff path already behaved this way. - A refusal repeated during the backoff leaves out the balance it read before, which may be old (a wallet funded since). The call that waited on the top-up itself still names the fresh balance. A new session within the backoff gets the reason without a number. Backoff now has its own no-backoff set (contention, a payment still out), separate from the silent reasons, so `top_up_unavailable` is silent yet still backs off. docs/agent-permissions.md says which top-up failures say nothing. Tests: a 429 and a refused connection each give the call a silent skip (not "unreachable"), and the next call sends no second top-up; a repeated `wallet_low` carries no balance. All three fail on the previous payer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vraspar/router-background-topup
There was a problem hiding this comment.
🤖 Reviewer · Review PR #533: feat(router): top up the routing channel in the background; a pr… · claude-code · claude-opus-5-5
Re-review at 69d0d2c. Since 2a8eacc the only change is a renamed test in src/router/fee.test.ts, and the new name says what the test checks: it pins CHANNEL_DEPOSIT_ATOMIC to the router's announced $0.05 hint. My last review was at 530695b, so I also read the merges in between.
Changes since 530695b:
- $0.05 hint: the hint is now $0.05 (50000 atomic), and
FakeRouterannounces it by default. - Floor: doctor (
canPayRouting) and thewallet_lowtext now use the payer's floor,DEPOSIT_FLOOR_FEES × ROUTING_FEE_CEILING_ATOMIC($0.03), not the hint. A wallet between the floor and the hint is no longer refused offline while the payer would route it. The payer's floor is 10 × the quote, and the quote is at or below the ceiling, so doctor never passes a wallet the payer would refuse. This resolves my earlier nit about doctor sizing with the fixed hint.
Still open, not blocking: src/router/mcp.ts:622. drained() waits only TOP_UP_TIMEOUT_MS, but a top-up's deposit can run longer on its detached tail, so the wait should be TOP_UP_TIMEOUT_MS + DETACHED_CEILING_MS. The topping-up memo gaps, the channelCredit max and the zero-quote floor are unchanged since my earlier reviews.
Checks I ran at this head:
- Typecheck: passes.
- Tests: the 7 router test files this delta touches ran 352 tests, and all passed.
No new findings.
A1igator
left a comment
There was a problem hiding this comment.
Re-review at 69d0d2c: 1 of 1 closed
Round 4, layer over #534 at 3cc1893. Delta since round 3 (530695b): two merges of #534's $0.05 hint, 56962e0 (doctor's floor), and the two changeset notes.
Open
None.
New
Should fix
- On the common low-wallet path the once-per-session
wallet_lowline names no balance, whiledocs/agent-permissions.mdsays the next call names it.tenjin mcptops up at start (), a wallet under ten fees is refused there with its balance, and the first prompt inside the 30-secondtenjin-agent/src/router/mcp.ts
Line 665 in 69d0d2c
WALLET_LOW_BACKOFF_MSwindow reachesdeferral, which dropsbalanceAtomicanddepositAtomicbeforerefuse(), so the session's one line is the static $0.03 sentence and the later refusal that re-reads the balance is the same reason, no longer news. Either carrytenjin-agent/src/router/routing-payer.ts
Lines 920 to 929 in 69d0d2c
failed.facts()on a backed-off refusal (that read is at most 30 seconds old) or drop "naming the balance" from the doc sentence ().tenjin-agent/docs/agent-permissions.md
Line 45 in 69d0d2c
Closed as decisions
channelCreditis the largest credit across every channel file (round 1, item 2): declined for now as the owner's call; the check moves to the channel the 402 names when a wallet switch is planned. Settled, not re-argued.- The top-up reads the wallet balance over RPC and sizes the deposit from it (round 1, item 3): settled by the operator; the rebase against #523 decides it.
Facts, not findings: doctor and the payer agree on the wallet_low floor, ten fees, $0.03 at the $0.003 ceiling (canPayRouting with DEPOSIT_FLOOR_FEES * ROUTING_FEE_CEILING_ATOMIC, the payer with ten times the quote, equal while the router quotes the ceiling), and a wallet between the floor and the $0.05 hint deposits what it holds on both. The top-up sizes from the 402's extra.minDeposit with CHANNEL_DEPOSIT_ATOMIC as the no-hint fallback only; the fixtures README's extra.minDeposit 50000 matches the server. No balance read is added beyond the one the top-up makes. Locally at 69d0d2c: tsc --noEmit and vitest on the twelve touched test files (637 tests) pass.
A refusal said again inside the backoff gives the reason alone, since the balance read before it may be from before the wallet was funded. The permissions doc and the release note promised the balance on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@A1igator re your round 4 at 69d0d2c:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
🤖 Reviewer · Review PR #533: feat(router): top up the routing channel in the background; a pr… · claude-code · claude-opus-5-5
Re-review at 33ae023. This commit changes only docs: .changeset/background-top-up.md and docs/agent-permissions.md. No source file changed since 69d0d2c.
The new wording says a repeated wallet_low names no balance, and it matches the code:
- When a refused top-up is still holding off the next one,
RoutingPayer.deferral(src/router/routing-payer.ts:919-925) gives the call onlyquotedAtomic. The balance the earlier top-up read is dropped, because it may be older than a fund. - A call that waited on a fresh top-up's refusal gets that top-up's facts (
:518), so its message can still name the balance just read. unpaidSentence(src/router/fee.ts:232) names the balance only when bothbalanceAtomicanddepositAtomicare present.
Earlier findings:
- Unchanged nit:
src/router/mcp.ts:622.drained()waits onlyTOP_UP_TIMEOUT_MS, notTOP_UP_TIMEOUT_MS + DETACHED_CEILING_MS. The other earlier nits are also unchanged. - Blocking: none.
Checks: I ran nothing new. The source is identical to 69d0d2c, where typecheck passed and 352 router tests passed.
A1igator
left a comment
There was a problem hiding this comment.
Re-review at 33ae023: nothing left I would block on.
Round 5, layer over #534 at 3cc1893. Delta since round 4 (69d0d2c): one docs commit, no source change. Round 4's should fix (the repeated wallet_low line names no balance) is closed on the doc side: docs/agent-permissions.md and the release note now promise the balance only on the call that read it, which is what deferral does (a backed-off refusal repeats the reason without the earlier read), and routing-payer.test.ts "holds the next top-up off after a refusal" pins the case the author names: funded to $10 inside the backoff, the repeat carries no balance. Carried as decisions, not re-argued: channelCredit taking the largest credit across channel files (owner declined for now), and the top-up's RPC balance read (settled by the rebase against #523). Locally at 33ae023: vitest on routing-payer and fee tests (113 tests) passes.
…ontract The fee change now merges on its own, first, so it no longer sits on the tolerant contract (#532). It needed nothing from it to work: it only borrowed two of its names in code that both PRs rewrite. Rebuilt on main, a decided answer carries no `skipped` offers and the paid path reads answers with main's `readDecision`. The fixtures README drops "the same bodies and answers as the free route", which stopped being true once every routing call goes to the paid route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ceiling The payer signed the SDK's $0.25 ceiling from any wallet holding twice it ($0.50), else the hint, and refused a wallet below the hint as wallet_low. It now signs the smallest of the 402's extra.minDeposit ($0.05 when a 402 has none), the wallet and the per-call limit, never below ten quoted fees or what the next voucher needs, and never above $0.25. A wallet below that amount is wallet_low, so one between ten fees and the hint deposits all it holds; doctor's canPayRouting and the wallet_low line use the same floor, ten fees at the ceiling ($0.03). The deposit still rides the routing call. The README, the permissions doc, the deposit changeset and the install question say the new rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pdate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps this PR's one paid request (no free-path retry, so no `freePath`) and adds main's `skipped` offers on top. The paid route's answer, and a payer's unreached answer, are both read through `readOffer`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fee PR squash-merged to main at this head. Its deposit sizing came from this branch, so each conflict keeps this branch's wording and code (the top-up, and the `wallet_low` balance). Main's extra fee-sizing row and its "only the first deposit counted" check are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main is the fee PR's final head, squash-merged, so the tree is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🤖 Reviewer · Review PR #533: feat(router): top up the routing channel in the background; a pr… · claude-code · claude-opus-5-5
Re-review at e8e8a8b. The PR now targets main. This delta is two merges: the fee PR's final head (#534) and main, which brings #532 and the hash-wasm wallet unlock (#521).
Merge resolution: correct. No router source file changed since 33ae023, so this PR's $0.05 hint, its deposit sizing and its top-up logic are intact. The only router changes are two test assertions from #534's final head:
fee.test.tsadds a $0.50-wallet case. It sizes to the hint, asroutingDepositAtomictakes the smallest of hint, wallet and per-call limit.routing-payer.test.tschecks that only the first deposit was counted inspend.json.
The docs/agent-permissions.md change is #521's unlock wording alone; this PR's routing text is untouched. The files under src/lib/wallet/, the packaging scripts and NOTICE.md come straight from main, and I didn't re-review them.
Earlier findings:
- Unchanged nit:
src/router/mcp.ts:622.drained()waits onlyTOP_UP_TIMEOUT_MS, notTOP_UP_TIMEOUT_MS + DETACHED_CEILING_MS. The other earlier nits are also unchanged. - Blocking: none.
Checks at this head:
- First run: typecheck and the keystore tests failed because
hash-wasm, the new dependency, wasn't installed in this workspace. - After
pnpm install --frozen-lockfile: typecheck passes, andfee,routing-payerandkeystore-kdfran 144 tests, all passing. Both runs are recorded.
|
Heads-up from the review loop at e8e8a8b: this PR waits on one author-side action. The Greptile P1 thread on |
|
@vraspar the deposit size is settled: $0.05 ( |
A top-up can run its own 15 s and then leave a detached payment running up to 15 s more, so the exit wait now covers both instead of the first only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🤖 Reviewer · Review PR #533: feat(router): top up the routing channel in the background; a pr… · claude-code · claude-opus-5-5
Re-review at 9c6cdc7. This commit fixes my open nit at src/router/mcp.ts:622.
drained() now races payer.idle() against TOP_UP_TIMEOUT_MS + DETACHED_CEILING_MS (30 s), not TOP_UP_TIMEOUT_MS alone (src/router/mcp.ts:624):
- Late deposit: a top-up that times out at its 15 s budget can leave its deposit running for up to another 15 s. That deposit now lands, and is recorded in the SDK's channel file, before the process exits.
- Normal shutdown: a shutdown with no top-up in flight still exits as soon as
idle()resolves, and the timer is cleared. - Docs: the changeset and
docs/agent-permissions.mdchange "15 seconds" to "30 seconds", which matches the new bound.
Earlier findings: the other earlier nits are unchanged and none block.
Checks at this head: typecheck passes, and mcp and routing-payer ran 122 tests, all passing.
|
@vraspar review at 9c6cdc7 is clean and CI is green, so the approval waits on one thing from your side: run the background deposit path once on your local machine against tenjin#1055 (approved at 05a9a13), the top-up, the deposit-after-budget notice and the mcp exit wait, and say here that it held. The approval follows that note. |
|
@vraspar heads-up from the review loop at 9c6cdc7: this PR now conflicts with main after the latest merge there, so it needs a re-merge or rebase before it can land. The other awaited item stands: the local run against tenjin#1055 (top-up, deposit-after-budget notice, mcp exit wait). Both from your side; nothing else is awaited from the reviewer. |
What
C2 of the router contract and fee plan: the background top-up, and PR 4 of the hook-leg latency plan. A prompt never carries the routing deposit.
Based on
main, now that #532 and #534 are merged. Merge after the server change that adds thewarmform is deployed. The server's CI reads this repo'smainfixtures, so merging earlier turns it red on thewarmfixtures.tenjin mcpdeposits with one backgroundwarmcall to the paid route, which settles at $0 and carries the deposit. It runs through the payer's normal turns, ledger and detach logic, with its own 15 s budget. It triggers:topping_up: no fault, no notice. Atopping-upmemo naming the wallet stops a second process from depositing at the same time.mainwith feat(router): pay the router's quoted fee, capped at $0.003, and route only on the paid route #534; this PR keeps it and has thewallet_lownotice name the balance. The smallest ofextra.minDeposit(read with the SDK'sparseAnnouncedMinDeposit, falling back to $0.05), the wallet and the per-call limit.wallet_low, and the once-per-session notice names the balance.warmshape (an older server's 400 settles nothing) writes awarm-absentmemo per machine and router origin. For an hour, deposits ride the next paid call as today. A 429 or 5xx backs off instead.wallet_lowretries every 2 minutes at most (30 s, then 2 min), so someone who funds mid-session routes again within 2 minutes.warmexchange and a tolerant exchange (honored, plus a spec withrequires) are added. The fixtures README says the server keeps a copy refreshed from this one until its CI reads them from this repo'smain.Not in this PR
Checks
vitest runon 22 covering files (1,172 tests) on the main change, then 8 files (719 tests) on the backoff follow-up. That includes both suite-wide consistency tests anddist-chunks.warmthe way the server change does, and also with a 400 like an older server.vitest runpasses on 18 files (1,201 tests).warmform and the fee off, using a copy of a real wallet:tenjin mcpstarts without a top-up.warmgoes out about 0.6 s after start, gets 200 and is read as done, with no payment and nowarm-absentmemo. A lookup in the same process routes.no_wallet).warmfallback against an older server, which need the fee on.main(e8e8a8b): typecheck and eslint pass, andvitest runpasses on 12 files (854 tests), both suite-wide consistency tests included.vitest runpasses on 3 files (338 tests), the skills-text consistency test included.Notes for reviewer
requesttool also leaves its deposit to the top-up, per the plan's "only a warm call deposits".🤖 Generated with Claude Code


Confidence Score: 5/5
The changes since the previous review appear safe to merge; the shutdown wait now covers both payment windows.Summary
The PR moves routing deposits onto background
warmcalls, with an older-router fallback.Reviews (8) · Last reviewed commit: "fix(router): wait out a detached top-up ..." · Reviewed by Greptile