From abc6d3a656b42e28966b36f7f1307dffe94e40e2 Mon Sep 17 00:00:00 2001 From: Riccardo Bartoli Date: Sat, 24 Oct 2026 03:28:39 +0100 Subject: [PATCH] feat: from 95%, wakeups and scheduled jobs are refused --- CHANGELOG.md | 5 +++++ README.md | 3 ++- TESTING.md | 2 +- core/index.ts | 2 +- core/tools.ts | 23 ++++++++++++++++++++++- core/verdict.ts | 2 ++ hooks/register.ts | 6 +++++- tests/tools.test.ts | 31 +++++++++++++++++++++++++++++++ 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f0ec6..1a00043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ judge for any reason is now refused, so Claude starts it again; before, only an overrun was. Tool calls and prompts still go ahead when the guard fails. +- **From 95%, wakeups and scheduled jobs are refused.** At the threshold + where new agents are refused, `ScheduleWakeup` and `CronCreate` are now + refused too, with the plan window and its reset in the reason: the turn + each would start later runs into the limit with nobody there. Other tools + run as before, and `/agent-guard allow` (or `allow agents`) lifts it. ## 1.1.1 — 2026-10-23 diff --git a/README.md b/README.md index 528bf01..5246e8a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ And it's not just me: [339 subagents from a single prompt](https://www.reddit.co |---|---|---|---| | Plan windows | New agents need approval once a window is this full | 80% | asks; approval lasts until that window resets | | | New agents are refused once a window is this full | 95% | refuses | +| | `ScheduleWakeup` and `CronCreate` are refused once a window is this full, so no turn starts itself into the limit | 95% | refuses | | | Each session is told once to finish its step and say what is left | 95% | adds a note to the next prompt | | | New agents need approval when the 5-hour window rises this fast | 20 points / 10 min | asks | | Agents | Agents running at once, across every session on this machine | 4 | asks | @@ -148,7 +149,7 @@ Set these as environment variables, or under `env` in `~/.claude/settings.json`. | `AGENT_GUARD_AGENT_TOKENS_MAX` | Subagent tokens per window on this machine (`10000000`) | | `AGENT_GUARD_DEPTH_MAX` | Agent layers below the main conversation (`1`) | | `AGENT_GUARD_LIMIT_ASK_PERCENT` | Plan-window percentage from which new agents ask (`80`) | -| `AGENT_GUARD_LIMIT_DENY_PERCENT` | Plan-window percentage from which new agents are refused and each session is told to wind down (`95`) | +| `AGENT_GUARD_LIMIT_DENY_PERCENT` | Plan-window percentage from which new agents, wakeups and scheduled jobs are refused and each session is told to wind down (`95`) | | `AGENT_GUARD_BURN_PERCENT` | 5-hour-window points per window that make new agents ask (`20`) | | `AGENT_GUARD_CONTEXT_WARN` | Context at which Claude is told once to keep the turn bounded (`300000`) | | `AGENT_GUARD_CONTEXT_HARD` | Context at which a prompt asks before it is sent (`500000`) | diff --git a/TESTING.md b/TESTING.md index 42d3a37..76880bc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,7 +39,7 @@ npx -p typescript@5.9 tsc -p tsconfig.json | Surface | Evidence | |---|---| | Agent limits | Concurrency, rolling starts and subagent tokens; a finished agent frees its slot, one in a long tool call keeps it, and one that went quiet counts again at its next request; peers' counts add up, their tokens age out with the window, and stale records expire; a parallel batch of five lets exactly four through; nesting refused; resumes by `SendMessage` gated and counted once, also when Claude Code raises `agent.spawn` for them; a resume through `agent.spawn` alone gated as a resume; `/subtask` forks counted, even while a gated agent is starting, internal agents not | -| Plan windows | Refused from 95%, asked once from 80% with the approval lasting to reset; weekly window; expired windows ignored; burn rate; threshold crossings journaled once | +| Plan windows | Refused from 95%, wakeups and scheduled jobs too, while other tools run and an allow lifts it; asked once from 80% with the approval lasting to reset; weekly window; expired windows ignored; burn rate; threshold crossings journaled once | | Questions | Approval leases the condition; declining refuses without asking again; typed answers reach the model; a question that ends without an answer, however it ends, counts as dismissed and never as a yes; an agent the guard failed to judge is refused; calls made while a question is open are held back without waiting, and run once it is answered | | Tool gate | Retry fuse on identical failures in a row only, so test, edit, re-run is not a loop; a permission refusal is not a failure; the heavy-context budget asks once, stops the turn, or compacts after it; subagents judged on their own context; `SubagentHandback` and `TaskStop` never held; compaction, or a context reading below the threshold, clears the budget | | Prompt gate | Heavy prompt asks, compacts then resends as the user's own words, or cancels back into the input box; a compaction that fails or a hook vetoes puts the prompt back instead; headless holds with a reason; notifications pass untouched; dormant resumes ask or are capped across sessions; the context warning is given once, on a prompt the user typed; from 95% of a plan window, read in any session, each session is told once to wind down, and again only in a new window; a session compacted while idle is not asked | diff --git a/core/index.ts b/core/index.ts index 90de945..eb03ac8 100644 --- a/core/index.ts +++ b/core/index.ts @@ -50,5 +50,5 @@ export { } from './state.ts' export { USAGE, statusLine, statusLineExpiry, statusReport } from './status.ts' export { canonical, contextBucket, fingerprint } from './text.ts' -export { type HeavyOutcome, type ToolRequest, resolveHeavyAsk, toolGate } from './tools.ts' +export { type HeavyOutcome, type ToolRequest, isScheduling, resolveHeavyAsk, scheduleGate, toolGate } from './tools.ts' export { type Ask, type Deny, type Family, HEADER, type Rule, SIGNATURE, type Verdict } from './verdict.ts' diff --git a/core/tools.ts b/core/tools.ts index 1004c9b..1592f96 100644 --- a/core/tools.ts +++ b/core/tools.ts @@ -2,8 +2,9 @@ // loop carries a heavy context. Each loop is judged on its own context, so a // subagent's calls never inherit the main conversation's size. +import { highestLimit, limitDetail } from './agents.ts' import type { Config } from './config.ts' -import { type LoopId, MAIN, type SessionState, loopOf, overrideCovers, within } from './state.ts' +import { type LoopId, MAIN, type PeerRecord, type SessionState, loopOf, overrideCovers, within } from './state.ts' import { count, duration, sentence, shortClock, tokens } from './text.ts' import { ALLOW, type Ask, type Deny, type Verdict, refuse } from './verdict.ts' @@ -18,6 +19,26 @@ const EXEMPT_TOOLS: ReadonlySet = new Set([ 'ExitPlanMode', ]) +/** Tools that start a turn later, on their own: a wakeup, a scheduled job. */ +const SCHEDULING: ReadonlySet = new Set(['ScheduleWakeup', 'CronCreate']) + +export function isScheduling(tool: string): boolean { + return SCHEDULING.has(tool) +} + +/** + * From the refusal threshold, a wakeup or scheduled job is refused like a new + * agent: the turn it starts later would run into the limit with nobody there. + * The agents override lifts it. + */ +export function scheduleGate(state: SessionState, peers: readonly PeerRecord[], config: Config, now: number, request: ToolRequest): Verdict { + if (!config.enabled || !isScheduling(request.tool) || overrideCovers(state, now, 'agents')) return ALLOW + const limit = highestLimit(state, peers, now) + if (!limit || limit.percent < config.limitDenyPercent) return ALLOW + const base = `This ${request.tool} call was refused: ${limitDetail(limit)}, and the turn it starts would run into the limit. Finish the step in progress instead; the user can lift this with /agent-guard allow.` + return refuse(state, config, now, 'schedule', `schedule:${request.tool}`, base, false) +} + export type ToolRequest = { loop: LoopId tool: string diff --git a/core/verdict.ts b/core/verdict.ts index e4c0c1a..eec4852 100644 --- a/core/verdict.ts +++ b/core/verdict.ts @@ -10,6 +10,8 @@ export type Rule = | 'depth' | 'fuse' | 'limit-deny' + // A wakeup or scheduled job, past the refusal threshold. + | 'schedule' // Agents, asked when someone can answer. | 'concurrency' | 'starts' diff --git a/hooks/register.ts b/hooks/register.ts index 49b3784..f916056 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -31,6 +31,7 @@ import { helpText, highestLimit, isJournalRow, + isScheduling, journalKey, livePeers, lockoutKind, @@ -59,6 +60,7 @@ import { resolveHeavyAsk, resolvePromptAsk, resumeGuard, + scheduleGate, splitJournalKeys, statusLine, statusLineExpiry, @@ -393,7 +395,9 @@ export function register(on: On): void { if (!resumed) { const label = loop === MAIN ? 'this conversation' : `agent ${session.agents[loop]?.name ?? loop.slice(0, 8)}` const request = { loop, tool: e.tool, fingerprint: print, label } - let verdict = toolGate(session, config, now, request) + if (isScheduling(e.tool)) await loadPeers($, now) + let verdict = scheduleGate(session, peers, config, now, request) + if (verdict.kind === 'allow') verdict = toolGate(session, config, now, request) if (verdict.kind === 'ask') verdict = await settleHeavy($, verdict, request) if (verdict.kind === 'deny') { await journalDenial($, verdict, e.tool, contextBucket(session.loops[loop]?.context)) diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 843c3dc..e45fd74 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -158,3 +158,34 @@ describe('a Stop hook that reopens the turn', () => { expect(String(second.deny)).toMatch(/A Stop hook reopened this turn, so ending the turn will not clear this\. Tell the user/) }) }) + +describe('scheduled work near the limit', () => { + const measure = (percent: number) => ({ + context: { window: 1_000_000 }, + rateLimits: [{ kind: 'five_hour', percentUsed: percent, resetsAt: '2027-01-15T15:00:00.000Z' }], + changed: ['rateLimits'], + }) + + test('from 95% a wakeup or a scheduled job is refused, and other tools still run', async ($, on) => { + const w = world(on) + await start($, w, true) + await $.session.measure(measure(96) as never) + const wakeup = await $.tool.call({ tool: 'ScheduleWakeup', delaySeconds: 1200, prompt: 'check CI' } as never) + expect(String((wakeup as Record).deny)).toMatch(/^This ScheduleWakeup call was refused: the 5-hour window is 96% used \(resets \d\d:\d\d\), and the turn it starts would run into the limit\./) + const cron = await $.tool.call({ tool: 'CronCreate', cron: '*/5 * * * *', prompt: 'check CI' } as never) + expect(String((cron as Record).deny)).toMatch(/^This CronCreate call was refused/) + expect(await $.tool.call({ tool: 'Read', file_path: '/a' } as never)).toEqual({ result: 'ok' }) + expect(w.ran).toEqual(['Read']) + expect(journalRows(w).filter((r) => r.ev === 'deny').map((r) => r.rule)).toEqual(['schedule', 'schedule']) + }) + + test('below 95% it runs, and /agent-guard allow lifts the hold', async ($, on) => { + const w = world(on) + await start($, w, true) + await $.session.measure(measure(90) as never) + expect(await $.tool.call({ tool: 'ScheduleWakeup', delaySeconds: 1200, prompt: 'x' } as never)).toEqual({ result: 'ok' }) + await $.session.measure(measure(97) as never) + await $.command.run({ command: 'agent-guard', args: 'allow agents', origin: { kind: 'composer' } } as never) + expect(await $.tool.call({ tool: 'CronCreate', cron: '0 9 * * *', prompt: 'x' } as never)).toEqual({ result: 'ok' }) + }) +})