Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

A single stuck assistant investigation can no longer hold up others from being tidied away.
Comment on lines +1 to +6

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.

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 @@ -545,6 +545,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 on lines +134 to +145

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.

🟡 Clean-up run reports investigations as force-closed when another process already closed them

An investigation is counted and logged as force-closed (forceAbandon at apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts:135-145) even when the call reports that nothing was changed because it had already been closed normally, so the run's reported figures and warnings misstate what happened.

Impact: Operators see warnings about abandoned investigations that were actually settled cleanly, making it harder to spot genuinely stuck ones.

Mechanism

settleInvestigationAsInconclusive returns null when the row is no longer in_progress (internal-packages/dashboard-agent-db/src/queries.ts:1097-1125), i.e. a concluding turn or another sweep won the race. The sweep ignores the return value, unconditionally doing result.abandoned++ and emitting logger.warn("...abandoned a card past the attempt cap"). The correct classification in that case is alreadySettled.

Suggested change
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;
try {
const abandoned = await forceAbandon({
id: investigation.id,
note: UNSETTLED_INVESTIGATION_NOTE,
});
if (!abandoned) {
result.alreadySettled++;
continue;
}
result.abandoned++;
logger.warn(
"Dashboard agent investigation sweep: abandoned a card past the attempt cap",
{
investigationId: investigation.id,
chatId: investigation.chatId,
attempts,
}
);
continue;
Open in Devin Review

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

} catch (abandonError) {
logger.error("Dashboard agent investigation sweep: failed to abandon a poison card", {
investigationId: investigation.id,
chatId: investigation.chatId,
error: abandonError,
});
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +131 to +153

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.

🟡 Investigations that fail for temporary reasons get permanently closed with no visible answer

Every failed clean-up attempt on an investigation is counted the same way (recordAttempt at apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts:122) regardless of whether the failure was temporary, so after five such failures the investigation is closed without ever showing its closing message to the user.

Impact: A user can be left with a permanently spinning investigation card that never resolves, even though the underlying investigation was perfectly displayable and only hit transient database or delivery errors.

Why transient failures accumulate toward the poison-row cap

sweepDashboardAgentInvestigations treats any throw from settleAndClose as evidence that the card "will never render" (comment at apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts:131-133). But settleInvestigationAndCloseCard (internal-packages/dashboard-agent-db/src/queries.ts:1140-1162) can throw for reasons other than an unrenderable state — any error in the transaction (append conflict, connection blip, statement timeout) surfaces the same way. The counter sweepAttempts is monotonic and is never reset on a successful run or after a long quiet period, so unrelated failures spread over the row's lifetime add up. Worse, a failed run rethrows at the end (:105-109) so the job retries immediately, and each retry increments the counter again — a short-lived fault affecting only the settle transaction can burn all five attempts within seconds. At the cap, forceAbandon (settleInvestigationAsInconclusive) marks the row terminal without appending the closing card, which is exactly the permanent spinner the transactional design was built to avoid.

A narrower trigger (e.g. only counting attempts when the error indicates an unrenderable state, or resetting/aging the counter) would keep the starvation fix without abandoning healthy rows.

Prompt for agents
In apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts, the attempt counter that drives force-abandonment is incremented for every failure of settleAndClose, not just for failures caused by an unrenderable investigation state. settleInvestigationAndCloseCard throws a specific error for the unrenderable case, but it can also throw for transient reasons (connection errors, timeouts, append conflicts). Because a failed run rethrows and the job is retried, a short transient fault can burn through MAX_SWEEP_ATTEMPTS quickly, after which the row is settled without its closing card — leaving the user's investigation card spinning forever, which is precisely what the transactional settle exists to prevent. Consider distinguishing the unrenderable-state error from other failures (e.g. a typed/marker error thrown by settleInvestigationAndCloseCard) and only counting attempts for that case, and/or ageing the counter so unrelated failures spread over time don't accumulate.
Open in Devin Review

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


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

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.

🔍 Watch service now transitively constructs the platform cache (Redis client) at import time

dashboardAgentWatchLimits.server.ts imports platform.v3.server, whose module-level platformCache singleton constructs a RedisCacheStore (and hence an ioredis client) as a side effect of import (apps/webapp/app/services/platform.v3.server.ts:156-209). Because dashboardAgentWatches.server.ts now imports the limits module statically, every consumer of the watch service — including the several existing postgres-only test files that import it without a Redis container — will open that connection at import. Worth confirming the existing watch test suites still exit cleanly (no open handles / connection-retry noise); the existing pattern elsewhere (realtimeClientGlobal.server.ts) keeps the cached-limit provider behind a configuration module for this reason.

Open in Devin Review

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


// 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 on lines +9 to +12

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.


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> {
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;
}
Comment on lines +21 to +25

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.


/**
* 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 };
}
Comment on lines +21 to +37

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 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 @@ -317,6 +336,17 @@ export async function createDashboardAgentWatch(params: {
});
if (!precheck.ok) return creationGuardrailError(precheck);

// 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."),
};
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Comment on lines +343 to +348

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

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.


// `since` is server-set so the model can't backdate a recurrence window.
const persistedSpec: PersistedWatchSpec =
spec.kind === "error_recurrence" ? { ...spec, since: now.toISOString() } : spec;
Expand All @@ -331,6 +361,18 @@ export async function createDashboardAgentWatch(params: {
return { ok: true, watching: false, identity, immediate };
}

// Counted only now the immediate check didn't answer: a one-shot creates no row and so
// consumes no watcher slot. 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 on lines +367 to +374

@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.


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

const created = await createWatch(dashboardAgentDb, {
Expand Down
Loading
Loading