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
2 changes: 1 addition & 1 deletion .changeset/quota-boundary-continuous.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions packages/framework-dashboard/components/Quota.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions packages/framework-dashboard/components/RoutineWork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,9 @@ export function RoutineWork({
</p>
)}

{/* 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.) */}
<div className="border-t border-border pt-3">
<div className="flex items-center justify-between gap-2">
<Tooltip>
Expand Down
9 changes: 6 additions & 3 deletions packages/framework-dashboard/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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. */}
<NumberRow
label="Spend offset"
description={`How far unattended work sits from the quota boundary, in percentage points (max ${MAX_SPEND_OFFSET}). Negative holds it back; positive lets it borrow from the days ahead.`}
value={preferences.autoSpendOffset ?? 0}
value={Math.round((preferences.autoSpendOffset ?? DEFAULT_SPEND_OFFSET) * 10) / 10}
min={-MAX_SPEND_OFFSET}
max={MAX_SPEND_OFFSET}
onChange={value => updatePreferences({ autoSpendOffset: value })}
Expand Down
4 changes: 2 additions & 2 deletions packages/framework-dashboard/lib/quota-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 3 additions & 2 deletions packages/framework-dashboard/lib/quota-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion packages/the-framework/src/auto-pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/the-framework/src/auto-pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
17 changes: 16 additions & 1 deletion packages/the-framework/src/consumption-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions packages/the-framework/src/consumption-guard.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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. */
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. */
Expand Down Expand Up @@ -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
},
}
Expand Down
Loading