mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
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:
1 parent
6a66c09f7f
commit
dd889e6a0b
27 files changed
+1911
-5
No files matched your search
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user