Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f9d674e
feat(webapp): enforce watch plan limits
kathiekiwi Aug 10, 2026
00933ab
Merge remote-tracking branch 'origin/feat/agent-message-quota-tri-128…
kathiekiwi Aug 10, 2026
063e41e
merge: propagate review fixes from feat/agent-message-quota-tri-12863
kathiekiwi Aug 10, 2026
21f5461
merge: propagate wave-2 review fixes from feat/agent-message-quota-tr…
kathiekiwi Aug 10, 2026
2dd410d
fix(webapp,dashboard-agent-db): stop a stuck investigation pinning th…
kathiekiwi Aug 10, 2026
7d2efc9
merge: propagate org-purge best-effort from feat/agent-message-quota-…
kathiekiwi Aug 10, 2026
bce03cb
fix(webapp): map a watch plan-limit refusal to 409, not 500
kathiekiwi Aug 10, 2026
2db7c39
style(dashboard-agent-db): oxfmt the drizzle meta files
kathiekiwi Aug 10, 2026
3fbc04a
merge: watch plan-limit 409 review-comment fixes
kathiekiwi Aug 10, 2026
743644b
merge: propagate review-comment fixes from feat/agent-message-quota-t…
kathiekiwi Aug 10, 2026
f695267
fix(webapp): hoist a type-only import so oxlint stops failing
kathiekiwi Aug 11, 2026
f64f238
merge: hoist type-only import for oxlint
kathiekiwi Aug 11, 2026
f080779
merge: propagate second-pass fixes from feat/agent-message-quota-tri-…
kathiekiwi Aug 11, 2026
3fd5cf4
chore(server-changes): consolidate the watch-limits notes into one
kathiekiwi Aug 11, 2026
946831b
merge: consolidate watch-limits notes 2 to 1
kathiekiwi Aug 11, 2026
47139f6
merge: propagate server-changes consolidation from feat/agent-message…
kathiekiwi Aug 11, 2026
49f64a6
merge: propagate changeset consolidation and note restoration from fe…
kathiekiwi Aug 11, 2026
526c3fc
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
26ab506
merge: propagate tsql linter test fix
kathiekiwi Aug 11, 2026
0cbe4c0
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
e4b02df
chore: merge feat/agent-message-quota-tri-12863 (main sync)
kathiekiwi Aug 11, 2026
d7c7fb7
chore: merge feat/agent-message-quota-tri-12863 (review fixes)
kathiekiwi Aug 11, 2026
cf74d85
chore: merge feat/agent-message-quota-tri-12863 (review fixes round 2)
kathiekiwi Aug 11, 2026
372a4eb
fix(webapp): honor zero watch limits and answer instant questions bef…
kathiekiwi Aug 11, 2026
d4bda0a
chore: merge feat/agent-message-quota-tri-12863 (review fixes round 3)
kathiekiwi Aug 11, 2026
8f93f80
chore: merge feat/dashboard-agent-flows-watch (review fixes round 3)
kathiekiwi Aug 11, 2026
b9656c0
chore: merge feat/agent-message-quota-tri-12863 (review fixes round 3)
kathiekiwi Aug 11, 2026
aced730
chore: merge feat/agent-message-quota-tri-12863 (composer escape foll…
kathiekiwi Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/agent-watch-plan-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more.
4 changes: 3 additions & 1 deletion apps/webapp/app/routes/api.v1.dashboard-agent.watches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export async function action({ request }: ActionFunctionArgs) {

if (!result.ok) {
const status =
result.code === "limit_reached" || result.code === "duplicate"
result.code === "limit_reached" ||
result.code === "watch_limit_reached" ||
result.code === "duplicate"
? 409
: result.code === "invalid_target"
? 404
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (!result.ok) {
const status =
result.code === "limit_reached" ||
result.code === "watch_limit_reached" ||
result.code === "duplicate" ||
result.code === "request_conflict"
? 409
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

import {
listStaleOpenInvestigations,
recordInvestigationSweepAttempt,
settleInvestigationAndCloseCard,
settleInvestigationAsInconclusive,
type Investigation,
type SettledInvestigation,
type SettledInvestigationCard,
} from "@internal/dashboard-agent-db";
import { UNSETTLED_INVESTIGATION_NOTE } from "@internal/dashboard-agent-contracts";
Expand All @@ -22,6 +25,13 @@ export const INVESTIGATION_STALE_MS = 30 * 60 * 1000;
/** Per-run cap. Oldest first, so the rest land next run. */
const SWEEP_BATCH_LIMIT = 100;

/**
* After this many failed settle attempts a row is force-abandoned: settled `inconclusive`
* WITHOUT the closing card, so a card that never renders leaves the queue instead of
* looping forever. The rare stuck spinner is the price of not starving every other row.
*/
export const MAX_SWEEP_ATTEMPTS = 5;

export type InvestigationSweepResult = {
/** Stale `in_progress` rows seen. */
stale: number;
Expand All @@ -30,6 +40,8 @@ export type InvestigationSweepResult = {
closed: number;
/** A turn (or another sweep) settled it first. */
alreadySettled: number;
/** Rows past the attempt cap, force-settled without a card so they leave the queue. */
abandoned: number;
failed: number;
};

Expand All @@ -46,6 +58,10 @@ export type InvestigationSweepDeps = {
chatId: string;
note: string;
}) => Promise<SettledInvestigationCard | null>;
/** Record a failed settle out-of-band; returns the new attempt count, or null if gone. */
recordAttempt?: (params: { id: string }) => Promise<number | null>;
/** Force a poison row terminal without the failing render path. */
forceAbandon?: (params: { id: string; note: string }) => Promise<SettledInvestigation | null>;
};

/**
Expand All @@ -61,12 +77,17 @@ export async function sweepDashboardAgentInvestigations(
deps.listStale ?? ((params) => listStaleOpenInvestigations(dashboardAgentDb, params));
const settleAndClose =
deps.settleAndClose ?? ((params) => settleInvestigationAndCloseCard(dashboardAgentDb, params));
const recordAttempt =
deps.recordAttempt ?? ((params) => recordInvestigationSweepAttempt(dashboardAgentDb, params));
const forceAbandon =
deps.forceAbandon ?? ((params) => settleInvestigationAsInconclusive(dashboardAgentDb, params));

const result: InvestigationSweepResult = {
stale: 0,
settled: 0,
closed: 0,
alreadySettled: 0,
abandoned: 0,
failed: 0,
};

Expand All @@ -93,10 +114,49 @@ export async function sweepDashboardAgentInvestigations(
result.settled++;
if (outcome.closed) result.closed++;
} catch (error) {
// The settle rolled back, so the row is still `in_progress`. Record the attempt in
// its own write — this rotates the row to the back of the sweep order (see
// `listStaleOpenInvestigations`) so it can't pin the head and starve newer rows.
let attempts: number | null = null;
try {
attempts = await recordAttempt({ id: investigation.id });
} catch (recordError) {
logger.error("Dashboard agent investigation sweep: failed to record a sweep attempt", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: recordError,
});
}

// 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;
Comment thread
kathiekiwi marked this conversation as resolved.
} catch (abandonError) {
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: abandonError,
});
}
}
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.

result.failed++;
logger.error("Dashboard agent investigation sweep: failed to settle an investigation", {
investigationId: investigation.id,
chatId: investigation.chatId,
attempts,
error,
});
}
Expand Down
59 changes: 59 additions & 0 deletions apps/webapp/app/services/dashboardAgentWatchLimits.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Limits } from "@trigger.dev/platform";
import { WATCH_MAX_HOURS } from "@internal/dashboard-agent-contracts";
import { getCachedLimitAllowingZero, isBillingConfigured } from "./platform.v3.server";

// The unlimited sentinel, matching the message quota (TRI-12863 P1). Never Infinity: it
// serializes to null in the limit cache.
export const UNLIMITED_WATCH_LIMIT = 100_000_000;

// 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;
Comment thread
kathiekiwi marked this conversation as resolved.

export type WatchPlanLimits = {
/** Longest window one watch may run for, in hours. */
maxHours: number;
/** How many active watches the org may run at once. */
watchers: number;
};

async function readLimit(organizationId: string, key: keyof Limits): Promise<number> {
// A plan of 0 means zero, not absent: an org with watches switched off must not read as
// unlimited. Only a missing limit falls open.
const cached = await getCachedLimitAllowingZero(organizationId, key, UNLIMITED_WATCH_LIMIT);
// A cache error leaves `val` empty; fall open to unlimited.
return cached.val ?? UNLIMITED_WATCH_LIMIT;
}
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.

/**
* 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. `read` is the
* plan-limit seam: tests pass their own reader instead of the cached platform one.
*/
export async function resolveWatchPlanLimits(
organizationId: string,
read: (organizationId: string, key: keyof Limits) => Promise<number> = readLimit
): Promise<WatchPlanLimits> {
const [maxHours, watchers] = await Promise.all([
read(organizationId, WATCH_MAX_HOURS_LIMIT_KEY),
read(organizationId, WATCH_COUNT_LIMIT_KEY),
]);
return { maxHours, watchers };
}
Comment thread
kathiekiwi marked this conversation as resolved.

/**
* The window ceiling actually in force: the plan floor under the code ceiling. A plan that
* allows 100 hours still caps at {@link WATCH_MAX_HOURS}.
*/
export function effectiveWatchMaxHours(planMaxHours: number): number {
return Math.min(planMaxHours, WATCH_MAX_HOURS);
}

/**
* A watch-limit refusal, plus an upgrade nudge when billing is present. Self-hosted never
* hits this (fails open above), and the nudge is gated so a stray refusal stays quiet there.
*/
export function watchLimitHint(base: string, billingConfigured = isBillingConfigured()): string {
return billingConfigured ? `${base} Upgrade your plan for more.` : base;
}
42 changes: 42 additions & 0 deletions apps/webapp/app/services/dashboardAgentWatches.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
cancelWatch,
chatExists,
claimWatchSubmission,
countActiveWatchesForOrg,
createChat,
createWatch,
generateWatchId,
Expand Down Expand Up @@ -68,6 +69,12 @@ import {
import { watchCreationCheckDeps } from "~/services/dashboardAgentWatchChecks.server";
import { normalizeErrorFingerprint } from "~/services/dashboardAgentWatchErrorChecks";
import { subscribeUserToWatchAlerts } from "~/services/dashboardAgentWatchAlerts.server";
import {
effectiveWatchMaxHours,
resolveWatchPlanLimits,
watchLimitHint,
type WatchPlanLimits,
} from "~/services/dashboardAgentWatchLimits.server";
import {
mintDashboardAgentWatchBatchToken,
mintDashboardAgentWatchToken,
Expand Down Expand Up @@ -165,6 +172,7 @@ export async function authorizeWatchEnvironmentById(params: {

export type CreateWatchErrorCode =
| "limit_reached"
| "watch_limit_reached"
| "duplicate"
| "invalid_target"
| "chat_not_found"
Expand Down Expand Up @@ -273,6 +281,12 @@ export async function createDashboardAgentWatch(params: {
scheduleTick?: typeof scheduleWatchTick;
/** Skip the real trigger-config gate when a tick scheduler is injected. */
configured?: () => boolean;
/** Plan floors on window and count. Fails open to unlimited when absent. */
resolveLimits?: (organizationId: string) => Promise<WatchPlanLimits>;
/** Org-wide active-watch count, for the watcher-count floor. */
countActiveWatches?: (organizationId: string) => Promise<number>;
/** Gates the upgrade nudge, so self-hosted stays quiet. */
billingConfigured?: () => boolean;
};
}): Promise<CreateDashboardAgentWatchResult> {
const { environment, userId, chatId } = params;
Expand All @@ -284,6 +298,11 @@ export async function createDashboardAgentWatch(params: {
const buildCheckDeps = params.deps?.checkDeps ?? watchCreationCheckDeps;
const scheduleTick = params.deps?.scheduleTick ?? scheduleWatchTick;
const isDashboardAgentConfigured = params.deps?.configured ?? isDashboardAgentConfiguredDefault;
const resolveLimits = params.deps?.resolveLimits ?? resolveWatchPlanLimits;
const countActiveWatches =
params.deps?.countActiveWatches ??
((organizationId: string) => countActiveWatchesForOrg(dashboardAgentDb, { organizationId }));
const hint = (base: string) => watchLimitHint(base, params.deps?.billingConfigured?.());
const checkDeps = buildCheckDeps(environment, now);

if (!isDashboardAgentConfigured()) {
Expand Down Expand Up @@ -331,6 +350,29 @@ export async function createDashboardAgentWatch(params: {
return { ok: true, watching: false, identity, immediate };
}

// Both floors are read only now the immediate check didn't answer: a one-shot creates no
// row, so a plan floor must not turn an answerable question into an upgrade nudge. Plan
// floors sit below the code ceilings (min(plan, ceiling)) and fail open: an absent limit
// resolves to unlimited, so neither 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."),
};
}

// The per-chat cap of 3 still applies independently, in `createWatch`.
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."),
};
}
Comment thread
kathiekiwi marked this conversation as resolved.

const expiresAt = new Date(now.getTime() + spec.maxHours * 60 * 60 * 1000);

const created = await createWatch(dashboardAgentDb, {
Expand Down
31 changes: 31 additions & 0 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,37 @@ export async function getCachedLimit(orgId: string, limit: keyof Limits, fallbac
});
}

/**
* Reads one plan limit, treating 0 as zero rather than absent: only a missing limit falls back.
* {@link getLimit} keeps its `!result` fallback, which its callers depend on.
*/
export function limitValueAllowingZero(
limits: Limits | undefined,
limit: keyof Limits,
fallback: number
): number {
const result = limits?.[limit];

if (result === undefined || result === null) return fallback;
if (typeof result === "number") return result;
if (typeof result === "object" && "number" in result) return result.number;
return fallback;
}

/**
* Like {@link getCachedLimit}, but a plan value of 0 means zero. Cached under its own key so it
* never crosses with {@link getCachedLimit}.
*/
export async function getCachedLimitAllowingZero(
orgId: string,
limit: keyof Limits,
fallback: number
) {
return platformCache.limits.swr(`${orgId}:${limit}:allow-zero`, async () =>
limitValueAllowingZero(await getLimits(orgId), limit, fallback)
);
}
Comment on lines +506 to +514

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.

🟡 Users who upgrade are still told to upgrade before creating a watch, for up to ten minutes

The org's watch allowance is read from a cache that is never cleared when the plan changes (platformCache.limits.swr at apps/webapp/app/services/platform.v3.server.ts:511), so a customer who follows the upgrade prompt keeps getting the same refusal until the cached value ages out.
Impact: After paying to upgrade, a user can be blocked from creating watches — and shown "Upgrade your plan for more" again — for up to ten minutes.

Why the plan change doesn't reach the watch limit read

resolveWatchPlanLimits (apps/webapp/app/services/dashboardAgentWatchLimits.server.ts:34-43) reads both floors through getCachedLimitAllowingZero, which stores under ${orgId}:${limit}:allow-zero in the limits namespace (fresh 5 min, stale 10 min, see apps/webapp/app/services/platform.v3.server.ts:172-176).

setPlan invalidates plan-derived caches on every successful plan change (apps/webapp/app/services/platform.v3.server.ts:587), but invalidatePlanDerivedCaches only removes entitlement and ssoEntitlement (apps/webapp/app/services/platform.v3.server.ts:226-229); nothing ever calls platformCache.limits.remove. The refusal in createDashboardAgentWatch (apps/webapp/app/services/dashboardAgentWatches.server.ts:357-374) therefore keeps using the pre-upgrade limits. This was harmless for the existing getCachedLimit callers (concurrency defaults), but this PR makes the stale value directly user-facing behind an explicit "Upgrade your plan" call to action.

Prompt for agents
The watch plan floors are read via getCachedLimitAllowingZero, which caches under the `limits` namespace with a 5 min fresh / 10 min stale window. No code path ever removes entries from that namespace: invalidatePlanDerivedCaches in apps/webapp/app/services/platform.v3.server.ts only clears `entitlement` and `ssoEntitlement`, even though it is called after every successful plan change in setPlan. As a result, an org that upgrades in response to the new 'Upgrade your plan for more' hint from dashboardAgentWatchLimits.server.ts keeps hitting the old, tighter watch limits until the cache expires. Consider extending the plan-change invalidation so the limit keys used by the watch floors (both the plain `${orgId}:${limit}` and the new `${orgId}:${limit}:allow-zero` variants) are removed when a plan changes, or otherwise ensure the watch limit read observes a plan change promptly.
Open in Devin Review

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


export async function customerPortalUrl(orgId: string, orgSlug: string) {
if (!client) return undefined;

Expand Down
Loading
Loading