Skip to content

feat(webapp): enforce watch plan limits - #4556

Open
kathiekiwi wants to merge 10 commits into
feat/agent-message-quota-tri-12863from
feat/agent-watch-limits-tri-12863
Open

feat(webapp): enforce watch plan limits#4556
kathiekiwi wants to merge 10 commits into
feat/agent-message-quota-tri-12863from
feat/agent-watch-limits-tri-12863

Conversation

@kathiekiwi

Copy link
Copy Markdown
Collaborator

What & why. Watches now honour a plan's watch limits. A watch whose window exceeds the plan's agentWatchMaxHours, or that would push the org past its agentWatchers count, is refused with a new watch_limit_reached result and an upgrade hint (a chat line on the card, HTTP 409 on the API).

Key decisions.

  • Plan limits are a floor below the existing code ceilings: min(plan, WATCH_MAX_HOURS=24) for the window, and the per-chat cap of 3 still applies independently. Plans only tighten, never loosen.
  • Watcher count is org-wide and checked only after the immediate check declines, so a one-shot consumes no slot.
  • Fails open: an absent limit resolves to the unlimited sentinel, so self-hosted is unaffected; the upgrade nudge is gated on isBillingConfigured().
  • Follow-up: quiet Pro-mark on the card's long-window options.

TRI-12863

Refuse a watch whose window exceeds the plan's agentWatchMaxHours, or that
would push the org past its agentWatchers count, with a new watch_limit_reached
result carrying an upgrade hint. Plan limits are a floor below the existing
code ceilings (min(plan, WATCH_MAX_HOURS=24) and the per-chat cap of 3, which
still apply independently). Fails open: an absent limit resolves to unlimited,
so self-hosted is unaffected and the upgrade nudge is gated on billing presence.

TRI-12863
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 743644b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10db2266-bc8b-413a-8b05-d5349cbe818e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines +343 to +348
return {
ok: false,
code: "watch_limit_reached",
error: hint("That watch window is longer than your plan allows."),
};
}

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.

🟡 Dashboard watch card refusals from plan limits return a server error instead of a normal rejection

The new plan-limit refusal is returned with a code the dashboard's watch-submit endpoint doesn't recognise (code: "watch_limit_reached" at apps/webapp/app/services/dashboardAgentWatches.server.ts:345), so a perfectly ordinary "your plan doesn't allow this" answer is sent back as an internal server error.
Impact: Users hitting their plan's watch limit from the dashboard card trigger 500 responses, which pollute error monitoring and can be treated as outages rather than expected rejections.

Status mapping in the dashboard route lacks the new refusal code

createDashboardAgentWatch now returns watch_limit_reached for both the window floor (apps/webapp/app/services/dashboardAgentWatches.server.ts:342-348) and the watcher-count floor (apps/webapp/app/services/dashboardAgentWatches.server.ts:367-374). This code propagates through submitDashboardAgentWatch (SubmitWatchErrorCode = CreateWatchErrorCode | "request_conflict").

The MCP route was updated (apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts:113-118 maps it to 409), but the dashboard resource route's ladder at apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts:542-551 only lists limit_reached, duplicate, request_conflict, invalid_target, chat_not_found, not_configured, and otherwise falls through to 500. A plan-limit refusal therefore returns HTTP 500 with the upgrade message in the body.

Prompt for agents
The new refusal code `watch_limit_reached` produced by createDashboardAgentWatch is not handled by the dashboard watch-submit route's HTTP status ladder in apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts (around lines 542-551), so plan-limit refusals fall through to 500. The MCP route api.v1.dashboard-agent.watches.ts was updated to map it to 409. Update the dashboard route's mapping to treat watch_limit_reached the same way (409), keeping the two routes consistent.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +339 to +348
// Plan floors sit below the code ceilings (min(plan, ceiling)). Fails open: an absent
// limit resolves to unlimited, so neither floor bites on self-hosted.
const planLimits = await resolveLimits(environment.organizationId);
if (spec.maxHours > effectiveWatchMaxHours(planLimits.maxHours)) {
return {
ok: false,
code: "watch_limit_reached",
error: hint("That watch window is longer than your plan allows."),
};
}

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.

