Skip to content
Merged
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
39 changes: 39 additions & 0 deletions LOCAL_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ compiled output instead:
| `get_config` | Read current configuration |
| `update_config` | Partially update configuration |
| `get_runtime` | Get runtime state (drain mode, active queue handlers) |
| `get_agent_spend` | Get coding-agent spend for the rolling hour, with the ceiling and a breakdown by work kind |
| `set_drain_mode` | Enable/disable drain mode for new queue claims |
| `list_changelogs` | List social-media changelogs |
| `get_changelog` | Get one changelog by ID |
Expand Down Expand Up @@ -766,6 +767,44 @@ Get the current runtime state.

---

#### `GET /api/agent-spend`

Get coding-agent spend for the rolling hour. Every spawn of the `codex` or
`claude` CLI is recorded, whichever path caused it — PR work, feedback
evaluation, issue work, issue decompose/verify, CI healing, deployment healing,
PR questions, release notes, and social posts.

`max` is the `maxAgentInvocationsPerHour` setting. **`0` means unlimited**, and
`remaining` is then `null`. `used` excludes agent health-check probes, which are
recorded in `byKind` but never counted against the ceiling.

`windowMs` is a rolling window, not a calendar hour: `resetsAt` is when the
oldest invocation inside the window ages out and the next slot frees up.

When the ceiling is reached, the dispatcher stops claiming agent-invoking job
kinds and any agent spawn started inside an already-running job is refused.
Queued work is not failed — it waits and resumes on its own.

**Response** `200`
```json
{
"windowMs": 3600000,
"windowStartedAt": "2026-08-29T11:00:00.000Z",
"resetsAt": "2026-08-29T12:14:00.000Z",
"max": 30,
"used": 18,
"remaining": 12,
"exhausted": false,
"byKind": [
{ "workKind": "babysit_pr", "count": 11, "totalDurationMs": 1830000 },
{ "workKind": "work_issue", "count": 5, "totalDurationMs": 920000 },
{ "workKind": "heal_ci", "count": 2, "totalDurationMs": 240000 }
]
}
```

---

#### `POST /api/runtime/drain`

Enable or disable drain mode. When enabled, the dispatcher stops claiming new
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ Most configuration is editable in Settings:
- GitHub progress replies
- CI healing
- Agent retry attempts for failed work
- Hourly ceiling on agent runs
- Theme

Key defaults:
Expand All @@ -195,6 +196,11 @@ Key defaults:
needs a person — expired agent credentials, a missing CLI — is parked and shown under
**Needs attention**, and is retried again roughly hourly so it resumes on its own once fixed.
- Drain mode pauses new agent work without deleting tracked state.
- **Max agent runs per hour** (default 0, meaning unlimited) caps coding-agent runs
across every path — PR work, issues, CI and deployment healing, releases, and
questions. When the ceiling is reached, queued work waits instead of failing and
resumes on its own as the rolling hour moves forward. The header shows usage once
a ceiling is set, and `GET /api/agent-spend` reports the full breakdown.

## Authentication

Expand Down
51 changes: 49 additions & 2 deletions client/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { Link } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import type { ActivitySnapshot, Config, RuntimeState } from "@shared/schema";
import type { ActivitySnapshot, AgentSpendSummary, Config, RuntimeState } from "@shared/schema";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { ToastAction } from "@/components/ui/toast";
Expand Down Expand Up @@ -371,6 +371,52 @@ function GitHubRateLimitNotice() {
);
}

function AgentSpendNotice() {
const { data: config } = useQuery<Config>({
queryKey: ["/api/config"],
});
const uiPollIntervalMs = getUiPollIntervalMs(config);
const { data: spend } = useQuery<AgentSpendSummary>({
queryKey: ["/api/agent-spend"],
refetchInterval: uiPollIntervalMs,
});

// No ceiling configured means unlimited, and an unlimited budget has nothing
// worth taking up header space.
if (!spend || spend.max === 0) {
return null;
}

const resetTime = new Date(spend.resetsAt).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
const nearingCeiling = spend.used >= Math.ceil(spend.max * 0.8);

const label = spend.exhausted
? `Agent runs paused until ${resetTime}`
: `${spend.used}/${spend.max} agent runs`;

const tooltip = spend.exhausted
? `The hourly ceiling of ${spend.max} agent runs is reached. Queued work resumes on its own around ${resetTime}.`
: `${spend.used} of ${spend.max} agent runs used in the last hour, across PR work, issues, healing, releases, and questions.`;

const toneClass = spend.exhausted || nearingCeiling
? "border-warning-border bg-warning-muted text-warning-foreground hover:border-warning hover:bg-warning-muted/80"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground";

return (
<Link
href="/settings"
title={tooltip}
data-testid="agent-spend-notice"
className={`inline-flex max-w-[320px] items-center truncate whitespace-nowrap rounded-md border px-2.5 py-1 text-label uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background ${toneClass}`}
>
{label}
</Link>
);
}

export function AppHeader({
active,
status,
Expand Down Expand Up @@ -407,8 +453,9 @@ export function AppHeader({
})}
</nav>
</div>
<div className="flex min-w-0 items-center justify-center max-lg:empty:hidden">
<div className="flex min-w-0 items-center justify-center gap-2 max-lg:empty:hidden">
<GitHubRateLimitNotice />
<AgentSpendNotice />
</div>
<div className="-mx-1 flex min-w-0 items-center gap-2 overflow-x-auto px-1 pb-0.5 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0 lg:justify-self-end lg:justify-end [&>*]:shrink-0 lg:[&>*]:shrink">
{status ? (
Expand Down
9 changes: 9 additions & 0 deletions client/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const DEFAULT_SETTING_VALUES = {
maxChangesPerRun: 20,
maxConcurrentBabysitRuns: 3,
maxAgentRetryAttempts: 3,
maxAgentInvocationsPerHour: 0,
maxHealingAttemptsPerSession: 3,
maxHealingAttemptsPerFingerprint: 2,
maxConcurrentHealingRuns: 1,
Expand Down Expand Up @@ -1358,6 +1359,14 @@ export default function Settings() {
defaultValue={DEFAULT_SETTING_VALUES.maxAgentRetryAttempts}
disabled={updateConfigMutation.isPending}
/>
<SettingRow
label="Max agent runs per hour"
description="Ceiling on coding-agent runs across every path — PR work, issues, healing, releases, questions. Queued work waits and resumes as the hour rolls forward. 0 means unlimited."
value={config?.maxAgentInvocationsPerHour ?? 0}
onChange={(v) => updateConfigMutation.mutate({ maxAgentInvocationsPerHour: v })}
defaultValue={DEFAULT_SETTING_VALUES.maxAgentInvocationsPerHour}
disabled={updateConfigMutation.isPending}
/>
</div>
</SettingsSubsection>

Expand Down
Loading
Loading