fix(native-chat): let a chat continue on the account you switch to after a usage limit

A usage-limit reset belongs to the account that hit it. After a switch the
host kept holding the chat until the old account's reset, and desktop and
phone hid Continue until then, so Switch Account on the card did nothing.
The host now releases the hold once the chat would start on another
account, letting only that account's own reading hold it, and publishes the
earlier continue time that both clients read.
This commit is contained in:
Kelvin Amoaba
2026-11-12 21:34:30 +00:00
parent 908ab68ca0
commit 5fc95075db
12 changed files with 122 additions and 30 deletions
@@ -43,14 +43,19 @@ export function MobileNativeChatUsageLimitStatus({
const { usageLimit, openAccounts } = useContext(MobileNativeChatNoticeContext)
const failure = readWholeAgentSessionFailureFact(notice.failure)
const resetsAt = failure?.kind === 'usageLimitReached' ? failure.usageLimit?.resetsAt : undefined
const resetPassed = useResetPassed(resetsAt)
const actions =
failure?.kind === 'usageLimitReached' &&
notice.usageLimitTurnItemId !== undefined &&
usageLimit?.turnItemId === notice.usageLimitTurnItemId
? usageLimit
: null
// The line names the reset Orca waits for; Continue still keys off the cut's own reset.
// Continue applies at the cut's own reset, or earlier once the host says the chat moved accounts.
const resetPassed = useResetPassed(
resetsAt !== undefined && actions?.continueAt !== undefined
? Math.min(resetsAt, actions.continueAt)
: resetsAt
)
// The line names the reset Orca waits for.
const shownAt = actions?.autoContinue ? (actions.continueAt ?? resetsAt) : resetsAt
const shownPassed = useResetPassed(shownAt)
if (failure?.kind !== 'usageLimitReached') {
@@ -75,6 +75,10 @@ async function heldChat() {
launchUnder: (next: UsageLimitAccountScope) => {
attributed = next
},
/** Switches the account a start of this chat would launch on. */
switchAccount: (path: string) => {
current.host.deps.launchAccountHome = async () => ({ variable: 'CODEX_HOME', path })
},
setEnabled: (next: boolean) => {
enabled = next
},
@@ -134,6 +138,15 @@ it('holds again on spending inside the grace, and leaves an unknown reset once r
await settleOne(await refused.pass(room + 15_000, (now) => weekly(0, null, now)))
})
it('continues before the cut resets once the chat would start on another account with room', async () => {
const chat = await heldChat()
const start = chat.cut.resetsAt - 60_000
chat.switchAccount('/homes/b')
expect(await chat.pass(start + 5_000, (now) => weekly(0, null, now))).toEqual([])
expect(chat.state()).toMatchObject({ state: 'scheduled', continueAt: start + 5_000 })
await settleOne(await chat.pass(start + 20_000, (now) => weekly(0, null, now)))
})
it('retires a hold when the launch moves to billing the reading does not describe', async () => {
const chat = await heldChat()
const weeklyReset = chat.cut.resetsAt + 3 * HOUR
@@ -119,6 +119,7 @@ it('continues every chat whose limit reset in the same pass', async () => {
sessions: new Map(ids.map((id) => [id, {}])),
readSavedCut: () => null,
readLoadedCut: cut,
cutAccountHome: () => null,
serialize: (_id, run) => run(),
// Each send is answered as superseded, which spends no budget; only who was sent matters here.
send: async (params) => {
@@ -14,6 +14,15 @@ export function start(
sessions: context.sessions,
readLoadedCut: delivery.readUsageLimitCut,
readSavedCut: (id) => readSavedUsageLimitCut(context.deps.journalDatabase.db, id),
cutAccountHome: (id, turnItemId) => {
// The child that ran the cut's turn knows its account; otherwise the chat's own.
const session = context.sessions.get(id)
const child = session?.child
if (child?.accountHome && session?.journal.itemFence(turnItemId) === child.fence) {
return child.accountHome
}
return context.deps.store.getRecord(id)?.accountHome ?? null
},
serialize: context.serialize,
send: (params) => host.send({ callerKey: 'trusted-local:usage-limit-continue' }, params),
publish: delivery.publishStatus,
@@ -10,8 +10,11 @@ import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecy
import type { AgentSessionRecordStore } from '../../../runtime/agent-session/records/agent-session-record-store'
import {
agentSessionAccountHomePath,
agentSessionAccountHomesEqual,
isLegacyAgentSessionAccountHome,
type AgentSessionAccountHome
} from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import { usageLimitContinueIdentity as identity } from './structured-agent-session-usage-limit-chain'
/** The polled usage of the account a chat continues on, as the host attributes it. */
@@ -75,6 +78,8 @@ export class UsageLimitAccountHolds {
private readonly scopes = new Map<string, UsageLimitAccountScope | undefined>()
/** The source witness each chat's attribution was computed under. */
private readonly witnesses = new Map<string, string | undefined>()
/** When each chat was first seen continuing on another account than the one its cut hit. */
private readonly movedSince = new Map<string, number>()
/** Shared by every chat of a pass and keyed by source witness: work scales with accounts and
* their changes, not chats. */
private launchHomes = new Map<string, Promise<AgentSessionAccountHome | null>>()
@@ -90,15 +95,22 @@ export class UsageLimitAccountHolds {
this.readings = new Map()
}
/** Re-attributed each pass: the account a chat continues on follows the host's selection. */
async attribute(id: string, host: AttributionHost): Promise<void> {
/** Re-attributed each pass: the account a chat continues on follows the host's selection.
* `cutHome` is the account the cut hit, when known. */
async attribute(
id: string,
host: AttributionHost,
cutHome?: AgentSessionAccountHome | null,
now = Date.now()
): Promise<void> {
const record = host.store.getRecord(id)
const read = host.usageLimitAutoContinue?.accountUsage
const witness = record ? liveWitness(host, record.provider) : undefined
let scope: UsageLimitAccountScope | undefined
let moved = false
try {
// WSL readings never reach this host, so a WSL launch keeps the cut's own reset.
if (record && read && record.location.wslDistro === null) {
if (record && record.location.wslDistro === null) {
// Off WSL a launch home follows only the provider's selection, never the chat.
const target = JSON.stringify([witness, record.provider])
let launch = this.launchHomes.get(target)
@@ -107,8 +119,9 @@ export class UsageLimitAccountHolds {
this.launchHomes.set(target, launch)
}
const home = (await launch) ?? record.accountHome
moved = cutHome !== undefined && cutHome !== null && !sameAccountHome(home, cutHome)
const path = agentSessionAccountHomePath(home)
if (home && path) {
if (read && home && path) {
const key = JSON.stringify([witness, record.provider, path])
let reading = this.readings.get(key)
if (!reading) {
@@ -129,6 +142,11 @@ export class UsageLimitAccountHolds {
}
this.scopes.set(id, scope)
this.witnesses.set(id, witness)
if (!moved) {
this.movedSince.delete(id)
} else if (!this.movedSince.has(id)) {
this.movedSince.set(id, now)
}
}
/** The witness the chat's attribution was computed under; a send binds to this one. */
@@ -155,20 +173,32 @@ export class UsageLimitAccountHolds {
}
}
}
for (const id of this.movedSince.keys()) {
if (!shown.has(id)) {
this.movedSince.delete(id)
}
}
}
clear(): void {
this.holds.clear()
this.scopes.clear()
this.witnesses.clear()
this.movedSince.clear()
this.beginPass()
}
verdict(
id: string,
cut: { turnItemId: string; resetsAt: number },
limited: { turnItemId: string; resetsAt: number },
now: number
): UsageLimitAccountVerdict {
// The account the chat moved to never hit this cut: from the move on, only its reading holds.
const movedAt = this.movedSince.get(id)
const cut =
movedAt === undefined
? limited
: { ...limited, resetsAt: Math.min(limited.resetsAt, movedAt) }
const scope = this.scopes.get(id)
let hold = this.holds.get(id)
// Nothing attributed and nothing held: the cut decides, and nothing is kept for it.
@@ -293,6 +323,14 @@ export class UsageLimitAccountHolds {
}
}
function sameAccountHome(left: AgentSessionAccountHome, right: AgentSessionAccountHome): boolean {
return isLegacyAgentSessionAccountHome(left) && isLegacyAgentSessionAccountHome(right)
? left.variable === right.variable &&
normalizeRuntimePathForComparison(left.path) ===
normalizeRuntimePathForComparison(right.path)
: agentSessionAccountHomesEqual(left, right)
}
/** Changes whenever a source the provider's attribution reads changes. */
function liveWitness(
host: AttributionHost,
@@ -16,6 +16,7 @@ import type { sendStructuredAgentSessionTurn } from '../operations/structured-ag
import { RestartContinuationSupersededError } from './structured-agent-session-restart-continuation'
import { UsageLimitAccountHolds } from './structured-agent-session-usage-limit-account-hold'
import { UsageLimitColdCuts } from './structured-agent-session-usage-limit-cold-cuts'
import type { AgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
type Cut = HostUsageLimitCut & { resetsAt: number; turnEndedAt: number }
type Attempt = { identity: string; count: number; at: number; unconfirmed?: boolean }
@@ -38,6 +39,8 @@ type Deps = {
params: Parameters<typeof sendStructuredAgentSessionTurn>[2]
) => ReturnType<typeof sendStructuredAgentSessionTurn>
readLoadedCut: (id: string) => HostUsageLimitCut | null
/** The account the cut's turn ran on, when known. */
cutAccountHome: (id: string, turnItemId: string) => AgentSessionAccountHome | null
publish: (id: string) => void
now: () => number
}
@@ -132,7 +135,9 @@ export class StructuredAgentSessionUsageLimitContinue {
turnItemId: cut.turnItemId,
resetsAt: cut.resetsAt,
// While the reset is unknown, `hold.resetsAt` is a reading time, not a reset.
...(!hold.unknownReset && hold.resetsAt > cut.resetsAt ? { continueAt: hold.resetsAt } : {}),
...(!hold.unknownReset && hold.resetsAt !== cut.resetsAt
? { continueAt: hold.resetsAt }
: {}),
state
}
}
@@ -172,13 +177,7 @@ export class StructuredAgentSessionUsageLimitContinue {
if (this.running || this.disposed) {
return
}
// Off, nothing can be scheduled; the settings listener publishes the switch itself.
if (!this.enabled()) {
if (this.holds.size + this.cold.size + this.attempts.size > 0) {
this.retainShown(this.deps.host.store.listVisibleSessionIds())
}
return
}
// Off, nothing is sent, but an account switch still moves when Continue applies.
this.running = true
try {
const visible = this.deps.host.store.listVisibleSessionIds()
@@ -197,10 +196,10 @@ export class StructuredAgentSessionUsageLimitContinue {
if (!cut || previous?.identity !== identity(cut)) {
this.attempts.delete(id)
}
// Before the overdue check: after a restart only the reading still knows a later reset.
// Only a reading taken after the cut's reset counts, so attribution waits for it.
if (cut?.resetsAt !== undefined && this.deps.now() >= cut.resetsAt) {
await this.holds.attribute(id, this.deps.host)
// Before the overdue check: after a restart only the reading still knows a later reset,
// and a switched account frees the chat before it.
if (cut?.resetsAt !== undefined) {
await this.attribute(id, cut)
}
const attempt = this.attempts.get(id)
if (!cut || !this.eligible(id, cut) || attempt?.unconfirmed || (attempt?.count ?? 0) >= 5) {
@@ -226,6 +225,15 @@ export class StructuredAgentSessionUsageLimitContinue {
}
}
private attribute(id: string, cut: HostUsageLimitCut): Promise<void> {
return this.holds.attribute(
id,
this.deps.host,
this.deps.cutAccountHome(id, cut.turnItemId),
this.deps.now()
)
}
private deferUnchangedCut(id: string, cut: Cut, at: number): void {
const current = this.cold.read(id)
// Admission also refuses temporary backlog; yield the next passes without spending a failure.
@@ -243,7 +251,7 @@ export class StructuredAgentSessionUsageLimitContinue {
private async send(id: string, cut: Cut): Promise<void> {
// Earlier sends take time; moved sources resolve again, once per new witness for every chat.
if (!this.holds.attributionCurrent(id, this.deps.host)) {
await this.holds.attribute(id, this.deps.host)
await this.attribute(id, cut)
}
const record = this.deps.host.store.getRecord(id)
if (!record) {
@@ -321,7 +321,9 @@ export function NativeChatNoticeRow({
) : usageLimitContinue ? (
<UsageLimitContinueNow
resetsAt={
failure?.kind === 'usageLimitReached' ? failure.usageLimit?.resetsAt : undefined
failure?.kind === 'usageLimitReached'
? (orcaStopView.usageLimitContinueAt ?? failure.usageLimit?.resetsAt)
: undefined
}
/>
) : null}
@@ -12,6 +12,8 @@ export type NativeChatOrcaStopView = {
continueNow: () => void
usageLimitTurnItemId: string | null
usageLimitAutoContinue?: NativeChatUsageLimitAutoContinueState | null
/** When Continue past the usage limit applies; earlier than its reset after an account switch. */
usageLimitContinueAt?: number
onUsageLimitHandOffStarted?: () => void
usageLimitContinueDisabled?: boolean
continueUsageLimit: (hadFocus?: boolean) => void
@@ -93,11 +93,8 @@ export function useNativeChatInterruptedContinuation(input: {
() => latestNativeChatUsageLimitCut(input.journalItems, input.submissions),
[input.journalItems, input.submissions]
)
const usageLimitAutoContinue = useNativeChatUsageLimitAutoContinue(
target,
sessionId,
usageLimitCut
)
const { auto: usageLimitAutoContinue, continueAt: usageLimitContinueAt } =
useNativeChatUsageLimitAutoContinue(target, sessionId, usageLimitCut)
const [attempt, setAttempt] = useState(NO_NATIVE_CHAT_INTERRUPTED_CONTINUE_ATTEMPT)
const [usageFailure, setUsageFailure] = useState<string | null>(null)
if (usageFailure !== null && input.pending.some((entry) => entry.phase === 'sending')) {
@@ -193,6 +190,7 @@ export function useNativeChatInterruptedContinuation(input: {
continueNow,
usageLimitTurnItemId,
usageLimitAutoContinue,
usageLimitContinueAt,
onUsageLimitHandOffStarted:
usageLimitAutoContinue?.kind === 'scheduled' ? usageLimitAutoContinue.cancel : undefined,
usageLimitContinueDisabled: usageCapability === 'unknown',
@@ -207,6 +205,7 @@ export function useNativeChatInterruptedContinuation(input: {
continueNow,
usageLimitTurnItemId,
usageLimitAutoContinue,
usageLimitContinueAt,
usageCapability,
continueUsageLimit
]
@@ -24,7 +24,11 @@ export function useNativeChatUsageLimitAutoContinue(
target: RuntimeClientTarget,
sessionId: string,
cut: NativeChatUsageLimitCut | null
): NativeChatUsageLimitAutoContinueState | null {
): {
auto: NativeChatUsageLimitAutoContinueState | null
/** When the host lets Continue apply: earlier than the cut's reset once the chat moved accounts. */
continueAt: number | undefined
} {
const capability = useStructuredAgentSessionHostCapabilityState(
target,
AGENT_SESSION_USAGE_LIMIT_AUTO_CONTINUE_RUNTIME_CAPABILITY
@@ -101,7 +105,7 @@ export function useNativeChatUsageLimitAutoContinue(
})
.finally(() => setCancelling(false))
}, [cancelling, readCutState, target, sessionId, turnItemId, resetsAt])
return useMemo<NativeChatUsageLimitAutoContinueState | null>(() => {
const auto = useMemo<NativeChatUsageLimitAutoContinueState | null>(() => {
const kind =
capability === 'supported'
? nativeChatUsageLimitAutoContinueKind(cutState, {
@@ -116,4 +120,14 @@ export function useNativeChatUsageLimitAutoContinue(
? { kind, resetsAt: continueAt, cancelDisabled: cancelling, cancel }
: { kind }
}, [capability, continueAt, cutState, schedulable, target.kind, cancelling, cancel])
return useMemo(
() => ({
auto,
continueAt:
resetsAt === undefined || continueAt === undefined
? resetsAt
: Math.min(resetsAt, continueAt)
}),
[auto, resetsAt, continueAt]
)
}
@@ -2,7 +2,8 @@
export type AgentSessionUsageLimitContinue = {
turnItemId: string
resetsAt: number
/** The later reset the account still shows spent, which Orca waits for; absent: `resetsAt`. */
/** When Orca continues instead of `resetsAt`: later while the account still shows spent,
* earlier once the chat would continue on another account. Absent: `resetsAt`. */
continueAt?: number
state: 'scheduled' | 'cancelled' | 'failed' | 'off'
}
@@ -25,7 +25,7 @@ export function usageLimitContinueStateForCut(
return publishedForCut(published, cut)?.state
}
/** The reset Orca waits for before continuing this cut: a later one the account still shows spent. */
/** When Orca continues this cut: a later reset the account still shows spent, or earlier after a switch. */
export function usageLimitContinueAtForCut(
published: PublishedUsageLimitContinue,
cut: CutKey