🔍 Window floor also refuses one-shots, unlike the watcher-count floor

The window check runs before the immediate check, while the watcher-count check deliberately runs after it (apps/webapp/app/services/dashboardAgentWatches.server.ts:364-374) so that a one-shot consumes no slot. Consequence: a request whose condition is already satisfied — which would create no row at all — is still refused purely because the requested window exceeds the plan's agentWatchMaxHours. If the intent is "plan limits constrain what is actually persisted", the window check should arguably also sit after the immediate check, or the card should clamp the window instead of refusing.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9 to +12
// Filled by cloud billing (TRI-12863 P0). Absent until then, and always on self-hosted, so
// the fallback applies and the plan floor is off.
const WATCH_MAX_HOURS_LIMIT_KEY = "agentWatchMaxHours" as keyof Limits;
const WATCH_COUNT_LIMIT_KEY = "agentWatchers" as keyof Limits;

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.

🔍 Limit keys are cast to keyof Limits before they exist in the platform type

agentWatchMaxHours and agentWatchers are asserted into keyof Limits. Until the cloud billing side ships those keys, getLimit will never find them and both resolve to the unlimited sentinel — the documented fail-open behaviour. Worth tracking that the string names here match exactly what billing emits; a typo would silently keep limits disabled forever with no signal, since the fallback path is indistinguishable from "not configured".

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread apps/webapp/app/services/dashboardAgentWatches.server.ts
Comment on lines +21 to +25
async function readLimit(organizationId: string, key: keyof Limits): Promise<number> {
const cached = await getCachedLimit(organizationId, key, UNLIMITED_WATCH_LIMIT);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_WATCH_LIMIT;
}

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.

🔍 A plan limit configured as 0 is treated as unlimited

readLimit delegates to getCachedLimit -> getLimit, which does if (!result) return fallback (apps/webapp/app/services/platform.v3.server.ts:426). A plan that legitimately sets agentWatchers: 0 or agentWatchMaxHours: 0 is therefore indistinguishable from an absent limit and falls open to the unlimited sentinel. Worth knowing before the cloud side (TRI-12863 P0) starts publishing these keys — a "no watches" tier cannot be expressed with 0.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@743644b

trigger.dev

npm i https://pkg.pr.new/trigger.dev@743644b

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@743644b

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@743644b

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@743644b

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@743644b

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@743644b

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@743644b

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@743644b

commit: 743644b

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +502 to +511
export async function countActiveWatchesForOrg(
db: DashboardAgentDb,
params: { organizationId: string }
): Promise<number> {
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(watches)
.where(and(eq(watches.status, "active"), eq(watches.organizationId, params.organizationId)));
return rows[0]?.count ?? 0;
}

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.

🔍 Expired-but-unswept watches still consume plan slots

countActiveWatchesForOrg counts every row with status = 'active', including watches whose expiresAt has already passed but whose tick/sweep hasn't yet flipped them to expired. On an org at its plan count, this can transiently refuse a legitimate new watch until the sweep runs. Adding expiresAt > now() to the predicate (or relying on the sweep cadence being short) would avoid the surprise; worth confirming the sweep interval relative to the smallest plan count.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +131 to +153
// Past the cap the card will never render; force it terminal without the render
// path so it leaves the queue instead of looping forever.
if (attempts !== null && attempts >= MAX_SWEEP_ATTEMPTS) {
try {
await forceAbandon({ id: investigation.id, note: UNSETTLED_INVESTIGATION_NOTE });
result.abandoned++;
logger.warn(
"Dashboard agent investigation sweep: abandoned a card past the attempt cap",
{
investigationId: investigation.id,
chatId: investigation.chatId,
attempts,
}
);
continue;
} catch (abandonError) {
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: abandonError,
});
}
}

@devin-ai-integration devin-ai-integration Bot Aug 10, 2026

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.

🔍 Attempt cap can force-abandon a renderable card after transient settle failures

