diff --git a/.changeset/quota-boundary-continuous.md b/.changeset/quota-boundary-continuous.md index 4f03e86e..dddc0cf2 100644 --- a/.changeset/quota-boundary-continuous.md +++ b/.changeset/quota-boundary-continuous.md @@ -2,4 +2,4 @@ '@gemstack/the-framework': patch --- -The quota boundary (#879) now rises continuously with the clock — a plain pro-rated share of the elapsed week — instead of jumping to the next seventh once every 24 hours. A stepped boundary unlocked a whole day's worth of allowance the instant a new day began (including the entire week's worth on the last day), which read as generous on paper but let a burst of spending land the moment the clock ticked over rather than pacing with it. The usage panel's boundary line moves the same way it always did — it just draws whatever the framework reports — so this is purely a change to what auto PM and the panel treat as "on pace," not to the dashboard. +The quota boundary (#879) now rises continuously with the clock — a plain pro-rated share of the elapsed week — instead of jumping to the next seventh once every 24 hours. A stepped boundary unlocked a whole day's worth of allowance the instant a new day began (including the entire week's worth on the last day), which read as generous on paper but let a burst of spending land the moment the clock ticked over rather than pacing with it. The usage panel's boundary line moves the same way it always did — it just draws whatever the framework reports. The per-run consumption guard (#519) measures against the same line, so it now carries the same half-day cushion unattended work gets by default: the continuous boundary starts the week at zero, and without the cushion the first whole percent the agent reports would have paused the user's own first run of the week — where the stepped line had always kept at least the current day's seventh in hand. The cushion is fixed, deliberately not the auto-spend slider: holding unattended work back must never tighten the gate on work the user asked for. diff --git a/packages/framework-dashboard/components/Quota.tsx b/packages/framework-dashboard/components/Quota.tsx index 63646035..2f79ff0a 100644 --- a/packages/framework-dashboard/components/Quota.tsx +++ b/packages/framework-dashboard/components/Quota.tsx @@ -88,9 +88,9 @@ function WeekBar({ // How far ahead of the boundary's own pace the knob itself sits, as a duration — the same // arithmetic as the main figure's deviation, but of the limit rather than actual consumption. const limitDeviationMs = paceDeviationMs(limit, boundary.percent, boundary.resetsAt - boundary.startsAt) - // A full day above the boundary, not merely past it — the boundary steps a day at a time on its - // own, so a knob resting a few points ahead is normal and only worth flagging once it clears a - // whole day's worth of pace. + // A full day above the boundary, not merely past it — the knob already rests half a day ahead + // by default, so a few points past the boundary is the normal state and only worth flagging + // once it clears a whole day's worth of pace. const eagerConsumption = limit > boundary.percent + ONE_DAY_PERCENT const label = `${Math.round(percentUsed)}% of the week used, against a boundary of ${Math.round(boundary.percent)}% on day ${boundary.day} of 7` // How far ahead of or behind the boundary's own pace consumption is, as a duration (#960 Edit): diff --git a/packages/framework-dashboard/components/RoutineWork.tsx b/packages/framework-dashboard/components/RoutineWork.tsx index c127f2c1..2f6f681b 100644 --- a/packages/framework-dashboard/components/RoutineWork.tsx +++ b/packages/framework-dashboard/components/RoutineWork.tsx @@ -169,8 +169,9 @@ export function RoutineWork({

)} - {/* The same `autoPm` preference the usage panel offers (#1161), which is the point: one - switch, shown where the schedule it governs is listed. */} + {/* The same `autoPm` preference the Settings page offers (#1161), which is the point: + one switch, shown where the schedule it governs is listed. (The usage panel's copy + of it is gone, #960 Edit.) */}
diff --git a/packages/framework-dashboard/components/SettingsPage.tsx b/packages/framework-dashboard/components/SettingsPage.tsx index 717e81b9..c110018a 100644 --- a/packages/framework-dashboard/components/SettingsPage.tsx +++ b/packages/framework-dashboard/components/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react' import type { Preferences } from '@gemstack/the-framework' -import { AGENTS, AGENT_LABELS, MAX_SPEND_OFFSET } from '@gemstack/the-framework/client' +import { AGENTS, AGENT_LABELS, MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET } from '@gemstack/the-framework/client' import { useDetectedEditors } from '../lib/editors.js' import { usePreferences, updatePreferences, themePreference, type ThemePreference } from '../lib/preferences.js' import { runOptionRows, type OptionRow } from '../lib/run-option-rows.js' @@ -201,11 +201,14 @@ export function SettingsPage({ onChange={next => updatePreferences({ autoPm: next })} /> {/* Bounded to the same ±MAX_SPEND_OFFSET the slider and the sanitizer use (#960). Without - it a typed 9999 was clamped to 50 on save while the box kept showing 9999. */} + it a typed 9999 was clamped to 50 on save while the box kept showing 9999. + An untouched preference shows the real default in force — the half-day cushion + (#960 Edit), to one decimal — not a 0 the daemon isn't using. A saved value is an + integer, so the rounding only ever trims the default. */} updatePreferences({ autoSpendOffset: value })} diff --git a/packages/framework-dashboard/lib/quota-bar.test.ts b/packages/framework-dashboard/lib/quota-bar.test.ts index cce4006c..ce8a743c 100644 --- a/packages/framework-dashboard/lib/quota-bar.test.ts +++ b/packages/framework-dashboard/lib/quota-bar.test.ts @@ -64,8 +64,8 @@ test('the built-in labels are a fixed two-letter notation, not the machine local }) describe('quotaTone', () => { - // The band exists so an account spending exactly as intended does not flip colour every day at - // the moment the boundary steps a seventh. + // The band exists so an account spending exactly as intended does not flicker between colours + // for the ordinary jitter of readings either side of the line. test('reads consumption against the boundary, with a band around it', () => { expect(quotaTone(10, 43)).toBe('under') expect(quotaTone(41, 43)).toBe('near') diff --git a/packages/framework-dashboard/lib/quota-bar.ts b/packages/framework-dashboard/lib/quota-bar.ts index 4e0bf593..18fe4598 100644 --- a/packages/framework-dashboard/lib/quota-bar.ts +++ b/packages/framework-dashboard/lib/quota-bar.ts @@ -96,8 +96,9 @@ export function quotaTone(percentUsed: number, boundaryPercent: number, band = N /** * A day, as a share of the week (#960 Edit): how far above the boundary the limit has to drift - * before it's worth flagging as eager rather than merely past it. The boundary itself steps a - * whole day at a time, so a knob resting a few points ahead of it is normal, not eager. + * before it's worth flagging as eager rather than merely past it. The limit already rests half a + * day ahead by default, so a knob a few points past the boundary is the normal state — only one + * that clears a whole day's worth of pace is asking for faster-than-the-week on purpose. */ export const ONE_DAY_PERCENT = 100 / 7 diff --git a/packages/the-framework/src/auto-pm.test.ts b/packages/the-framework/src/auto-pm.test.ts index 1ea63a71..0d3da808 100644 --- a/packages/the-framework/src/auto-pm.test.ts +++ b/packages/the-framework/src/auto-pm.test.ts @@ -14,9 +14,10 @@ import { type AutoPmProject, } from './auto-pm.js' import { quotaBoundaryStatus, type QuotaBoundaryStatus } from './quota-boundary.js' +import { DEFAULT_SPEND_OFFSET } from './preference-defaults.js' import { presets, type PresetKey } from './preset-catalog.js' -/** 2026-07-20T12:00:00Z. The week below resets in 5 days 19 hours, so ~31.5% has elapsed (#960 Edit). */ +/** 2026-07-20T12:00:00Z. The week below resets in 4 days 19 hours, so ~31.5% has elapsed (#960 Edit). */ const T0 = Date.UTC(2026, 6, 20, 12, 0, 0) /** A reading where the account's week is `weekPercent` used. */ @@ -88,6 +89,19 @@ test('quotaHeadroom stands down at the boundary, and says where it sits (#879)', assert.match(decision.start === false ? decision.reason : '', /99% used, at or past day 3 of the week's 32%/) }) +test('quotaHeadroom names a fractional offset to one decimal, not fifteen digits (#960 Edit)', () => { + // The half-day default is 100/14 — the reason line should say "+7.1", not the raw double. + const boundary = quotaBoundaryStatus({ + windows: [{ label: 'Current week (all models)', kind: 'week', percentUsed: 99, resetsAtText: 'Jul 25 at 7am (UTC)' }], + now: T0, + limitOffset: DEFAULT_SPEND_OFFSET, + }) + if (!boundary) throw new Error('the fixture week should be placeable') + const decision = quotaHeadroom(boundary) + assert.equal(decision.start, false) + assert.match(decision.start === false ? decision.reason : '', /your 39% limit \(\+7\.1 on the week's 32%\)/) +}) + test('quotaHeadroom stands down the moment the boundary is met, not only when it is passed (#879)', () => { // Reads the boundary's own actual value back, rather than assuming a day/7 fraction (#960 Edit): // percent is now the continuous elapsed share of the week, not a stepped one. diff --git a/packages/the-framework/src/auto-pm.ts b/packages/the-framework/src/auto-pm.ts index e5d35d15..fa266d6b 100644 --- a/packages/the-framework/src/auto-pm.ts +++ b/packages/the-framework/src/auto-pm.ts @@ -82,10 +82,13 @@ export function quotaHeadroom(quota: QuotaBoundaryStatus | undefined): QuotaDeci if (reached) { // Name the line it actually stopped at (#960). With the slider moved, saying "the week's 43%" // when the run stopped at 63% would send someone looking for a bug that is a setting. + // The offset is rounded to one decimal for the sentence: a dragged slider stores integers, + // but the half-day default (#960 Edit) is 100/14 and fifteen digits of it would say less. const { limit, boundary } = quota + const offsetText = `${limit.offset > 0 ? '+' : ''}${Math.round(limit.offset * 10) / 10}` const line = limit.offset === 0 ? `the week's ${Math.round(boundary.percent)}%` - : `your ${Math.round(limit.percent)}% limit (${limit.offset > 0 ? '+' : ''}${limit.offset} on the week's ${Math.round(boundary.percent)}%)` + : `your ${Math.round(limit.percent)}% limit (${offsetText} on the week's ${Math.round(boundary.percent)}%)` return { start: false, reason: `${reached.label} is ${Math.round(reached.percentUsed)}% used, at or past day ${boundary.day} of ${line}`, diff --git a/packages/the-framework/src/consumption-guard.test.ts b/packages/the-framework/src/consumption-guard.test.ts index c4a55fe0..7cf1ed90 100644 --- a/packages/the-framework/src/consumption-guard.test.ts +++ b/packages/the-framework/src/consumption-guard.test.ts @@ -4,7 +4,7 @@ import { startConsumptionGuard } from './consumption-guard.js' import { FakeDriver } from './driver/index.js' import type { Driver, DriverQuota, DriverQuotaWindow } from './driver/index.js' -/** 2026-07-20T12:00:00Z. The week below resets in 5 days 19 hours, so ~31.5% has elapsed (#960 Edit). */ +/** 2026-07-20T12:00:00Z. The week below resets in 4 days 19 hours, so ~31.5% has elapsed (#960 Edit). */ const T0 = Date.UTC(2026, 6, 20, 12, 0, 0) function quotaDriver(...readings: DriverQuota[]): Driver { @@ -69,6 +69,21 @@ test("startConsumptionGuard brings the run's own model window into the gate (#87 onSonnet.stop() }) +test('startConsumptionGuard keeps a half-day cushion, so a fresh week is not paused over its first percent (#960 Edit)', async () => { + // Half an hour into a fresh week the continuous boundary sits at ~0.3%, and the agent reports + // whole percentages — without the cushion, "1% used" would pause the user's own first run. + const halfHourIn = Date.UTC(2026, 6, 18, 7, 30, 0) // week runs Jul 18 7am -> Jul 25 7am (UTC) + const guard = startConsumptionGuard({ driver: quotaDriver(week(1), week(9)), now: () => halfHourIn }) + assert.ok(guard) + // start() takes the first reading itself; let it land. + await new Promise(resolve => setImmediate(resolve)) + assert.equal(guard.gate(), null) + // The cushion is half a day (~7.1 points), not a blank cheque: past it, the gate still bites. + await guard.poller.poll() + assert.equal(guard.gate(), 'Current week (all models)') + guard.stop() +}) + test('startConsumptionGuard gate carries on when the quota cannot be read (#531)', async () => { const guard = startConsumptionGuard({ driver: quotaDriver({ available: false, reason: 'fetch-failed' }), now: () => T0 }) assert.ok(guard) diff --git a/packages/the-framework/src/consumption-guard.ts b/packages/the-framework/src/consumption-guard.ts index b86b810a..cfb31449 100644 --- a/packages/the-framework/src/consumption-guard.ts +++ b/packages/the-framework/src/consumption-guard.ts @@ -1,5 +1,6 @@ import { QuotaPoller } from './quota-poller.js' import { quotaBoundaryStatus } from './quota-boundary.js' +import { DEFAULT_SPEND_OFFSET } from './preference-defaults.js' import type { Driver } from './driver/index.js' /** A live quota gate, and the polling behind it. */ @@ -7,7 +8,8 @@ export interface ConsumptionGuard { /** * Pass as `consumptionGate` to a run. Answers from the poller's cached * readings, so it is cheap enough to ask between every turn. The label of the - * window that reached the boundary, or null while there is room. + * window that reached the limit in force (the boundary plus its half-day + * cushion, #960 Edit), or null while there is room. */ gate: () => string | null /** The poller feeding it, exposed so a caller can read the windows off it. */ @@ -59,7 +61,13 @@ export function startConsumptionGuard(opts: StartConsumptionGuardOptions): Consu gate: () => { const windows = poller.current().lastGood?.windows if (!windows) return null - const status = quotaBoundaryStatus({ windows, now: now(), ...(opts.model ? { model: opts.model } : {}) }) + // The same half-day cushion unattended work gets by default (#960 Edit). The continuous + // boundary starts the week at zero, so without one the first integer percent the agent + // reports outruns it and the user's own first run of the week is paused over ordinary + // rounding — the stepped line this replaces always kept the current day's seventh in hand. + // Deliberately not the user's slider: that sets where *unattended* work stands down, and + // holding it back must never tighten the gate on work the user asked for. + const status = quotaBoundaryStatus({ windows, now: now(), limitOffset: DEFAULT_SPEND_OFFSET, ...(opts.model ? { model: opts.model } : {}) }) return status?.reached?.label ?? null }, }