Trial agent cap, peak, meter API
## Original requirement (verbatim, epic 8925)
> When a new customer is on trial, they signed up for some agents, but they are not paying for them for 14 days. Let's give them unlimited agents and keep a tally of what was the max until now compared to what they signed up for. Something like 10/3.
>
> This needs to be web researched and analyzed and graphically represented in the sidebar with an unobtrusive Upgrade button that allows to increase the number of agents workin.
Settled inputs: the BA PRD (Task 8923, decisions D1–D7, assumptions AS1–AS6, criteria 1–10) and the Architecture (Task 8924, §3 components, §4 decomposition). This child is one row of that §4 table. The contracts quoted below are pinned; do not re-design them.
## Settled decisions this child relies on (verbatim from the PRD)
- **D1. The "max until now" number** is peak **demand** since the trial started: the maximum over time of **agents running + ready tasks the dispatcher held back only because of the org's agent cap** (the trial ceiling). Tasks waiting only for node or worktree capacity are not counted. The operator asked for "the maximum agents that were willing to run, but were limited by plan".
- **D2. "Unlimited" during the trial** means the runner-slot cap is lifted to the plan's **Max agents**. When the plan has no Max agents, a new operator-set per-plan **trial agent ceiling** applies. A blank ceiling falls back to a fixed default of 10. Node and worktree capacity still bound the count.
- **D3. Upgrade during a trial** raises the agent count without charging now. The new count is billed when the trial converts. The count field is prefilled with the peak. This lifts the `IN_TRIAL` refusal for the agent count only. Plan, interval and hosting changes stay refused during a trial. (Architecture amendment Q1: the trial agent change moves the count **both ways**, within the plan's min/max.)
- **D4. The meter shows only while the subscription is `trialing`.** It is hidden after conversion, cancellation or expiry.
- **D5. Audience.** Every member of the org sees the meter. Only Owners and Admins (`isOrgAdminSeat`) see the Upgrade button.
- **D6. Trial end.** At conversion the cap returns to the paid agent count. Running work is never killed; only new dispatch is limited. In the last 3 days of the trial, if peak is greater than signed-up, the meter turns warning-coloured and states the gap, e.g. "Trial ends in 2d — used 10, paying for 3".
- **D7. Scope** covers **per-agent trial plans only**. Flat plans have no customer-chosen "signed up for" number and are unchanged.
- **AS1.** An org override (`orgs.limit_runner_slots`) still wins over the trial ceiling. **AS2.** The `active_worktrees` cap must not hold trial dispatch below the trial ceiling. **AS3.** A staging org shares its parent's ledger; its demand counts toward the parent's meter and the meter shows on both. **AS5.** Peak is a high-water mark persisted per org and per trial, reset when a new trial starts.
## This child: C1 — Trial cap + peak demand + meter API (backend foundation)
Approach (Architecture §3.1–§3.5, verbatim contracts):
**Schema** — one EXPAND-only migration `apps/api/migrations/versions/<YYYYMMDDHHMM>_trial_agents.py`, `SET lock_timeout`, pattern of `202610201437_plan_trials.py`:
| Column | Type | Meaning |
|---|---|---|
| `billing_plans.trial_agent_ceiling` | `Integer NULL`, CHECK `> 0` | Operator-set per-plan ceiling (D2). NULL = default 10. |
| `orgs.agents_trial_until` | `timestamptz NULL` | Mirror: the live per-agent plan subscription's `trial_end` while `status = 'trialing'`, else NULL. |
| `orgs.agents_trial_peak` | `Integer NULL` | High-water demand this trial (D1). Reset to NULL when `agents_trial_until` goes NULL → set. |
**Trial mirror** — `apps/api/app/org_plan_admin.py::_follow_purchase`: beside `agents_bought`: `org.agents_trial_until = sub.trial_end if sub.status == "trialing" and plan.per_agent else None`. When it moves NULL → value, also set `org.agents_trial_peak = None` (AS5 reset). Conversion, cancel and expiry clear it through the same mirror write (D6 snap-back).
**Trial cap** — `apps/api/app/plan_terms.py`: `TRIAL_AGENT_CEILING_DEFAULT: Final = 10` (the single home of 10); `trial_live(org, plan, defaults_row=None) -> bool` (`org.agents_trial_until > now(UTC)` and the plan is per agent); `trial_agents_of(org, plan, defaults_row=None) -> Optional[int]` (plan Max agents via `max_agents_of`, else `trial_agent_ceiling`, else 10; None when no trial is live). `caps_of` `runner_slots` order: `override` (AS1, unchanged) → **`trial`**: `Term(max(org.agents_bought or 0, trial_agents_of(...)), "trial", planned)` → `purchase` → `plan`. `active_worktrees` (AS2): while the runner-slot source is `trial` and `active_worktrees` has no override, its limit becomes `max(planned, trial value + retained)` (unlimited stays unlimited), source `trial`, computed in `org_limits._limit`. Add `"trial"` to every copy of the source vocabulary: `plan_terms.Source`, `org_limits.Source`, web `OrgLimit.source` (`apps/web/src/pages/orgs/api.ts`) and its hint in `apps/web/src/pages/orgs/overridesForm.ts` (label `Trial`). `org_limits.limit_reached_detail`: source `trial` → `"Trial limit reached: {n} agents."`. `org_wait_reason` unchanged.
**Peak demand** — `org_limits.DispatchBudget.take(self, org_id: Optional[UUID], task_id: Optional[int] = None) -> bool` records cap-refused `task_id`s per ledger (either `runner_slots` or `active_worktrees` spent); keeps per-ledger runner-slot usage at pass start (own `_runner_slots` read when both caps are unlimited but a trial is live); `demand(self, placed: Iterable[tuple[Optional[UUID], int]]) -> dict[UUID, int]` returns, for every ledger with a live per-agent trial (whatever the cap source), `usage_at_start + |placed| + |capped tasks never admitted this pass|`. `routes/fleet.py::fleet_allocation` gains `demand_out: Optional[dict[UUID, int]] = None`, passes `t.id` to `take`, and fills `demand_out` with `placed = [(t.org_id, t.id) for t in ordered if t.id in offered and t.id in table]`. `lease_drain.py` prelude (beside `dispatch_wait.record`, same try/except/rollback) calls new `app/trial_agents.py::record_peaks(session, demand)`: `UPDATE orgs SET agents_trial_peak = :n WHERE id = :org AND agents_trial_until > now() AND COALESCE(agents_trial_peak, 0) < :n`.
**Meter read (pinned seam C1 ↔ C4)** — `GET /api/org/plan/trial-agents` in `apps/api/app/routes/org_plan.py`, `@always(Tenancy.ORG_SCOPED)`, any member via `_member_session` (keeps its 404 for no tenant org), reading the terms org (staging → parent, AS3) on a fleet session. Logic in `app/trial_agents.py::meter(session, org)`; route only serialises:
```python
class TrialAgentsOut(BaseModel):
trialing: bool # False → every other field None; the meter does not render
peak: Optional[int] # max(agents_trial_peak or 0, current runner_slots usage)
signed_up: Optional[int] # orgs.agents_bought
trial_end: Optional[datetime] # orgs.agents_trial_until
trial_cap: Optional[int] # the lifted cap (caps_of runner_slots value)
min_agents: Optional[int] # min_agents_of — for the dialog stepper
max_agents: Optional[int] # max_agents_of; None = no upper bound
interval: Optional[str] # orgs.billing_interval — ChangeAgentsDialog prop
billed_with: Optional[str] # a staging org: its parent's name; else None
```
`trialing` is true only when `agents_trial_until > now`, the plan is per-agent and `agents_bought` is set. Web type + fetcher in `apps/web/src/lib/orgPlan.ts`: `TRIAL_AGENTS_QUERY = 'trial-agents'`, `getTrialAgents()`.
Tests (Architecture §4): `caps_of` matrix (trialing+plan max; trialing+ceiling; ceiling NULL → 10; override wins; non-trialing → bought; flat plan unchanged; trial_until in the past → bought; bought > ceiling → bought); placer demand (3+7 capped → 10; 3 + 7 no-node → 3; later drop keeps 10; refunded-then-admitted not counted); `_follow_purchase` mirror + reset; endpoint `{trialing:false}` for flat/active/cancelled, 404 for the operator plane, staging child reads parent; `org_limits._limit` AS2 (`active_worktrees` = `max(planned, trial + retained)`, source `trial`; unlimited stays unlimited; override wins); `limit_reached_detail` for source `trial` returns `"Trial limit reached: {n} agents."`.
Already-running trials at deploy: `agents_trial_until` stays NULL until their next entitlement event or the nightly reconcile (Architecture §5.3) — accepted; do not add a backfill.
Dev fixtures (deferred row below): this child creates them through the console before its dev check — Products → a per-agent plan with trial days; Customers → a test tenant org with a trialing subscription on N=3 (scale down if fewer node slots exist, e.g. ceiling 3 vs N=1). Leave them in place for C2–C4 and name them in the PR body.
Credentials & reach: (verbatim rows from the Architecture §7; the dev console host is `app.dev.jaah.ai` — the PRD's "dev.jaah.ai" means this host; operate dev only through the console UI)
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| Dev dispatch, sidebar, Upgrade, plan editor, trial-end move (criteria 1–6, 8–10) | `jaah/debug/main/operator-login` (operator; switches into tenants; Owner role in a test tenant; plain member created by that Owner in the console), dev CP `https://app.dev.jaah.ai` | verified — `credential_get` ok; `GET /` 200, `GET /api/auth/me` 401 (reached, unauthenticated) |
| Unit/API/Vitest/Playwright tests | worktree `./dc` stack only | verified — nothing beyond the worktree |
| Dev fixtures for criteria 1–3, 6, 8 (a per-agent plan with trial days, a trialing test-tenant subscription on dev's Stripe test account, a node with ≥ 8 free slots for that tenant) | same operator login, console only | deferred — fixture state, not access: the C1 builder creates it through the console (Products → plan; Customers → org + `set_trial_end`) before its dev check; scaled down if fewer slots exist (e.g. ceiling 3 vs N=1) |
Out of scope:
- Do NOT change trial length logic (`billing_plans.trial_days`, the `trial_days()` eligibility rules) or trial eligibility and reuse rules (`self_serve_rules.trial_used`).
- Do NOT lift `IN_TRIAL` for plan, interval or hosting changes. Only the agent-count change is opened.
- Do NOT auto-bill the peak, and do NOT change the agent count without an admin's explicit confirmation.
- Do NOT stop, pause or evict running tasks or sessions at trial end or on a cap change.
- Do NOT change caps or behaviour for flat (non-per-agent) plans, non-trialing subscriptions, or the other caps except as AS2 requires.
- Do NOT add a post-trial usage meter or a usage analytics page.
- Do NOT restructure the sidebar footer line (badge, name, version, Help). The meter is an addition above it.
- Do NOT add nag modals, toasts or emails for upgrade prompting.
- Deferred, do NOT build: post-trial used-vs-bought meter; operator Customers view of trial peak; `customer.subscription.trial_will_end` notices.
- Do NOT build the sidebar meter UI, the plan-editor field, or the trial agent-change (billing) path — those are sibling children C4, C2, C3. Only the web type/fetcher in `lib/orgPlan.ts` and the `"trial"` source label in the operator limits panel belong here.
- Do NOT read `billing_subscriptions` inside `caps_of` (hot path; use the mirror column).
Done means:
- **1. Cap lifted during trial.** A trial org's runner-slot cap equals the plan's Max agents, or the trial ceiling when the plan has none, or 10 when that is blank. It is not `agents_bought`.
→ Verify on dev: give a trial org with N=3 (plan max blank, ceiling 10) 8 ready tasks. Observe 8 dispatched at once in the Tasks list, given enough node capacity. Then set the ceiling to 5 and requeue: observe 5 running and 3 held. (Until C2 ships the ceiling field, run this leg by setting the plan's existing Max agents to 5 in the plan editor — `trial_agents_of` reads Max agents first — then restore it; never set the column by SQL. C2's Done 9 re-runs the ceiling leg.)
→ Unit test: `caps_of`/`dispatch_headroom` covers each state (trialing with plan max; trialing with ceiling; ceiling blank → 10; override present; non-trialing → `agents_bought`; flat plan → unchanged).
- **2. Cap snaps back at conversion.** After the trial converts, the cap is the paid count. Already-running tasks keep running.
→ Verify on dev: move the trial end to now (the operator `set_trial_end` in the Customers panel) while 8 tasks run with N=3. Observe 8 still running, and new ready tasks held until running drops below 3.
- **3. Peak demand tally (D1).** The persisted peak = max over the trial of (running + ready tasks held only by the org agent cap). Tasks held only by node or worktree capacity do not raise it. It never decreases during a trial and resets on a new trial.
→ Unit test on the placer: (a) 3 running + 7 held by the cap → peak 10; (b) 3 running + 7 held for lack of a node → peak 3; (c) the count later drops → peak stays 10.
→ Verify on dev: in the criterion 1 scenario with ceiling 5, `GET /api/org/plan/trial-agents` (as seen by the console's network panel) returns `peak: 8, signed_up: 3`. The sidebar `8 / 3` rendering is C4's oracle.
- **5 (API half). Meter endpoint hides non-applicable states.** `GET /api/org/plan/trial-agents` answers `{trialing:false}` with every other field null for a converted (`active`), cancelled/ended, free/unpaid org and a flat-plan trial, and 404 for an operator principal with no tenant org; a staging child returns its parent's figures with `billed_with` = parent name. → API tests.
- **10. On by default, real deployment.** No feature flag gates criteria 1–3. → Verify: they pass on `app.dev.jaah.ai` with no manual toggle.
- AS2 + refusal sentence: unit tests prove `active_worktrees` never holds trial dispatch below the trial cap (retained VMs included) and a creation door refuses with `"Trial limit reached: {n} agents."`.
- Quality: the hot path adds no per-task query; peak writes happen only for trial orgs and only when the peak grows (a naive "peak = max running" that would show 5/3 when 8 were wanted FAILS criterion 3a).
Outcome
Trial agent cap (plan max → ceiling → 10), peak-demand tally and GET /api/org/plan/trial-agents merged (#6614, f49b90d); quick gate and changed-area tests green; dev deploy ok — cap 10/trial and meter verified on dev; the dispatch legs were not run on dev (the fixture tenant has no node). Fixtures: plan trialagents, org t8937-trial-agents. Visual check done: Trial hint renders cleanly. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)