diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatUsageLimitStatus.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatUsageLimitStatus.tsx index bdca6cc312b..6d586d03903 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatUsageLimitStatus.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatUsageLimitStatus.tsx @@ -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') { diff --git a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition.ts index 229f9d18d36..3fd364a11d2 100644 --- a/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/acquisition/structured-agent-session-acquisition.ts @@ -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: { diff --git a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-usage-limit-account-hold.test.ts b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-usage-limit-account-hold.test.ts index 3bd4356bc7a..5e160b2cf1f 100644 --- a/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-usage-limit-account-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-usage-limit-account-hold.test.ts @@ -75,6 +75,15 @@ async function heldChat() { launchUnder: (next: UsageLimitAccountScope) => { attributed = next }, + /** The cut's runtime launched on account A; a start of this chat would now launch on `path`. */ + switchAccount: async (path: string) => { + 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) => { enabled = next }, @@ -134,6 +143,33 @@ 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 + await 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('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 diff --git a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-status-journal-projection.ts index 2ebdcfae18f..fe4d0cd83e4 100644 --- a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-status-journal-projection.ts @@ -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(), diff --git a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-account-hold.ts b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-account-hold.ts index 503e3471d8f..7f67ac9cc57 100644 --- a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-account-hold.ts +++ b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-account-hold.ts @@ -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,11 @@ export class UsageLimitAccountHolds { private readonly scopes = new Map() /** The source witness each chat's attribution was computed under. */ private readonly witnesses = new Map() + /** When each chat was first seen continuing on another account than the one its cut hit. */ + private readonly movedSince = new Map< + string, + { cut: string; to: AgentSessionAccountHome; at: 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>() @@ -90,15 +98,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 { + /** Re-attributed each pass: the account a chat continues on follows the host's selection. + * `hit` names the cut and the account it hit, when known. */ + async attribute( + id: string, + host: AttributionHost, + hit?: { cut: string; home: AgentSessionAccountHome | null }, + now = Date.now() + ): Promise { const record = host.store.getRecord(id) const read = host.usageLimitAutoContinue?.accountUsage const witness = record ? liveWitness(host, record.provider) : undefined let scope: UsageLimitAccountScope | undefined + let movedTo: AgentSessionAccountHome | null = null 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 +122,9 @@ export class UsageLimitAccountHolds { this.launchHomes.set(target, launch) } const home = (await launch) ?? record.accountHome + movedTo = hit?.home && !sameAccountHome(home, hit.home) ? home : null 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 +145,13 @@ export class UsageLimitAccountHolds { } this.scopes.set(id, scope) this.witnesses.set(id, witness) + const since = this.movedSince.get(id) + if (!movedTo || !hit) { + this.movedSince.delete(id) + } else if (since?.cut !== hit.cut || !sameAccountHome(since.to, movedTo)) { + // Each destination counts from when it was chosen, so an older reading of it never releases. + this.movedSince.set(id, { cut: hit.cut, to: movedTo, at: now }) + } } /** The witness the chat's attribution was computed under; a send binds to this one. */ @@ -155,20 +178,30 @@ 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 moved = this.movedSince.get(id) + 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. @@ -202,10 +235,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') { @@ -220,7 +254,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 @@ -293,6 +330,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, diff --git a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-chain.ts b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-chain.ts index ee1e8927915..cf16f2a024f 100644 --- a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-chain.ts +++ b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-chain.ts @@ -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) + ) } diff --git a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-continue.ts b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-continue.ts index 7160665287b..9cd0ecac485 100644 --- a/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-continue.ts +++ b/src/main/native-chat/agent-session-wire/recovery/structured-agent-session-usage-limit-continue.ts @@ -132,7 +132,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 +174,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 +193,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 +222,13 @@ export class StructuredAgentSessionUsageLimitContinue { } } + /** 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 { + 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 { const current = this.cold.read(id) // Admission also refuses temporary backlog; yield the next passes without spending a failure. @@ -243,7 +246,7 @@ export class StructuredAgentSessionUsageLimitContinue { private async send(id: string, cut: Cut): Promise { // 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) { diff --git a/src/main/runtime/agent-session/ownership/agent-session-owner-proof.ts b/src/main/runtime/agent-session/ownership/agent-session-owner-proof.ts index c0278d40d9d..cd54e93b6c3 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-owner-proof.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-owner-proof.ts @@ -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 => - 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 + }) } diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx index 9e569b40797..95ceca40858 100644 --- a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx @@ -321,7 +321,9 @@ export function NativeChatNoticeRow({ ) : usageLimitContinue ? ( ) : null} diff --git a/src/renderer/src/components/native-chat/messages/notices/native-chat-orca-stop-context.ts b/src/renderer/src/components/native-chat/messages/notices/native-chat-orca-stop-context.ts index bb995befe84..bb0395228f0 100644 --- a/src/renderer/src/components/native-chat/messages/notices/native-chat-orca-stop-context.ts +++ b/src/renderer/src/components/native-chat/messages/notices/native-chat-orca-stop-context.ts @@ -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 diff --git a/src/renderer/src/components/native-chat/usage/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/usage/NativeChatInterruptedContinue.tsx index 403a8dd907f..ba52745f3da 100644 --- a/src/renderer/src/components/native-chat/usage/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/usage/NativeChatInterruptedContinue.tsx @@ -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(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 ] diff --git a/src/renderer/src/components/native-chat/usage/use-native-chat-usage-limit-auto-continue.ts b/src/renderer/src/components/native-chat/usage/use-native-chat-usage-limit-auto-continue.ts index a6d8b8dee80..d2d9ae3514c 100644 --- a/src/renderer/src/components/native-chat/usage/use-native-chat-usage-limit-auto-continue.ts +++ b/src/renderer/src/components/native-chat/usage/use-native-chat-usage-limit-auto-continue.ts @@ -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(() => { + const auto = useMemo(() => { 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] + ) } diff --git a/src/shared/agent-session/agent-session-usage-limit-continue-wire.ts b/src/shared/agent-session/agent-session-usage-limit-continue-wire.ts index 95719a1c636..af5e02a1649 100644 --- a/src/shared/agent-session/agent-session-usage-limit-continue-wire.ts +++ b/src/shared/agent-session/agent-session-usage-limit-continue-wire.ts @@ -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' } diff --git a/src/shared/agent-session/lifecycle/agent-session-account-home.ts b/src/shared/agent-session/lifecycle/agent-session-account-home.ts index 96d257eebf1..90678a19b9f 100644 --- a/src/shared/agent-session/lifecycle/agent-session-account-home.ts +++ b/src/shared/agent-session/lifecycle/agent-session-account-home.ts @@ -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' && diff --git a/src/shared/agent-session/lifecycle/agent-session-record.ts b/src/shared/agent-session/lifecycle/agent-session-record.ts index 0732f967dd7..fa42e1d26a9 100644 --- a/src/shared/agent-session/lifecycle/agent-session-record.ts +++ b/src/shared/agent-session/lifecycle/agent-session-record.ts @@ -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. */ @@ -313,6 +317,7 @@ 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)) && diff --git a/src/shared/native-chat/native-chat-usage-limit-auto-continue.ts b/src/shared/native-chat/native-chat-usage-limit-auto-continue.ts index d09ef92c864..d9a62af102b 100644 --- a/src/shared/native-chat/native-chat-usage-limit-auto-continue.ts +++ b/src/shared/native-chat/native-chat-usage-limit-auto-continue.ts @@ -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