Public: anyone can read this; the crew can change it.
Usage and "limited until" reach the meter from the runner, not one CLI's event format #12255
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12123-usage-and-limited-until-reach-the-meter → main 12 commits fork
10 files
changed +322
−213
scripts/unused-exports-allow.json +1
| @@ -56,6 +56,7 @@ | ||
| 56 | 56 | "src/core/usage.ts USAGE_BY", |
| 57 | 57 | "src/core/usage.ts USAGE_OUTCOMES", |
| 58 | 58 | "src/core/webhooks.ts deliveryBusy", |
| 59 | "src/crew/agent_runner.ts useRunner", | |
| 59 | 60 | "src/crew/ceiling_stagger.ts forgetStagger", |
| 60 | 61 | "src/crew/conversation_confirm.ts CONFIRM_TTL_MS", |
| 61 | 62 | "src/crew/first_look.ts useLookModel", |
src/crew/agent_runner.ts +31 −2
| @@ -79,7 +79,21 @@ export interface AgentRun { | ||
| 79 | 79 | export interface Spent { |
| 80 | 80 | readonly tokensIn: number; |
| 81 | 81 | readonly tokensOut: number; |
| 82 | readonly costUsd: number; | |
| 82 | /** Absent until the runner knows it (Claude reports it only at the end). */ | |
| 83 | readonly costUsd?: number | undefined; | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * The provider turned the run away. `limitedUntil` is the provider's own | |
| 88 | * instant (ISO, no slack added) when it named one; a limit with no time is | |
| 89 | * waited out by default. `window` says which allowance is full when known. | |
| 90 | * What a CLI's rate-limit events look like never crosses the seam. | |
| 91 | */ | |
| 92 | export interface RunnerLimit { | |
| 93 | readonly limitedUntil?: string | undefined; | |
| 94 | readonly window?: LimitWindow | undefined; | |
| 95 | /** One line a person can read about it. */ | |
| 96 | readonly said?: string | undefined; | |
| 83 | 97 | } |
| 84 | 98 | |
| 85 | 99 | export interface AgentOutcome { |
| @@ -102,6 +116,14 @@ export interface AgentOutcome { | ||
| 102 | 116 | readonly contextWindow: number; |
| 103 | 117 | /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */ |
| 104 | 118 | readonly reason: string; |
| 119 | /** | |
| 120 | * Set when the end was the provider's refusal, not the agent's failure. | |
| 121 | * A runner that can tell must set it: the crew pauses a run only on this, | |
| 122 | * and reads no provider's wording itself (Claude Code's is `claude.ts`'s | |
| 123 | * `limitedBy`). A runner that never sets it has its refusals judged as | |
| 124 | * failures. | |
| 125 | */ | |
| 126 | readonly limit?: RunnerLimit | undefined; | |
| 105 | 127 | } |
| 106 | 128 | |
| 107 | 129 | |
| @@ -131,9 +153,16 @@ const claude: AgentRunner = { | ||
| 131 | 153 | detect: (binary) => detectClaude(binary), |
| 132 | 154 | }; |
| 133 | 155 | |
| 156 | let chosen: AgentRunner | undefined; | |
| 157 | ||
| 134 | 158 | /** The runner every run uses today. */ |
| 135 | 159 | export function currentRunner(): AgentRunner { |
| 136 | return claude; | |
| 160 | return chosen ?? claude; | |
| 161 | } | |
| 162 | ||
| 163 | /** Swap the runner (tests drive a fake one through the crew); undefined restores Claude Code. */ | |
| 164 | export function useRunner(runner: AgentRunner | undefined): void { | |
| 165 | chosen = runner; | |
| 137 | 166 | } |
| 138 | 167 | |
| 139 | 168 | /** The forge tool names a brief should say for `runner` (#12128); the current runner by default. */ |
src/crew/claude.ts +154 −1
| @@ -18,6 +18,8 @@ import { oomFirst } from "../apps/oom.ts"; | ||
| 18 | 18 | import { cgroupHost } from "../apps/cgroup.ts"; |
| 19 | 19 | import { openRunCell } from "./run-cell.ts"; |
| 20 | 20 | import { watchLabelled } from "../apps/contain.ts"; |
| 21 | import { isNetworkError } from "./network.ts"; | |
| 22 | import type { LimitWindow } from "../core/standing.ts"; | |
| 21 | 23 | import { childEnv, pump } from "../apps/process.ts"; |
| 22 | 24 | import { endedLine, liveTree } from "../runs/ended.ts"; |
| 23 | 25 | import { CANCEL_GRACE_MS } from "../runs/grace.ts"; |
| @@ -28,6 +30,7 @@ import type { | ||
| 28 | 30 | AgentOutcome, |
| 29 | 31 | AgentRun, |
| 30 | 32 | BUDGET_REACHED as SeamBudgetReached, |
| 33 | RunnerLimit, | |
| 31 | 34 | Spent, |
| 32 | 35 | } from "./agent_runner.ts"; |
| 33 | 36 | |
| @@ -138,6 +141,16 @@ export function claudeArgs(input: ClaudeInput): string[] { | ||
| 138 | 141 | |
| 139 | 142 | /** Run Claude Code to completion. Never throws; every ending is an outcome. */ |
| 140 | 143 | export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { |
| 144 | return withLimit(await runClaudeRaw(input), new Date()); | |
| 145 | } | |
| 146 | ||
| 147 | /** The outcome, marked with the provider's limit when that is how it ended. */ | |
| 148 | export function withLimit(outcome: ClaudeOutcome, now: Date): ClaudeOutcome { | |
| 149 | const limit = limitedBy(outcome, now); | |
| 150 | return limit === undefined ? outcome : { ...outcome, limit }; | |
| 151 | } | |
| 152 | ||
| 153 | async function runClaudeRaw(input: ClaudeInput): Promise<ClaudeOutcome> { | |
| 141 | 154 | // Background work, with everything its shell starts: the forge's own |
| 142 | 155 | // pages outrank it (#3319, `apps/background.ts`). A binary that is not |
| 143 | 156 | // there is spawned bare, so it fails as itself (ENOENT, caught by the |
| @@ -339,7 +352,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 339 | 352 | tokensIn += t.tokensIn; |
| 340 | 353 | tokensOut += t.tokensOut; |
| 341 | 354 | } |
| 342 | input.onUsage({ tokensIn, tokensOut, costUsd: 0 }); | |
| 355 | input.onUsage({ tokensIn, tokensOut }); | |
| 343 | 356 | } |
| 344 | 357 | } |
| 345 | 358 | } |
| @@ -624,3 +637,143 @@ export async function detectClaude( | ||
| 624 | 637 | }; |
| 625 | 638 | } |
| 626 | 639 | } |
| 640 | ||
| 641 | /** | |
| 642 | * Whether Claude Code's ending is the provider's refusal rather than the | |
| 643 | * agent's failure, and what it said about when it lifts. It ends such a run | |
| 644 | * with `api_error`; the message names the limit, and is checked too in case | |
| 645 | * the ending is named differently. The seam carries only the result: | |
| 646 | * `{ limitedUntil, window }`. | |
| 647 | */ | |
| 648 | export function limitedBy(outcome: AgentOutcome, now: Date): RunnerLimit | undefined { | |
| 649 | if (outcome.ok || isNetworkError(outcome)) return undefined; | |
| 650 | const message = outcome.result; | |
| 651 | const refused = | |
| 652 | outcome.reason === "api_error" || | |
| 653 | /rate[ _-]?limit|session limit|usage limit|weekly limit|hit your \w+ limit|overloaded|too many requests|\b(?:429|529)\b/i.test( | |
| 654 | message, | |
| 655 | ); | |
| 656 | if (!refused) return undefined; | |
| 657 | return limitFromWords(message, now, outcome.reason); | |
| 658 | } | |
| 659 | ||
| 660 | /** What a refusal's words say about when it lifts, as the seam's limit. */ | |
| 661 | export function limitFromWords(message: string, now: Date, fallback = ""): RunnerLimit { | |
| 662 | const said = (message.split("\n").find((l) => l.trim() !== "") ?? fallback).slice(0, 160); | |
| 663 | const match = RESET.exec(message); | |
| 664 | const at = resetOf(message, now); | |
| 665 | if (match === null || at === undefined) return { said }; | |
| 666 | // The week is named (`weekly limit`) or dated (`resets Sep 26 at 9pm`); a | |
| 667 | // bare clock time is the 5-hour session window. | |
| 668 | const weekly = match[1] !== undefined || /\bweek(?:ly)?\b/i.test(message); | |
| 669 | const window: LimitWindow = weekly ? "week" : "5h"; | |
| 670 | return { limitedUntil: at.toISOString(), window, said }; | |
| 671 | } | |
| 672 | ||
| 673 | const MONTHS = [ | |
| 674 | "jan", | |
| 675 | "feb", | |
| 676 | "mar", | |
| 677 | "apr", | |
| 678 | "may", | |
| 679 | "jun", | |
| 680 | "jul", | |
| 681 | "aug", | |
| 682 | "sep", | |
| 683 | "oct", | |
| 684 | "nov", | |
| 685 | "dec", | |
| 686 | ]; | |
| 687 | ||
| 688 | /** | |
| 689 | * Claude Code says `resets 7:20am (UTC)` or `resets 2:20am (America/Chicago)` | |
| 690 | * for a session's window, and `resets Sep 26 at 9pm (America/Chicago)` for | |
| 691 | * the week's: that clock time in that zone, on that date or the next | |
| 692 | * occurrence. | |
| 693 | */ | |
| 694 | const RESET = | |
| 695 | /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i; | |
| 696 | ||
| 697 | /** The reset instant a message names, exactly; undefined when it names none. */ | |
| 698 | function resetOf(message: string, now: Date): Date | undefined { | |
| 699 | const match = RESET.exec(message); | |
| 700 | if (match === null) return undefined; | |
| 701 | const [, monthName, day, h, m, meridiem, zone] = match; | |
| 702 | const hour = (Number(h) % 12) + (meridiem!.toLowerCase() === "pm" ? 12 : 0); | |
| 703 | const minute = Number(m ?? "0"); | |
| 704 | const month = | |
| 705 | monthName === undefined | |
| 706 | ? -1 | |
| 707 | : MONTHS.indexOf(monthName.slice(0, 3).toLowerCase()); | |
| 708 | const date = | |
| 709 | monthName !== undefined && month >= 0 | |
| 710 | ? { month: month + 1, day: Number(day) } | |
| 711 | : undefined; | |
| 712 | return nextWallTime(now, hour, minute, zone!, date); | |
| 713 | } | |
| 714 | ||
| 715 | /** | |
| 716 | * The next instant at which a clock in `zone` reads `hour:minute`, strictly | |
| 717 | * after `now`. The zone's offset is taken from the runtime's own tables, | |
| 718 | * so daylight time is right without a list of rules here. | |
| 719 | */ | |
| 720 | function nextWallTime( | |
| 721 | now: Date, | |
| 722 | hour: number, | |
| 723 | minute: number, | |
| 724 | zone: string, | |
| 725 | date?: { month: number; day: number }, | |
| 726 | ): Date | undefined { | |
| 727 | let parts: Intl.DateTimeFormat; | |
| 728 | try { | |
| 729 | parts = new Intl.DateTimeFormat("en-US", { | |
| 730 | timeZone: zone, | |
| 731 | hourCycle: "h23", | |
| 732 | year: "numeric", | |
| 733 | month: "numeric", | |
| 734 | day: "numeric", | |
| 735 | hour: "numeric", | |
| 736 | minute: "numeric", | |
| 737 | second: "numeric", | |
| 738 | }); | |
| 739 | } catch { | |
| 740 | return undefined; // not a zone the runtime knows | |
| 741 | } | |
| 742 | const wall = (date: Date): Record<string, number> => { | |
| 743 | const out: Record<string, number> = {}; | |
| 744 | for (const p of parts.formatToParts(date)) | |
| 745 | if (p.type !== "literal") out[p.type] = Number(p.value); | |
| 746 | return out; | |
| 747 | }; | |
| 748 | // The instant whose wall clock in `zone` is `y-m-d hour:minute`: start | |
| 749 | // from the UTC reading and correct by the zone's offset at that instant. | |
| 750 | const instantOf = (y: number, mo: number, d: number): Date => { | |
| 751 | let guess = new Date(Date.UTC(y, mo - 1, d, hour, minute)); | |
| 752 | for (let i = 0; i < 2; i++) { | |
| 753 | const w = wall(guess); | |
| 754 | const asUtc = Date.UTC( | |
| 755 | w["year"]!, | |
| 756 | w["month"]! - 1, | |
| 757 | w["day"]!, | |
| 758 | w["hour"]!, | |
| 759 | w["minute"]!, | |
| 760 | w["second"]!, | |
| 761 | ); | |
| 762 | const want = Date.UTC(y, mo - 1, d, hour, minute); | |
| 763 | guess = new Date(guess.getTime() + (want - asUtc)); | |
| 764 | } | |
| 765 | return guess; | |
| 766 | }; | |
| 767 | const today = wall(now); | |
| 768 | if (date !== undefined) { | |
| 769 | // A named date this year, or next year's if it has already passed. | |
| 770 | const thisYear = instantOf(today["year"]!, date.month, date.day); | |
| 771 | if (thisYear.getTime() > now.getTime()) return thisYear; | |
| 772 | return instantOf(today["year"]! + 1, date.month, date.day); | |
| 773 | } | |
| 774 | const candidate = instantOf(today["year"]!, today["month"]!, today["day"]!); | |
| 775 | if (candidate.getTime() > now.getTime()) return candidate; | |
| 776 | const tomorrow = new Date(candidate.getTime() + 36 * 3_600_000); | |
| 777 | const t = wall(tomorrow); | |
| 778 | return instantOf(t["year"]!, t["month"]!, t["day"]!); | |
| 779 | } |
src/crew/conversation.ts +4 −4
| @@ -38,7 +38,7 @@ import { redactValues, releaseValues } from "../kernel/log.ts"; | ||
| 38 | 38 | import { running } from "../kernel/version.ts"; |
| 39 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 40 | 40 | import { appendRunLog } from "../runs/log.ts"; |
| 41 | import { runAgent, type AgentOutcome } from "./agent_runner.ts"; | |
| 41 | import { runAgent, type AgentOutcome, type RunnerLimit } from "./agent_runner.ts"; | |
| 42 | 42 | import { CONVERSATION_BRIEF } from "./conversation_brief.ts"; |
| 43 | 43 | import { isRateLimited, takeLimit, whenText } from "./limits.ts"; |
| 44 | 44 | import { credentialEnv, holdModelCredential, listenForSteer, steerRun } from "./runner.ts"; |
| @@ -443,9 +443,9 @@ export function resumeInterrupted(app: App, options: ConversationOptions): Promi | ||
| 443 | 443 | * never the provider's words. The forge's own token cannot rest, so the next |
| 444 | 444 | * message is accepted; the line says the window is full, not that anything rests. |
| 445 | 445 | */ |
| 446 | function limitLine(app: App, picked: Pick, message: string): string { | |
| 446 | function limitLine(app: App, picked: Pick, limit: RunnerLimit): string { | |
| 447 | 447 | const account = picked.kind === "ready" ? picked.subscription : undefined; |
| 448 | const { until } = takeLimit(app.db, account?.id, message, "", new Date()); | |
| 448 | const { until } = takeLimit(app.db, account?.id, limit, "", new Date()); | |
| 449 | 449 | return account === undefined |
| 450 | 450 | ? `The forge's own model window is full until ${whenText(until)}; try again then.` |
| 451 | 451 | : `${account.name} is resting until ${whenText(until)}.`; |
| @@ -617,7 +617,7 @@ function begin( | ||
| 617 | 617 | if (outcome === undefined) couldNot(failureLine("error"), () => false); |
| 618 | 618 | else if (isRateLimited(outcome)) { |
| 619 | 619 | const first = outcome.result.split("\n").find((l) => l.trim() !== "")?.trim() ?? ""; |
| 620 | couldNot(limitLine(app, picked, outcome.result), (said) => said === outcome!.result || (first !== "" && said.includes(first))); | |
| 620 | couldNot(limitLine(app, picked, outcome.limit), (said) => said === outcome!.result || (first !== "" && said.includes(first))); | |
| 621 | 621 | } else if (!outcome.ok) couldNot(failureLine(outcome.reason), (said) => said === outcome!.result); |
| 622 | 622 | else if (answer.trim() !== "" && answer !== lastText) put("agent", answer); |
| 623 | 623 | else if (answer.trim() === "" && lastText === "") couldNot(failureLine("error"), () => false); |
src/crew/limits.ts +30 −146
| @@ -8,8 +8,7 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { noteLimit, type LimitWindow } from "../core/standing.ts"; |
| 10 | 10 | import { PLATFORM, restSubscription } from "../core/subscriptions.ts"; |
| 11 | import { isNetworkError } from "./network.ts"; | |
| 12 | import type { AgentOutcome } from "./agent_runner.ts"; | |
| 11 | import type { AgentOutcome, RunnerLimit } from "./agent_runner.ts"; | |
| 13 | 12 | |
| 14 | 13 | /** How many times one task may be paused before the forge gives up on it. */ |
| 15 | 14 | export const MAX_PAUSES = 6; |
| @@ -21,83 +20,34 @@ const SLACK_MS = 60_000; | ||
| 21 | 20 | |
| 22 | 21 | /** |
| 23 | 22 | * Whether an outcome is the provider's refusal rather than the agent's |
| 24 | * failure. Claude Code ends such a run with `api_error`; the message names | |
| 25 | * the limit, and is checked too in case the ending is named differently. | |
| 23 | * failure: the runner says so with `limit` on the outcome. How a CLI words | |
| 24 | * its refusals is that runner's business (`claude.ts`), not read here. | |
| 26 | 25 | */ |
| 27 | export function isRateLimited(outcome: AgentOutcome): boolean { | |
| 28 | if (outcome.ok || isNetworkError(outcome)) return false; | |
| 29 | return ( | |
| 30 | outcome.reason === "api_error" || | |
| 31 | /rate[ _-]?limit|session limit|usage limit|weekly limit|hit your \w+ limit|overloaded|too many requests|\b(?:429|529)\b/i.test( | |
| 32 | outcome.result, | |
| 33 | ) | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | const MONTHS = [ | |
| 38 | "jan", | |
| 39 | "feb", | |
| 40 | "mar", | |
| 41 | "apr", | |
| 42 | "may", | |
| 43 | "jun", | |
| 44 | "jul", | |
| 45 | "aug", | |
| 46 | "sep", | |
| 47 | "oct", | |
| 48 | "nov", | |
| 49 | "dec", | |
| 50 | ]; | |
| 51 | ||
| 52 | /** | |
| 53 | * When to try again. Claude Code says `resets 7:20am (UTC)` or | |
| 54 | * `resets 2:20am (America/Chicago)` for a session's window, and | |
| 55 | * `resets Sep 26 at 9pm (America/Chicago)` for the week's; that clock time | |
| 56 | * in that zone, on that date or the next occurrence, plus a minute. | |
| 57 | * Anything else waits the default. | |
| 58 | */ | |
| 59 | export function resumeAt(message: string, now: Date): Date { | |
| 60 | const at = resetOf(message, now); | |
| 61 | if (at === undefined) return new Date(now.getTime() + DEFAULT_WAIT_MS); | |
| 62 | return new Date(at.getTime() + SLACK_MS); | |
| 26 | export function isRateLimited(outcome: AgentOutcome): outcome is AgentOutcome & { readonly limit: RunnerLimit } { | |
| 27 | return !outcome.ok && outcome.limit !== undefined; | |
| 63 | 28 | } |
| 64 | 29 | |
| 65 | 30 | /** |
| 66 | * Which window the provider said is full, and the instant it resets — read | |
| 67 | * from its own words, or undefined when it named no time. The week is named | |
| 68 | * (`weekly limit`) or dated (`resets Sep 26 at 9pm`); a bare clock time is | |
| 69 | * the 5-hour session window. No slack is added: this is the provider's time. | |
| 31 | * When to try again: the runner's `limitedUntil` plus a minute. A limit | |
| 32 | * that names no time waits the default. | |
| 33 | */ | |
| 34 | export function resumeAt(limit: RunnerLimit | undefined, now: Date): Date { | |
| 35 | const at = limit?.limitedUntil === undefined ? NaN : Date.parse(limit.limitedUntil); | |
| 36 | if (Number.isNaN(at)) return new Date(now.getTime() + DEFAULT_WAIT_MS); | |
| 37 | return new Date(at + SLACK_MS); | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Which window the runner said is full, and the instant it resets; undefined | |
| 42 | * when it named no time. No slack is added: this is the provider's time. | |
| 70 | 43 | */ |
| 71 | 44 | export function limitOf( |
| 72 | message: string, | |
| 73 | now: Date, | |
| 45 | limit: RunnerLimit | undefined, | |
| 74 | 46 | ): { window: LimitWindow; resetsAt: Date } | undefined { |
| 75 | const match = RESET.exec(message); | |
| 76 | const resetsAt = resetOf(message, now); | |
| 77 | if (match === null || resetsAt === undefined) return undefined; | |
| 78 | const weekly = match[1] !== undefined || /\bweek(?:ly)?\b/i.test(message); | |
| 79 | return { window: weekly ? "week" : "5h", resetsAt }; | |
| 80 | } | |
| 81 | ||
| 82 | const RESET = | |
| 83 | /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i; | |
| 84 | ||
| 85 | /** The reset instant a message names, exactly; undefined when it names none. */ | |
| 86 | function resetOf(message: string, now: Date): Date | undefined { | |
| 87 | const match = RESET.exec(message); | |
| 88 | if (match === null) return undefined; | |
| 89 | const [, monthName, day, h, m, meridiem, zone] = match; | |
| 90 | const hour = (Number(h) % 12) + (meridiem!.toLowerCase() === "pm" ? 12 : 0); | |
| 91 | const minute = Number(m ?? "0"); | |
| 92 | const month = | |
| 93 | monthName === undefined | |
| 94 | ? -1 | |
| 95 | : MONTHS.indexOf(monthName.slice(0, 3).toLowerCase()); | |
| 96 | const date = | |
| 97 | monthName !== undefined && month >= 0 | |
| 98 | ? { month: month + 1, day: Number(day) } | |
| 99 | : undefined; | |
| 100 | return nextWallTime(now, hour, minute, zone!, date); | |
| 47 | if (limit?.limitedUntil === undefined) return undefined; | |
| 48 | const resetsAt = new Date(limit.limitedUntil); | |
| 49 | if (Number.isNaN(resetsAt.getTime())) return undefined; | |
| 50 | return { window: limit.window ?? "5h", resetsAt }; | |
| 101 | 51 | } |
| 102 | 52 | |
| 103 | 53 | /** |
| @@ -110,91 +60,25 @@ function resetOf(message: string, now: Date): Date | undefined { | ||
| 110 | 60 | export function takeLimit( |
| 111 | 61 | db: Database, |
| 112 | 62 | subscriptionId: string | undefined, |
| 113 | message: string, | |
| 63 | limit: RunnerLimit, | |
| 114 | 64 | fallback: string, |
| 115 | 65 | now: Date, |
| 116 | 66 | ): { until: Date; limit: ReturnType<typeof limitOf>; said: string } { |
| 117 | const said = (message.split("\n").find((l) => l.trim() !== "") ?? fallback).slice(0, 160); | |
| 118 | const until = resumeAt(message, now); | |
| 119 | const limit = limitOf(message, now); | |
| 120 | if (limit !== undefined) | |
| 67 | const said = (limit.said ?? fallback).slice(0, 160); | |
| 68 | const until = resumeAt(limit, now); | |
| 69 | const named = limitOf(limit); | |
| 70 | if (named !== undefined) | |
| 121 | 71 | noteLimit(db, subscriptionId ?? PLATFORM, { |
| 122 | window: limit.window, | |
| 123 | resetsAt: limit.resetsAt.toISOString(), | |
| 72 | window: named.window, | |
| 73 | resetsAt: named.resetsAt.toISOString(), | |
| 124 | 74 | observedAt: now.toISOString(), |
| 125 | 75 | said, |
| 126 | 76 | }); |
| 127 | 77 | if (subscriptionId !== undefined) restSubscription(db, subscriptionId, until.toISOString(), said); |
| 128 | return { until, limit, said }; | |
| 78 | return { until, limit: named, said }; | |
| 129 | 79 | } |
| 130 | 80 | |
| 131 | 81 | /** `12:04 UTC` — how a resume time reads on a card. */ |
| 132 | 82 | export function whenText(at: Date): string { |
| 133 | 83 | return `${at.toISOString().slice(11, 16)} UTC`; |
| 134 | 84 | } |
| 135 | ||
| 136 | /** | |
| 137 | * The next instant at which a clock in `zone` reads `hour:minute`, strictly | |
| 138 | * after `now`. The zone's offset is taken from the runtime's own tables, | |
| 139 | * so daylight time is right without a list of rules here. | |
| 140 | */ | |
| 141 | function nextWallTime( | |
| 142 | now: Date, | |
| 143 | hour: number, | |
| 144 | minute: number, | |
| 145 | zone: string, | |
| 146 | date?: { month: number; day: number }, | |
| 147 | ): Date | undefined { | |
| 148 | let parts: Intl.DateTimeFormat; | |
| 149 | try { | |
| 150 | parts = new Intl.DateTimeFormat("en-US", { | |
| 151 | timeZone: zone, | |
| 152 | hourCycle: "h23", | |
| 153 | year: "numeric", | |
| 154 | month: "numeric", | |
| 155 | day: "numeric", | |
| 156 | hour: "numeric", | |
| 157 | minute: "numeric", | |
| 158 | second: "numeric", | |
| 159 | }); | |
| 160 | } catch { | |
| 161 | return undefined; // not a zone the runtime knows | |
| 162 | } | |
| 163 | const wall = (date: Date): Record<string, number> => { | |
| 164 | const out: Record<string, number> = {}; | |
| 165 | for (const p of parts.formatToParts(date)) | |
| 166 | if (p.type !== "literal") out[p.type] = Number(p.value); | |
| 167 | return out; | |
| 168 | }; | |
| 169 | // The instant whose wall clock in `zone` is `y-m-d hour:minute`: start | |
| 170 | // from the UTC reading and correct by the zone's offset at that instant. | |
| 171 | const instantOf = (y: number, mo: number, d: number): Date => { | |
| 172 | let guess = new Date(Date.UTC(y, mo - 1, d, hour, minute)); | |
| 173 | for (let i = 0; i < 2; i++) { | |
| 174 | const w = wall(guess); | |
| 175 | const asUtc = Date.UTC( | |
| 176 | w["year"]!, | |
| 177 | w["month"]! - 1, | |
| 178 | w["day"]!, | |
| 179 | w["hour"]!, | |
| 180 | w["minute"]!, | |
| 181 | w["second"]!, | |
| 182 | ); | |
| 183 | const want = Date.UTC(y, mo - 1, d, hour, minute); | |
| 184 | guess = new Date(guess.getTime() + (want - asUtc)); | |
| 185 | } | |
| 186 | return guess; | |
| 187 | }; | |
| 188 | const today = wall(now); | |
| 189 | if (date !== undefined) { | |
| 190 | // A named date this year, or next year's if it has already passed. | |
| 191 | const thisYear = instantOf(today["year"]!, date.month, date.day); | |
| 192 | if (thisYear.getTime() > now.getTime()) return thisYear; | |
| 193 | return instantOf(today["year"]! + 1, date.month, date.day); | |
| 194 | } | |
| 195 | const candidate = instantOf(today["year"]!, today["month"]!, today["day"]!); | |
| 196 | if (candidate.getTime() > now.getTime()) return candidate; | |
| 197 | const tomorrow = new Date(candidate.getTime() + 36 * 3_600_000); | |
| 198 | const t = wall(tomorrow); | |
| 199 | return instantOf(t["year"]!, t["month"]!, t["day"]!); | |
| 200 | } |
src/crew/runner.ts +7 −7
| @@ -1588,16 +1588,16 @@ async function work( | ||
| 1588 | 1588 | { |
| 1589 | 1589 | tokensIn: meter.tokensIn + so.tokensIn, |
| 1590 | 1590 | tokensOut: meter.tokensOut + so.tokensOut, |
| 1591 | costUsd: meter.costUsd + so.costUsd, | |
| 1591 | costUsd: meter.costUsd + (so.costUsd ?? 0), | |
| 1592 | 1592 | }, |
| 1593 | 1593 | started, |
| 1594 | so.costUsd > 0, | |
| 1594 | (so.costUsd ?? 0) > 0, | |
| 1595 | 1595 | ), |
| 1596 | 1596 | }), |
| 1597 | 1597 | ); |
| 1598 | 1598 | meter.tokensIn += spent.tokensIn; |
| 1599 | 1599 | meter.tokensOut += spent.tokensOut; |
| 1600 | meter.costUsd += spent.costUsd; | |
| 1600 | meter.costUsd += spent.costUsd ?? 0; | |
| 1601 | 1601 | return spent; |
| 1602 | 1602 | }; |
| 1603 | 1603 | write( |
| @@ -1741,7 +1741,7 @@ async function work( | ||
| 1741 | 1741 | const { until, limit, said } = takeLimit( |
| 1742 | 1742 | app.db, |
| 1743 | 1743 | picked.kind === "ready" ? picked.subscription.id : undefined, |
| 1744 | result.result, | |
| 1744 | result.limit, | |
| 1745 | 1745 | result.reason, |
| 1746 | 1746 | now, |
| 1747 | 1747 | ); |
| @@ -1772,18 +1772,18 @@ async function work( | ||
| 1772 | 1772 | } |
| 1773 | 1773 | } |
| 1774 | 1774 | if (run.attempt < MAX_PAUSES) { |
| 1775 | const at = resumeAt(result.result, new Date()); | |
| 1775 | const at = resumeAt(result.limit, new Date()); | |
| 1776 | 1776 | pausing = true; |
| 1777 | 1777 | return { |
| 1778 | 1778 | state: "paused", |
| 1779 | summary: `paused: ${pauseReason(picked.kind === "ready" ? picked.subscription.name : undefined, limitOf(result.result, new Date()), said)}; resumes ${whenText(at)}`, | |
| 1779 | summary: `paused: ${pauseReason(picked.kind === "ready" ? picked.subscription.name : undefined, limitOf(result.limit), said)}; resumes ${whenText(at)}`, | |
| 1780 | 1780 | resumeAt: at.toISOString(), |
| 1781 | 1781 | sessionId: result.sessionId, |
| 1782 | 1782 | costUsd: result.costUsd, |
| 1783 | 1783 | }; |
| 1784 | 1784 | } |
| 1785 | 1785 | write(`paused ${MAX_PAUSES} times already; not waiting again`); |
| 1786 | limitLifts = resumeAt(result.result, new Date()); | |
| 1786 | limitLifts = resumeAt(result.limit, new Date()); | |
| 1787 | 1787 | } |
| 1788 | 1788 | |
| 1789 | 1789 | watch.lap("harness"); |
test/crew-limits.test.ts +69 −33
| @@ -5,7 +5,15 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { describe, expect, test } from "bun:test"; |
| 7 | 7 | import type { ClaudeOutcome } from "../src/crew/claude.ts"; |
| 8 | import { isRateLimited, resumeAt, whenText } from "../src/crew/limits.ts"; | |
| 8 | import { limitFromWords, withLimit } from "../src/crew/claude.ts"; | |
| 9 | import { useRunner, type AgentRunner, type RunnerLimit } from "../src/crew/agent_runner.ts"; | |
| 10 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { listRuns } from "../src/core/runs.ts"; | |
| 12 | import { agentRunSettled, startStandInCrew, stopStandInCrew } from "./helpers/crew-standin.ts"; | |
| 13 | import { isRateLimited, limitOf, resumeAt, whenText } from "../src/crew/limits.ts"; | |
| 14 | ||
| 15 | /** What the Claude runner reports for a refusal's words. */ | |
| 16 | const limitOfWords = (words: string, now: Date): RunnerLimit => limitFromWords(words, now); | |
| 9 | 17 | |
| 10 | 18 | const outcome = (over: Partial<ClaudeOutcome>): ClaudeOutcome => ({ |
| 11 | 19 | ok: false, |
| @@ -23,68 +31,55 @@ const outcome = (over: Partial<ClaudeOutcome>): ClaudeOutcome => ({ | ||
| 23 | 31 | |
| 24 | 32 | describe("the provider's limit", () => { |
| 25 | 33 | test("is told apart from the agent's own failure", () => { |
| 26 | expect(isRateLimited(outcome({ reason: "api_error" }))).toBe(true); | |
| 34 | const rated = (o: ClaudeOutcome) => isRateLimited(withLimit(o, new Date())); | |
| 35 | expect(rated(outcome({ reason: "api_error" }))).toBe(true); | |
| 27 | 36 | expect( |
| 28 | isRateLimited( | |
| 37 | rated( | |
| 29 | 38 | outcome({ |
| 30 | 39 | result: "You've hit your session limit · resets 7:20am (UTC)", |
| 31 | 40 | }), |
| 32 | 41 | ), |
| 33 | 42 | ).toBe(true); |
| 34 | expect(isRateLimited(outcome({ result: "Overloaded" }))).toBe(true); | |
| 35 | expect(isRateLimited(outcome({ result: "429 Too Many Requests" }))).toBe( | |
| 43 | expect(rated(outcome({ result: "Overloaded" }))).toBe(true); | |
| 44 | expect(rated(outcome({ result: "429 Too Many Requests" }))).toBe( | |
| 36 | 45 | true, |
| 37 | 46 | ); |
| 38 | 47 | expect( |
| 39 | isRateLimited(outcome({ result: "TypeError: x is not a function" })), | |
| 48 | rated(outcome({ result: "TypeError: x is not a function" })), | |
| 40 | 49 | ).toBe(false); |
| 41 | expect(isRateLimited(outcome({ ok: true, reason: "completed" }))).toBe( | |
| 50 | expect(rated(outcome({ ok: true, reason: "completed" }))).toBe( | |
| 42 | 51 | false, |
| 43 | 52 | ); |
| 44 | 53 | }); |
| 45 | 54 | |
| 46 | 55 | test("resumes a minute after the reset the message names, in UTC", () => { |
| 47 | 56 | const said = "You've hit your session limit · resets 7:20am (UTC)"; |
| 48 | expect(resumeAt(said, new Date("2026-09-08T06:00:00Z")).toISOString()).toBe( | |
| 49 | "2026-09-08T07:21:00.000Z", | |
| 50 | ); | |
| 57 | const now = new Date("2026-09-08T06:00:00Z"); | |
| 58 | const later = new Date("2026-09-08T08:00:00Z"); | |
| 59 | expect(resumeAt(limitOfWords(said, now), now).toISOString()).toBe("2026-09-08T07:21:00.000Z"); | |
| 51 | 60 | // Already past today's: tomorrow's. |
| 52 | expect(resumeAt(said, new Date("2026-09-08T08:00:00Z")).toISOString()).toBe( | |
| 53 | "2026-09-09T07:21:00.000Z", | |
| 54 | ); | |
| 55 | expect( | |
| 56 | resumeAt( | |
| 57 | "resets 2:20pm (UTC)", | |
| 58 | new Date("2026-09-08T06:00:00Z"), | |
| 59 | ).toISOString(), | |
| 60 | ).toBe("2026-09-08T14:21:00.000Z"); | |
| 61 | expect( | |
| 62 | resumeAt( | |
| 63 | "resets 12:05am (UTC)", | |
| 64 | new Date("2026-09-08T06:00:00Z"), | |
| 65 | ).toISOString(), | |
| 66 | ).toBe("2026-09-09T00:06:00.000Z"); | |
| 61 | expect(resumeAt(limitOfWords(said, later), later).toISOString()).toBe("2026-09-09T07:21:00.000Z"); | |
| 62 | expect(resumeAt(limitOfWords("resets 2:20pm (UTC)", now), now).toISOString()).toBe("2026-09-08T14:21:00.000Z"); | |
| 63 | expect(resumeAt(limitOfWords("resets 12:05am (UTC)", now), now).toISOString()).toBe("2026-09-09T00:06:00.000Z"); | |
| 67 | 64 | }); |
| 68 | 65 | |
| 69 | 66 | test("honours the zone, daylight time included", () => { |
| 70 | 67 | const said = |
| 71 | 68 | "You've hit your session limit · resets 2:20am (America/Chicago)"; |
| 72 | 69 | // September: CDT, UTC−5. |
| 73 | expect(resumeAt(said, new Date("2026-09-08T06:00:00Z")).toISOString()).toBe( | |
| 74 | "2026-09-08T07:21:00.000Z", | |
| 75 | ); | |
| 70 | const september = new Date("2026-09-08T06:00:00Z"); | |
| 71 | expect(resumeAt(limitOfWords(said, september), september).toISOString()).toBe("2026-09-08T07:21:00.000Z"); | |
| 76 | 72 | // January: CST, UTC−6. |
| 77 | expect(resumeAt(said, new Date("2026-01-10T06:00:00Z")).toISOString()).toBe( | |
| 78 | "2026-01-10T08:21:00.000Z", | |
| 79 | ); | |
| 73 | const january = new Date("2026-01-10T06:00:00Z"); | |
| 74 | expect(resumeAt(limitOfWords(said, january), january).toISOString()).toBe("2026-01-10T08:21:00.000Z"); | |
| 80 | 75 | }); |
| 81 | 76 | |
| 82 | 77 | test("waits fifteen minutes when no reset is named or the zone is unknown", () => { |
| 83 | 78 | const now = new Date("2026-09-08T06:00:00Z"); |
| 84 | expect(resumeAt("Overloaded", now).toISOString()).toBe( | |
| 79 | expect(resumeAt(limitOfWords("Overloaded", now), now).toISOString()).toBe( | |
| 85 | 80 | "2026-09-08T06:15:00.000Z", |
| 86 | 81 | ); |
| 87 | expect(resumeAt("resets 7:20am (Mars/Olympus)", now).toISOString()).toBe( | |
| 82 | expect(resumeAt(limitOfWords("resets 7:20am (Mars/Olympus)", now), now).toISOString()).toBe( | |
| 88 | 83 | "2026-09-08T06:15:00.000Z", |
| 89 | 84 | ); |
| 90 | 85 | }); |
| @@ -92,4 +87,45 @@ describe("the provider's limit", () => { | ||
| 92 | 87 | test("reads as a clock time on a card", () => { |
| 93 | 88 | expect(whenText(new Date("2026-09-08T07:21:00Z"))).toBe("07:21 UTC"); |
| 94 | 89 | }); |
| 90 | ||
| 91 | test("the seam's shape alone is read: a limit's time, whatever the runner", () => { | |
| 92 | // A fake runner: no Claude wording, only { limitedUntil }. | |
| 93 | const fake = outcome({ result: "slow down", reason: "quota", limit: { limitedUntil: "2026-09-08T09:00:00.000Z" } }); | |
| 94 | expect(isRateLimited(fake)).toBe(true); | |
| 95 | expect(isRateLimited({ ...fake, limit: undefined })).toBe(false); | |
| 96 | expect(resumeAt(fake.limit, new Date("2026-09-08T06:00:00Z")).toISOString()).toBe("2026-09-08T09:01:00.000Z"); | |
| 97 | expect(limitOf(fake.limit)).toEqual({ window: "5h", resetsAt: new Date("2026-09-08T09:00:00Z") }); | |
| 98 | expect(limitOf({})).toBeUndefined(); | |
| 99 | }); | |
| 100 | ||
| 101 | test("the crew pauses until the time a fake runner's limit names", async () => { | |
| 102 | const limitedUntil = new Date(Date.now() + 3 * 3_600_000).toISOString(); | |
| 103 | const calls: number[] = []; | |
| 104 | const fake: AgentRunner = { | |
| 105 | detect: async () => ({ ok: true, version: "fake" }), | |
| 106 | run: async () => { | |
| 107 | calls.push(Date.now()); | |
| 108 | return { ...outcome({ result: "slow down", reason: "quota" }), limit: { limitedUntil, window: "week" } }; | |
| 109 | }, | |
| 110 | }; | |
| 111 | const standIn = await startStandInCrew({ tickMs: 50 }); | |
| 112 | useRunner(fake); | |
| 113 | try { | |
| 114 | const { app, alice, bot, repo } = standIn; | |
| 115 | const issue = openIssue(app, alice, repo, { title: "Wait it out", body: "anything" }); | |
| 116 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 117 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 118 | const paused = await agentRunSettled(app, repo, (r) => r.issueId === issue.value.id && r.attempt === 0); | |
| 119 | expect(paused.state).toBe("cancelled"); | |
| 120 | const waiting = listRuns(app.db, repo.id, { kind: "agent" }).find( | |
| 121 | (r) => r.issueId === issue.value.id && r.state === "queued", | |
| 122 | )!; | |
| 123 | expect(waiting).toBeDefined(); | |
| 124 | expect(new Date(waiting.notBefore!).toISOString()).toBe(new Date(Date.parse(limitedUntil) + 60_000).toISOString()); | |
| 125 | expect(calls.length).toBe(1); | |
| 126 | } finally { | |
| 127 | useRunner(undefined); | |
| 128 | await stopStandInCrew(standIn); | |
| 129 | } | |
| 130 | }, 60_000); | |
| 95 | 131 | }); |
test/crew-network.test.ts +4 −2
| @@ -14,9 +14,9 @@ import { listPulls } from "../src/core/pulls.ts"; | ||
| 14 | 14 | import { listRuns, type Run } from "../src/core/runs.ts"; |
| 15 | 15 | import { MAX_PAUSES } from "../src/crew/limits.ts"; |
| 16 | 16 | import { isNetworkError, NETWORK_PAUSE_MS } from "../src/crew/network.ts"; |
| 17 | import { isRateLimited } from "../src/crew/limits.ts"; | |
| 17 | import { isRateLimited as limited } from "../src/crew/limits.ts"; | |
| 18 | import { withLimit, type ClaudeOutcome } from "../src/crew/claude.ts"; | |
| 18 | 19 | import { interruptionOf } from "../src/crew/interrupted.ts"; |
| 19 | import type { ClaudeOutcome } from "../src/crew/claude.ts"; | |
| 20 | 20 | import { |
| 21 | 21 | agentRunSettled, |
| 22 | 22 | startStandInCrew, |
| @@ -24,6 +24,8 @@ import { | ||
| 24 | 24 | type StandInCrew, |
| 25 | 25 | } from "./helpers/crew-standin.ts"; |
| 26 | 26 | |
| 27 | const isRateLimited = (o: ClaudeOutcome): boolean => limited(withLimit(o, new Date())); | |
| 28 | ||
| 27 | 29 | const outcome = (over: Partial<ClaudeOutcome>): ClaudeOutcome => ({ |
| 28 | 30 | ok: false, |
| 29 | 31 | sessionId: "", |
test/standing.test.ts +11 −9
| @@ -23,33 +23,35 @@ import { addSubscription, deleteSubscription } from "../src/core/subscriptions.t | ||
| 23 | 23 | import { recordUsage } from "../src/core/usage.ts"; |
| 24 | 24 | import { createUser } from "../src/core/users.ts"; |
| 25 | 25 | import { limitOf, resumeAt } from "../src/crew/limits.ts"; |
| 26 | import { limitFromWords } from "../src/crew/claude.ts"; | |
| 27 | import type { RunnerLimit } from "../src/crew/agent_runner.ts"; | |
| 28 | ||
| 29 | /** What the Claude runner makes of a refusal's words: the seam's limit. */ | |
| 30 | const refusal = (text: string, now: Date = new Date()): RunnerLimit => limitFromWords(text, now); | |
| 26 | 31 | |
| 27 | 32 | describe("which window the provider says is full", () => { |
| 28 | 33 | test("a bare clock time is the 5-hour window, at that time exactly", () => { |
| 29 | 34 | const now = new Date("2026-10-02T05:00:00Z"); |
| 30 | 35 | const said = "You've hit your session limit · resets 7:20am (UTC)"; |
| 31 | expect(limitOf(said, now)).toEqual({ | |
| 36 | expect(limitOf(refusal(said, now))).toEqual({ | |
| 32 | 37 | window: "5h", |
| 33 | 38 | resetsAt: new Date("2026-10-02T07:20:00Z"), |
| 34 | 39 | }); |
| 35 | 40 | // The pause still waits a minute past it. |
| 36 | expect(resumeAt(said, now).toISOString()).toBe("2026-10-02T07:21:00.000Z"); | |
| 41 | expect(resumeAt(refusal(said, now), now).toISOString()).toBe("2026-10-02T07:21:00.000Z"); | |
| 37 | 42 | }); |
| 38 | 43 | test("a dated reset, or one named weekly, is the week's", () => { |
| 39 | 44 | // 9pm in Chicago in September is daylight time, UTC−5: 02:00Z next day. |
| 40 | 45 | expect( |
| 41 | limitOf( | |
| 42 | "You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)", | |
| 43 | new Date("2026-09-24T00:00:00Z"), | |
| 44 | ), | |
| 46 | limitOf(refusal("You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)", new Date("2026-09-24T00:00:00Z"))), | |
| 45 | 47 | ).toEqual({ window: "week", resetsAt: new Date("2026-09-27T02:00:00Z") }); |
| 46 | 48 | expect( |
| 47 | limitOf("Weekly limit reached · resets 3am (UTC)", new Date("2026-10-02T05:00:00Z")), | |
| 49 | limitOf(refusal("Weekly limit reached · resets 3am (UTC)", new Date("2026-10-02T05:00:00Z"))), | |
| 48 | 50 | ).toEqual({ window: "week", resetsAt: new Date("2026-10-03T03:00:00Z") }); |
| 49 | 51 | }); |
| 50 | 52 | test("no time named is nothing read", () => { |
| 51 | expect(limitOf("rate limit exceeded", new Date())).toBeUndefined(); | |
| 52 | expect(limitOf("resets 7:20am (Not/AZone)", new Date())).toBeUndefined(); | |
| 53 | expect(limitOf(refusal("rate limit exceeded", new Date()))).toBeUndefined(); | |
| 54 | expect(limitOf(refusal("resets 7:20am (Not/AZone)", new Date()))).toBeUndefined(); | |
| 53 | 55 | }); |
| 54 | 56 | }); |
| 55 | 57 |
test/subscriptions.test.ts +11 −9
| @@ -31,6 +31,11 @@ import { | ||
| 31 | 31 | import { createUser, type User } from "../src/core/users.ts"; |
| 32 | 32 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 33 | 33 | import { isRateLimited, resumeAt } from "../src/crew/limits.ts"; |
| 34 | import { limitFromWords, withLimit } from "../src/crew/claude.ts"; | |
| 35 | import type { RunnerLimit } from "../src/crew/agent_runner.ts"; | |
| 36 | ||
| 37 | /** What the Claude runner makes of a refusal's words: the seam's limit. */ | |
| 38 | const refusal = (text: string, now: Date = new Date()): RunnerLimit => limitFromWords(text, now); | |
| 34 | 39 | import { serve, type Servers } from "../src/http/server.ts"; |
| 35 | 40 | import { stylesheetAsset } from "../src/web/asset.ts"; |
| 36 | 41 | import { adminCookie } from "./helpers/admin.ts"; |
| @@ -240,28 +245,25 @@ describe("the pool", () => { | ||
| 240 | 245 | describe("the provider's reset time", () => { |
| 241 | 246 | const now = new Date("2026-09-23T02:10:00Z"); |
| 242 | 247 | test("names a date for the week's limit", () => { |
| 243 | const at = resumeAt( | |
| 244 | "You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)", | |
| 245 | now, | |
| 246 | ); | |
| 248 | const at = resumeAt(refusal("You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)", now), now); | |
| 247 | 249 | // 9pm Chicago on the 26th is 02:00 UTC on the 27th, plus the minute. |
| 248 | 250 | expect(at.toISOString()).toBe("2026-09-27T02:01:00.000Z"); |
| 249 | 251 | expect( |
| 250 | resumeAt("resets Sep 26, 2am (America/Chicago)", now).toISOString(), | |
| 252 | resumeAt(refusal("resets Sep 26, 2am (America/Chicago)", now), now).toISOString(), | |
| 251 | 253 | ).toBe("2026-09-26T07:01:00.000Z"); |
| 252 | 254 | }); |
| 253 | 255 | test("a date already past means next year's", () => { |
| 254 | const at = resumeAt("resets Sep 20 at 9pm (UTC)", now); | |
| 256 | const at = resumeAt(refusal("resets Sep 20 at 9pm (UTC)", now), now); | |
| 255 | 257 | expect(at.toISOString()).toBe("2027-09-20T21:01:00.000Z"); |
| 256 | 258 | }); |
| 257 | 259 | test("still reads a bare clock time", () => { |
| 258 | expect(resumeAt("resets 7am (UTC)", now).toISOString()).toBe( | |
| 260 | expect(resumeAt(refusal("resets 7am (UTC)", now), now).toISOString()).toBe( | |
| 259 | 261 | "2026-09-23T07:01:00.000Z", |
| 260 | 262 | ); |
| 261 | 263 | }); |
| 262 | 264 | test("the weekly wording counts as the provider's refusal", () => { |
| 263 | 265 | expect( |
| 264 | isRateLimited({ | |
| 266 | isRateLimited(withLimit({ | |
| 265 | 267 | ok: false, |
| 266 | 268 | sessionId: "", |
| 267 | 269 | result: "You've hit your weekly limit · resets Sep 26 at 9pm (UTC)", |
| @@ -272,7 +274,7 @@ describe("the provider's reset time", () => { | ||
| 272 | 274 | contextTokens: 0, |
| 273 | 275 | contextWindow: 0, |
| 274 | 276 | reason: "error_during_execution", |
| 275 | }), | |
| 277 | }, now)), | |
| 276 | 278 | ).toBe(true); |
| 277 | 279 | }); |
| 278 | 280 | }); |
new above
offline