feat: show the assignee's provider usage limit in task properties (#19270)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run on the native Runner through the Codex app-server and the
Claude ACP harness
> - Both harnesses report the provider account's rate-limit windows:
Codex sends `account/rateLimits/updated`, and Claude sends its SDK
`rate_limit_event` as `usage_update._meta["_claude/rateLimit"]`
> - Paperclip dropped both signals, so an operator could not see that an
agent was near or past its provider limit
> - This pull request normalizes both signals into one account-scoped
snapshot, keeps the latest snapshot per agent, and shows it as one
"Usage limit" line in the task properties pane
> - The benefit is that an operator sees "5h 78% · resets 15:40" (amber
above 80%, "Limited until …" when the provider refuses work, "using
credits" when a used-up window falls back to credits) next to the
assignee, and can click it for every window

## Linked Issues or Issue Description

Refs #19246
Refs #19259

**Is your feature request related to a problem?**
Codex and Claude report provider rate-limit windows during a run.
Paperclip dropped them: the Codex notification fell through the Rust
normalizer with no branch, and ACPX dropped Claude's `_meta`. Operators
could not see how close an agent was to its provider limit.

**Describe the solution you'd like**
Store the latest reported windows per agent and show one compact line in
the task properties pane, with a popover that lists every window.

**Describe alternatives you've considered**
A full gauge in the agent list and the run header. A design review chose
one property line with a details popover instead.

## What Changed

- Runner (Rust, the runnerd path): new `provider_rate_limits.rs`. Codex
`account/rateLimits/updated` becomes a `provider.notice.recorded` event
(category `provider.rate_limits`, scope `account`). `CodexProviderState`
merges sparse updates and emits a notice only when the windows change.
ACPX `usage_update` with a sanitized `rateLimit` field also emits the
notice.
- Runner (sidecar): `acpx-runtime-sidecar.ts` forwards only `status`,
`rateLimitType`, `utilization` and `resetsAt` from
`_meta["_claude/rateLimit"]`. This needs the ACPX `_meta` passthrough
from #19259. Until that merges, Claude emits nothing.
- Runner (TypeScript in-process drivers): the same mapping in
`codex-session-notifications.ts` and `codex-acpx-driver.ts` through
`drivers/provider-rate-limits.ts`.
- Shared: `provider-rate-limits.ts` holds the snapshot type, the parser,
the merge, and `summarizeProviderRateLimits` (fullest current window,
expiry by reset time, limited versus using credits).
- Server: `agent-provider-rate-limits.ts` merges each notice into
`agent_runtime_state.state_json.providerRateLimits`, scoped by company
and agent. A session reset keeps the snapshot. No migration.
- UI: `AgentRateLimitPropertyValue` adds a "Usage limit" row under the
assignee in `IssueProperties`. Transcripts treat the notice as run-log
only. A run status event refreshes the assignee's runtime state.
- Storybook: `Tasks and conversations/Task creation and lifecycle/Usage
limit property`.
- Docs: `doc/run-log-events.md` describes the notice and its storage.

## Verification

- `cd packages/paperclip-runner/runner && cargo test -p
paperclip-runner-core --lib rate_limit` and `--test
acpx_provider_events`: sparse Codex merge, dedupe, reached window,
checkpoint round trip, Claude utilization and rejection, notice shape.
- `cd packages/paperclip-runner && npx vitest run
src/drivers/provider-rate-limits.test.ts src/drivers/codex`
- `cd packages/shared && npx vitest run
src/provider-rate-limits.test.ts`: summary, expiry, limited, using
credits, parse and merge.
- `cd server && npx vitest run
src/__tests__/agent-provider-rate-limits.test.ts`: create and merge,
out-of-order snapshot, company isolation, unrelated notices, session
reset keeps the snapshot.
- `cd ui && npx vitest run src/components/transcript
src/components/issue-properties src/context/LiveUpdatesProvider`
- `pnpm -r typecheck` and `pnpm check:token-gates` pass.
- Live test on a local worktree instance: a native Codex agent on a
ChatGPT subscription login ran a real task. The runner recorded one
`provider.rate_limits` notice. The server stored it. The task properties
pane showed "7d 100% · using credits" (this account's weekly window was
used up and had credits, and the run succeeded). The transcript showed
no extra row.
- Not verified live: Claude. The local Claude CLI was not signed in, and
the Claude path also needs #19259. The Rust and sidecar paths have
tests.
- Not run locally: the full `pnpm test:run` and `pnpm build`. CI runs
them.

## Risks

- Low risk. The snapshot is display data. It does not hold or start
work. The existing provider-limit wait (#19246) still reads typed run
failures.
- Codex can send a rate-limit update often. The runner emits a notice
only when the windows change, so run-log growth stays small.
- With API-key auth, the harnesses report no windows, so no row appears.
This is the intended result.
- `CodexProviderState` gains two optional serde fields with defaults, so
older checkpoints still load.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edits, browser) and extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 20:26:51 +00:00
1 parent 6a66c09f7f
commit dd889e6a0b
27 files changed
+1911 -5

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
import { ChevronRight, Clock } from "lucide-react";
import {
summarizeProviderRateLimits,
type ProviderRateLimitSnapshot,
type ProviderRateLimitSummary,
} from "@paperclipai/shared";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
type Tone = ProviderRateLimitSummary["tone"];
const fillClass: Record<Tone, string> = {
normal: "bg-muted-foreground/60",
warn: "bg-(--status-task-todo)",
reached: "bg-destructive",
};
const textClass: Record<Tone, string> = {
normal: "text-foreground",
warn: "text-(--status-task-icon-todo)",
reached: "text-destructive",
};
export function formatRateLimitClock(iso: string, now: number): string {
const at = new Date(iso);
const time = at.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
if (new Date(now).toDateString() === at.toDateString()) return time;
if (Math.abs(at.getTime() - now) < 6 * 86_400_000) return `${at.toLocaleDateString("en-US", { weekday: "short" })} ${time}`;
return `${at.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${time}`;
}
function Bar({ pct, tone, className }: { pct: number; tone: Tone; className?: string }) {
const clamped = Math.min(100, Math.max(0, pct));
return (
<span className={cn("relative inline-block h-1.5 overflow-hidden rounded-full bg-muted", className)} aria-hidden>
<span className={cn("absolute inset-y-0 left-0 rounded-full", fillClass[tone])} style={{ width: `${clamped}%` }} />
</span>
);
}
function windowTone(pct: number, reached: boolean): Tone {
if (reached || pct >= 100) return "reached";
return pct > 80 ? "warn" : "normal";
}
/** Every current window as a labelled bar; shown inside the property popover. */
export function AgentRateLimitPanel({ snapshot, summary, now }: {
snapshot: ProviderRateLimitSnapshot;
summary: ProviderRateLimitSummary;
now: number;
}) {
const reachedId = summary.limited ? snapshot.limitReached?.windowId ?? null : null;
const creditTone = summary.usingCredits ? "warn" : null;
const credits = snapshot.credits;
return (
<div className="space-y-2">
{summary.current.map((window) => {
const pct = window.usedPercent ?? 0;
const tone = window.id === summary.window?.id && creditTone ? creditTone : windowTone(pct, window.id === reachedId);
return (
<div key={window.id} className="space-y-1">
<div className="flex items-baseline justify-between gap-3 text-xs">
<span className="truncate text-muted-foreground">{window.label}</span>
<span className={cn("shrink-0 tabular-nums", textClass[tone])}>
{Math.round(pct)}%
{window.resetsAt && (
<span className="text-muted-foreground"> · resets {formatRateLimitClock(window.resetsAt, now)}</span>
)}
</span>
</div>
<Bar pct={pct} tone={tone} className="w-full" />
</div>
);
})}
{summary.usingCredits && (
<div className="text-xs text-(--status-task-icon-todo)">Limit reached; work continues on account credits.</div>
)}
{credits && (credits.unlimited || credits.balance !== null || credits.hasCredits === false) && (
<div className="text-xs text-muted-foreground">
{credits.unlimited ? "Unlimited credits" : credits.hasCredits === false ? "No credits remaining" : `${credits.balance} credits`}
</div>
)}
</div>
);
}
/**
* One-line "Usage limit" property value: "5h 78% · resets 15:40", amber above
* 80%, "Limited until 15:40" while the provider refuses work, "using credits"
* when a used-up window falls back to account credits. Click opens the
* full window breakdown. Renders nothing when no current limit data exists.
*/
export function AgentRateLimitPropertyValue({ snapshot, now = Date.now(), defaultOpen, detailsHref, className }: {
snapshot: ProviderRateLimitSnapshot | null;
now?: number;
defaultOpen?: boolean;
/** Link to a fuller usage view, e.g. the agent's page. */
detailsHref?: string;
className?: string;
}) {
const summary = summarizeProviderRateLimits(snapshot, now);
if (!snapshot || !summary) return null;
const { window, tone } = summary;
const label = summary.limited
? `Limited${summary.limitedUntil ? ` until ${formatRateLimitClock(summary.limitedUntil, now)}` : ""}`
: summary.usingCredits
? `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}% · using credits`
: `${window!.shortLabel} ${Math.round(window!.usedPercent ?? 0)}%${window!.resetsAt ? ` · resets ${formatRateLimitClock(window!.resetsAt, now)}` : ""}`;
return (
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group inline-flex min-w-0 items-center gap-1.5 rounded-sm text-xs tabular-nums hover:text-foreground",
textClass[tone],
className,
)}
aria-label={`Provider usage limit: ${label}`}
>
{summary.limited
? <Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
: <Bar pct={window!.usedPercent ?? 0} tone={tone} className="w-6 shrink-0" />}
<span className="truncate">{label}</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 space-y-3 p-3">
<div className="text-xs font-medium">{snapshot.provider === "openai" ? "Codex" : "Claude"} usage limits</div>
<AgentRateLimitPanel snapshot={snapshot} summary={summary} now={now} />
<div className="flex items-center justify-between gap-3 text-(length:--text-micro) text-muted-foreground">
<span>Reported {formatRateLimitClock(snapshot.observedAt, now)}</span>
{detailsHref && <Link to={detailsHref} className="hover:text-foreground hover:underline">View agent</Link>}
</div>
</PopoverContent>
</Popover>
);
}
@@ -14,6 +14,8 @@ import { copyTextToClipboard } from "@/lib/clipboard";
import { Link } from "@/lib/router";
import {
deriveOriginatingActor,
readProviderRateLimits,
summarizeProviderRateLimits,
isArtifactReviewDocumentKey,
type ExecutionWorkspace,
type Issue,
@@ -69,7 +71,8 @@ import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags";
import { Identity } from "../Identity";
import { ProjectTile } from "../ProjectTile";
import { IssueReferencePill } from "../IssueReferencePill";
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
import { formatDate, formatDateTime, cn, projectUrl, agentUrl } from "../../lib/utils";
import { AgentRateLimitPropertyValue } from "../AgentRateLimitGauge";
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
import { timeAgo } from "../../lib/timeAgo";
import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache";
@@ -427,6 +430,16 @@ export function IssueProperties({
queryFn: () => agentsApi.list(companyId!),
enabled: !!companyId,
});
const assigneeRuntimeAgentId = issue.assigneeAgentId ?? null;
const { data: assigneeRuntimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(assigneeRuntimeAgentId ?? ""),
queryFn: () => agentsApi.runtimeState(assigneeRuntimeAgentId!, companyId ?? undefined),
// Optional detail: a 403 for board users without agent management access
// is not retried by the shared policy and simply hides the row.
enabled: !!assigneeRuntimeAgentId && !!companyId,
});
const assigneeRateLimits = readProviderRateLimits(assigneeRuntimeState?.stateJson);
const assigneeRateLimitAgent = assigneeRuntimeAgentId ? agents?.find((agent) => agent.id === assigneeRuntimeAgentId) : null;
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId!),
queryFn: () => accessApi.listUserDirectory(companyId!),
@@ -2403,6 +2416,15 @@ export function IssueProperties({
</PropertyPicker>
) : null}
{assigneeRateLimits && summarizeProviderRateLimits(assigneeRateLimits, Date.now()) ? (
<PropertyRow label="Usage limit">
<AgentRateLimitPropertyValue
snapshot={assigneeRateLimits}
detailsHref={assigneeRateLimitAgent ? agentUrl(assigneeRateLimitAgent) : undefined}
/>
</PropertyRow>
) : null}
<PropertyPicker
inline={inline}
label="Project"
@@ -1,8 +1,12 @@
import { isProviderRateLimitNotice } from "@paperclipai/shared";
/** Internal provider bookkeeping belongs in run logs, including older notice records. */
export function isRunLogOnlyProviderEvent(
eventType: string,
payload: Record<string, unknown>,
): boolean {
// Rate-limit snapshots feed the agent's usage-limit property, not the transcript.
if (isProviderRateLimitNotice(eventType, payload)) return true;
if (eventType === "harness.diagnostic") {
return payload.code === "codex_unrelated_information";
}
+11
View File
@@ -1378,6 +1378,10 @@ function invalidateHeartbeatQueries(
queryClient.invalidateQueries({
queryKey: queryKeys.heartbeats(companyId, agentId),
});
// Provider rate limits reported during the run live in runtime state.
queryClient.invalidateQueries({
queryKey: queryKeys.agents.runtimeState(agentId),
});
}
const runId = readString(payload.runId);
if (runId) {
@@ -1988,6 +1992,13 @@ function handleLiveEvent(
return;
}
if (event.type === "agent.runtime_state.updated") {
// The server saved a new provider rate-limit snapshot for this agent.
const agentId = readString(payload.agentId);
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId) });
return;
}
if (event.type === "agent.status") {
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(expectedCompanyId),