fix(native-chat): record the account each chat runtime launched on

record.accountHome names only the founding account, so after a switch
nothing said which account a usage limit hit. Owner proof now records the
account each runtime fence launched under, every host cut carries its
turn's fence, and the hold reads the cut's account from that pair. This
replaces the sweep's saved copy, which a fast child exit could miss.

Spending the new account showed before a switch now holds the chat until
its own reset; only a reading taken after the switch releases it.
This commit is contained in:
Kelvin Amoaba
2026-11-12 22:16:17 +00:00
parent 401d87d4a2
commit 40f39e72f9
13 changed files with 112 additions and 105 deletions
@@ -102,6 +102,7 @@ export async function acquireOwner(
sessionId: record.sessionId,
fence,
...(acquired.link ? { link: acquired.link } : {}),
...(acquired.accountHome ? { launchedAccountHome: acquired.accountHome } : {}),
...(acquired.accountRecipe
? {
accountAcquisition: {
@@ -75,12 +75,13 @@ async function heldChat() {
launchUnder: (next: UsageLimitAccountScope) => {
attributed = next
},
/** The cut ran on account A; a start of this chat would now launch on `path`. */
/** The cut's runtime launched on account A; a start of this chat would now launch on `path`. */
switchAccount: async (path: string) => {
await current.store.recordUsageLimitCutAccount(SESSION, {
turnItemId: cut.turnItemId,
accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }
})
const fence = journal.itemFence(cut.turnItemId)!
await current.store.transitionHandoff(SESSION, (record) => ({
...record,
launchedAccount: { fence, accountHome: { variable: 'CODEX_HOME', path: '/homes/a' } }
}))
current.host.deps.launchAccountHome = async () => ({ variable: 'CODEX_HOME', path })
},
setEnabled: (next: boolean) => {
@@ -151,6 +152,24 @@ it('continues before the cut resets once the chat would start on another account
await settleOne(await chat.pass(start + 20_000, (now) => weekly(0, null, now)))
})
it('keeps the new account spent from a reading before the switch until a later one shows room', async () => {
const chat = await heldChat()
const start = chat.cut.resetsAt - 60_000
const newAccountReset = start + 2 * HOUR
await chat.switchAccount('/homes/b')
expect(await chat.pass(start + 5_000, () => weekly(100, newAccountReset, start + 1_000))).toEqual(
[]
)
expect(chat.state()).toMatchObject({ continueAt: newAccountReset })
expect(await chat.pass(start + 10 * 60_000, () => weekly(100, newAccountReset, start))).toEqual(
[]
)
const room = start + 20 * 60_000
expect(await chat.pass(room, (now) => weekly(0, null, now))).toEqual([])
expect(chat.state()).toMatchObject({ continueAt: room })
await settleOne(await chat.pass(room + 15_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,7 +119,6 @@ it('continues every chat whose limit reset in the same pass', async () => {
sessions: new Map(ids.map((id) => [id, {}])),
readSavedCut: () => null,
readLoadedCut: cut,
cutAccountHome: async () => null,
serialize: (_id, run) => run(),
// Each send is answered as superseded, which spends no budget; only who was sent matters here.
send: async (params) => {
@@ -125,7 +125,8 @@ export class StructuredAgentSessionJournalProjections {
contextId,
usageLimitCut: hostUsageLimitCut(
snapshot,
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions, journal.itemBody)
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions, journal.itemBody),
journal.itemFence
),
firstInputSubmissionKey,
submissionCount: journal.inputOrder.inputCount(),
@@ -14,18 +14,6 @@ export function start(
sessions: context.sessions,
readLoadedCut: delivery.readUsageLimitCut,
readSavedCut: (id) => readSavedUsageLimitCut(context.deps.journalDatabase.db, id),
cutAccountHome: async (id, turnItemId) => {
// Only the child that ran the cut's turn knows its account, so save it while that child is live.
const session = context.sessions.get(id)
const child = session?.child
if (child?.accountHome && session?.journal.itemFence(turnItemId) === child.fence) {
const accountHome = child.accountHome
await context.deps.store.recordUsageLimitCutAccount(id, { turnItemId, accountHome })
return accountHome
}
const saved = context.deps.store.getRecord(id)?.usageLimitCutAccount
return saved?.turnItemId === turnItemId ? saved.accountHome : null
},
serialize: context.serialize,
send: (params) => host.send({ callerKey: 'trusted-local:usage-limit-continue' }, params),
publish: delivery.publishStatus,
@@ -195,10 +195,8 @@ export class UsageLimitAccountHolds {
): UsageLimitAccountVerdict {
// The account the chat moved to never hit this cut: from the move on, only its reading holds.
const moved = this.movedSince.get(id)
const cut =
moved?.cut === identity(limited)
? { ...limited, resetsAt: Math.min(limited.resetsAt, moved.at) }
: limited
const movedCut = moved?.cut === identity(limited)
const cut = movedCut ? { ...limited, resetsAt: Math.min(limited.resetsAt, moved.at) } : limited
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.
@@ -232,10 +230,11 @@ export class UsageLimitAccountHolds {
(hold.unknownReset && hold.spentSeenAt) || cut.resetsAt,
typeof covering === 'number' ? covering : cut.resetsAt
)
// Spending the new account showed before the move still holds; only a later reading releases it.
if (
account &&
reading &&
fresh(cut.resetsAt) &&
fresh(movedCut ? Number.NEGATIVE_INFINITY : cut.resetsAt) &&
now - anchor <= NATIVE_CHAT_USAGE_LIMIT_MAX_OVERDUE_MS
) {
if (typeof covering === 'number') {
@@ -250,7 +249,10 @@ export class UsageLimitAccountHolds {
hold.heldUntil = Math.max(hold.heldUntil ?? spentUntil, spentUntil)
}
hold.spentSeenAt = Math.max(hold.spentSeenAt ?? reading.updatedAt, reading.updatedAt)
} else if (reading.updatedAt > (hold.spentSeenAt ?? Number.NEGATIVE_INFINITY)) {
} else if (
fresh(cut.resetsAt) &&
reading.updatedAt > (hold.spentSeenAt ?? Number.NEGATIVE_INFINITY)
) {
// Room newer than any spending seen releases the hold at that reading, so it counts from now.
if (hold.unknownReset || (hold.heldUntil ?? Number.NEGATIVE_INFINITY) > reading.updatedAt) {
hold.heldUntil = reading.updatedAt
@@ -11,6 +11,7 @@ import { readAgentJournalTurn } from '../../../../shared/agent-session/lifecycle
import { restoreJournal } from '../../agent-session-journal/replay/journal-open'
import { renderJournalStatus } from '../../agent-session-journal/projection/journal-public-read'
import { journalInputOrder } from '../../agent-session-journal/input-order/journal-input-order'
import { journalItemExecutionFence } from '../../agent-session-journal/projection/journal-item-provenance'
import type Database from '../../../sqlite/sync-database'
import {
latestNativeChatUsageLimitCut,
@@ -168,18 +169,25 @@ export function usageLimitContinueChain(
return chain
}
/** A cut as the host's sweep reads it: with the chain that led to it. */
export type HostUsageLimitCut = NativeChatUsageLimitCut & { chain: number }
/** A cut as the host's sweep reads it: with the chain that led to it and the runtime fence that
* ran its turn, which names the account it hit. */
export type HostUsageLimitCut = NativeChatUsageLimitCut & { chain: number; fence?: number }
export function hostUsageLimitCut(
snapshot: {
items: readonly AgentJournalRenderItem[]
submissions: readonly ChainSubmission[]
},
cut: NativeChatUsageLimitCut | null
cut: NativeChatUsageLimitCut | null,
fenceOf: (itemId: string) => number | undefined
): HostUsageLimitCut | null {
const fence = cut ? fenceOf(cut.turnItemId) : undefined
return cut
? { ...cut, chain: usageLimitContinueChain(snapshot.items, snapshot.submissions, cut) }
? {
...cut,
chain: usageLimitContinueChain(snapshot.items, snapshot.submissions, cut),
...(fence === undefined ? {} : { fence })
}
: null
}
@@ -189,14 +197,13 @@ export function readSavedUsageLimitCut(
id: string
): HostUsageLimitCut | null {
const load = restoreJournal(db, id)
const snapshot =
load && !load.newer && !load.damage
? renderJournalStatus(load.state, journalInputOrder(load.state))
: null
return snapshot
? hostUsageLimitCut(
snapshot,
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions)
)
: null
if (!load || load.newer || load.damage) {
return null
}
const snapshot = renderJournalStatus(load.state, journalInputOrder(load.state))
return hostUsageLimitCut(
snapshot,
latestNativeChatUsageLimitCut(snapshot.items, snapshot.submissions),
(itemId) => journalItemExecutionFence(load.state, itemId)
)
}
@@ -16,7 +16,6 @@ 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 }
@@ -39,8 +38,6 @@ 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) => Promise<AgentSessionAccountHome | null>
publish: (id: string) => void
now: () => number
}
@@ -225,9 +222,11 @@ export class StructuredAgentSessionUsageLimitContinue {
}
}
private async attribute(id: string, cut: HostUsageLimitCut): Promise<void> {
const home = await this.deps.cutAccountHome(id, cut.turnItemId).catch(() => null)
await this.holds.attribute(id, this.deps.host, { cut: identity(cut), home }, this.deps.now())
/** The account a cut hit is the one its turn's runtime launched under, when the record names it. */
private attribute(id: string, cut: HostUsageLimitCut): Promise<void> {
const launched = this.deps.host.store.getRecord(id)?.launchedAccount
const home = launched && launched.fence === cut.fence ? launched.accountHome : null
return this.holds.attribute(id, this.deps.host, { cut: identity(cut), home }, this.deps.now())
}
private deferUnchangedCut(id: string, cut: Cut, at: number): void {
@@ -1,5 +1,6 @@
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
import type { AgentSessionProviderHandleLink } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle'
import type { AgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import {
withAcquiredAgentSessionAccount,
type ClaudeAccountRecipe
@@ -21,9 +22,11 @@ export function createAgentSessionOwnerProof(
now: number
leaseTtlMs?: number
accountAcquisition?: { generation: string; recipe: ClaudeAccountRecipe }
/** The account this runtime launched under, when the provider reports one. */
launchedAccountHome?: AgentSessionAccountHome
}): Promise<AgentSessionRecord> =>
mutate(args.sessionId, (record) =>
withAcquiredAgentSessionAccount(
mutate(args.sessionId, (record) => {
const { launchedAccount: _previous, ...proved } = withAcquiredAgentSessionAccount(
proveAgentSessionOwner({
record,
fence: args.fence,
@@ -33,5 +36,12 @@ export function createAgentSessionOwnerProof(
}),
args.accountAcquisition
)
)
// A runtime that reports no account leaves no stale claim about where this fence ran.
return args.launchedAccountHome
? {
...proved,
launchedAccount: { fence: args.fence, accountHome: args.launchedAccountHome }
}
: proved
})
}
@@ -1,6 +1,6 @@
import { agentSessionRecordPredecessorIds } from './agent-session-record-lineage'
import { clearSuccessor } from './agent-session-sender-identity-revision'
import * as usageLimit from '../../../../shared/agent-session/lifecycle/agent-session-usage-limit-continue'
import * as usageLimitContinue from '../../../../shared/agent-session/lifecycle/agent-session-usage-limit-continue'
import { createAgentSessionOwnerProof } from '../ownership/agent-session-owner-proof'
import { backfillAgentSessionPermissionModes } from '../ownership/agent-session-permission-backfill'
import { createAgentSessionConversationReceipts } from '../agent-session-conversation-receipts'
@@ -256,11 +256,10 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
}
recordUsageLimitCutAccount = (id: string, cut: usageLimit.UsageLimitCutAccount) =>
this.mutate(id, (record) => usageLimit.recordAgentSessionUsageLimitCutAccount(record, cut))
cancelUsageLimitContinue = (id: string, cut: usageLimit.UsageLimitContinueIdentity) =>
this.mutate(id, (record) => usageLimit.cancelAgentSessionUsageLimitContinue(record, cut))
cancelUsageLimitContinue = (id: string, cut: usageLimitContinue.UsageLimitContinueIdentity) =>
this.mutate(id, (record) =>
usageLimitContinue.cancelAgentSessionUsageLimitContinue(record, cut)
)
transitionHandoff = (
sessionId: string,
@@ -312,7 +311,7 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
/** The journal commit revealing a stopped cut co-writes its withdrawal; a saved one needs none. */
usageLimitContinueCancelReceipt = (
id: string,
cut: usageLimit.UsageLimitContinueIdentity
cut: usageLimitContinue.UsageLimitContinueIdentity
): JournalOperationReceipt | undefined => {
const saved = this.getRecord(id)?.usageLimitContinueCancel
return saved?.turnItemId === cut.turnItemId && saved.resetsAt === cut.resetsAt
@@ -320,7 +319,10 @@ export class AgentSessionRecordStore extends AgentSessionRecordTabs {
: this.transactions.receipt((draft) => {
const record = draft.records.get(id)
if (record) {
draft.records.set(id, usageLimit.cancelAgentSessionUsageLimitContinue(record, cut))
draft.records.set(
id,
usageLimitContinue.cancelAgentSessionUsageLimitContinue(record, cut)
)
}
})
}
@@ -86,6 +86,26 @@ export function agentPinsAccountHome(
: agent.accountLocatorKind === home.kind
}
/** The account a chat's runtime at `fence` launched under; a chat that follows the selection
* moves on, so its founding `accountHome` no longer says where it ran. */
export type AgentSessionLaunchedAccount = { fence: number; accountHome: AgentSessionAccountHome }
export function isOptionalAgentSessionLaunchedAccount(
value: unknown
): value is AgentSessionLaunchedAccount | undefined {
return (
value === undefined ||
(typeof value === 'object' &&
value !== null &&
'fence' in value &&
typeof value.fence === 'number' &&
Number.isSafeInteger(value.fence) &&
value.fence > 0 &&
'accountHome' in value &&
isAgentSessionAccountHome(value.accountHome))
)
}
function isBoundedAccountString(value: unknown, max: number): value is string {
return (
typeof value === 'string' &&
@@ -1,4 +1,4 @@
import { hasValidUsageLimitRecordFields } from './agent-session-usage-limit-continue'
import { isOptionalUsageLimitContinueCancel } from './agent-session-usage-limit-continue'
import type { AgentSessionDeathEvidence } from './agent-session-death-evidence'
import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind'
import { isAgentSessionLaunchArgs } from './agent-session-launch-args'
@@ -30,8 +30,10 @@ import { isStructuredAgentId } from './agent-session-provider-handle-encoding'
import { isNonNegativeSafeInteger } from '../../ssh-retained-payload-limits'
import {
isAgentSessionAccountHome,
isOptionalAgentSessionLaunchedAccount,
MAX_PATH_LENGTH,
type AgentSessionAccountHome
type AgentSessionAccountHome,
type AgentSessionLaunchedAccount
} from './agent-session-account-home'
export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const
@@ -119,6 +121,8 @@ export type AgentSessionRecord = {
providerHandleChain: AgentSessionProviderHandleLink[]
providerContextBoundary?: AgentSessionProviderContextBoundary
accountHome: AgentSessionAccountHome
/** Where the latest proven runtime actually launched; absent before any or from older hosts. */
launchedAccount?: AgentSessionLaunchedAccount
/** Optional host-written construction observation; checked independently of record readability. */
acquiredAccount?: unknown
/** Initial launch directory in host path syntax. Floating sessions resume here; worktree/folder IDs resolve their durable location. */
@@ -128,8 +132,6 @@ export type AgentSessionRecord = {
/** The permission mode the user chose; Orca's, so a provider's options answer never replaces it. */
permissionMode?: string
usageLimitContinueCancel?: { turnItemId: string; resetsAt: number }
/** The account the latest usage-limit cut hit, saved while the child that ran it was live. */
usageLimitCutAccount?: { turnItemId: string; accountHome: AgentSessionAccountHome }
rewind?: AgentSessionRewindRecord
conversationCommand?: AgentSessionConversationCommandRecord
/** The conversation's one name: Orca's automatic name or the user's rename. Session History,
@@ -315,12 +317,13 @@ export function isPersistedAgentSessionObservationRecord(
isAgentSessionExecutionLocation(record.location) &&
isStructuredAgentId(record.provider) &&
isAgentSessionAccountHome(record.accountHome) &&
isOptionalAgentSessionLaunchedAccount(record.launchedAccount) &&
(record.launchDirectory === undefined ||
isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.permissionMode === undefined ||
isBoundedString(record.permissionMode, MAX_ID_LENGTH)) &&
hasValidUsageLimitRecordFields(record) &&
isOptionalUsageLimitContinueCancel(record.usageLimitContinueCancel) &&
(record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
(record.providerContextBoundary === undefined ||
isAgentSessionProviderContextBoundary(record.providerContextBoundary)) &&
@@ -1,9 +1,4 @@
import type { AgentSessionRecord } from './agent-session-record'
import {
agentSessionAccountHomesEqual,
isAgentSessionAccountHome,
type AgentSessionAccountHome
} from './agent-session-account-home'
export type UsageLimitContinueIdentity = { turnItemId: string; resetsAt: number }
@@ -23,45 +18,6 @@ export function isOptionalUsageLimitContinueCancel(
)
}
/** The account whose usage limit ended a turn; that account's reset is the one that holds it. */
export type UsageLimitCutAccount = { turnItemId: string; accountHome: AgentSessionAccountHome }
export function isOptionalUsageLimitCutAccount(
value: unknown
): value is UsageLimitCutAccount | undefined {
return (
value === undefined ||
(typeof value === 'object' &&
value !== null &&
'turnItemId' in value &&
typeof value.turnItemId === 'string' &&
value.turnItemId.length > 0 &&
'accountHome' in value &&
isAgentSessionAccountHome(value.accountHome))
)
}
export function hasValidUsageLimitRecordFields(record: {
usageLimitContinueCancel?: unknown
usageLimitCutAccount?: unknown
}): boolean {
return (
isOptionalUsageLimitContinueCancel(record.usageLimitContinueCancel) &&
isOptionalUsageLimitCutAccount(record.usageLimitCutAccount)
)
}
export function recordAgentSessionUsageLimitCutAccount(
record: AgentSessionRecord,
cut: UsageLimitCutAccount
): AgentSessionRecord {
const saved = record.usageLimitCutAccount
return saved?.turnItemId === cut.turnItemId &&
agentSessionAccountHomesEqual(saved.accountHome, cut.accountHome)
? record
: { ...record, usageLimitCutAccount: cut, updatedAt: Date.now() }
}
export function cancelAgentSessionUsageLimitContinue(
record: AgentSessionRecord,
cut: UsageLimitContinueIdentity