The cap treats 5 failed settles as proof the card "will never render", but settleInvestigationAndCloseCard can also throw for transient reasons (e.g. a conflict inside appendChatMessageOnceByChatId, a connection reset while the attempt-recording write still succeeds). The maintenance job runs every 5 minutes with maxAttempts: 1 (apps/webapp/app/v3/commonWorker.server.ts:165-172), so ~25 minutes of intermittent-but-not-total failure is enough to force-settle a perfectly renderable row without its closing card — which is exactly the permanent spinner settleInvestigationAndCloseCard exists to prevent. Note a full DB outage is safe here (the attempt write fails too, leaving attempts null). Consider only counting attempts for the non-renderable error, or resetting sweep_attempts when the error differs.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +367 to +374
const activeCount = await countActiveWatches(environment.organizationId);
if (activeCount >= planLimits.watchers) {
return {
ok: false,
code: "watch_limit_reached",
error: hint("You've reached the number of active watches your plan allows."),
};
}

@devin-ai-integration devin-ai-integration Bot Aug 10, 2026

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.

🔍 Refusals are recorded in the submission ledger, so a retry after upgrading replays the refusal

A watch_limit_reached refusal from the card submit path goes through refuse(...), which writes the refusal into the submission ledger; a subsequent retry with the same clientRequestId replays the recorded refusal verbatim instead of re-evaluating (apps/webapp/app/services/dashboardAgentWatches.server.ts:764-778). A user who upgrades their plan and retries the same card must start a fresh submission (new request id) to succeed. This matches the existing ledger semantics for other refusals, but plan limits are the first refusal class that a user can clear themselves, so the UX is worth checking on the card side.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +1 to +6
---
area: webapp
type: fix
---

A single stuck assistant investigation can no longer hold up others from being tidied away.

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.

🟡 Pull request bundles two unrelated changes

The change set combines the watch plan-limit feature with an unrelated fix to how stuck assistant investigations are swept (.server-changes/dashboard-agent-investigation-sweep-backoff.md), which the repository's contribution rules forbid.

Impact: Reviewers and release notes mix two independent behaviours, and either half cannot be reverted on its own.

Rule reference

CONTRIBUTING.md states: "We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one." This PR contains the watch plan-limit feature (apps/webapp/app/services/dashboardAgentWatchLimits.server.ts, dashboardAgentWatches.server.ts) and the investigation sweep backoff (apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts, new sweep_attempts schema/migration).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +21 to +37
async function readLimit(organizationId: string, key: keyof Limits): Promise<number> {
const cached = await getCachedLimit(organizationId, key, UNLIMITED_WATCH_LIMIT);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_WATCH_LIMIT;
}

/**
* The org's plan floors for watches. Fails open: an absent limit (self-hosted, or before the
* cloud side ships) resolves to the unlimited sentinel, so neither floor bites.
*/
export async function resolveWatchPlanLimits(organizationId: string): Promise<WatchPlanLimits> {
const [maxHours, watchers] = await Promise.all([
readLimit(organizationId, WATCH_MAX_HOURS_LIMIT_KEY),
readLimit(organizationId, WATCH_COUNT_LIMIT_KEY),
]);
return { maxHours, watchers };
}

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.

🔍 Plan-limit resolution is not wrapped in a fail-open try/catch, unlike the message quota

resolveWatchPlanLimits relies on getCachedLimit never throwing to fail open. The analogous message-quota reader wraps the whole resolution in a try/catch and returns undefined (no cap) on any throw (apps/webapp/app/services/dashboardAgentQuota.server.ts:55-70). If the platform cache layer ever throws (e.g. a Redis error surfacing out of platformCache.limits.swr), watch creation will reject with a 500 rather than falling open to unlimited, which contradicts the "fails open" contract documented on this function.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

The card-submit route's status ladder didn't handle watch_limit_reached, so a plan-limit refusal fell through to HTTP 500. Match the MCP route and return 409.
drizzle-kit generated them unformatted, failing the oxfmt --check code-quality gate.
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