mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
fix(native-chat): continue a usage-limited chat on the account you switch to (#41665)
* 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. * fix(native-chat): save the account a usage limit hit, per cut The fallback to the chat's founding account could report a switch that never happened for a chat that had already moved, releasing it before the new account's reset. Save the cut's account while the child that ran it is live, use only that, and key the switch time by the cut it belongs to. * 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. * fix(native-chat): restart the switch time when the chat moves to yet another account A second switch for the same cut kept the first switch's time, so a reading of the newer account taken before it was chosen could release the chat.
This commit is contained in:
@@ -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') {
|
||||
|
||||
+1
@@ -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: {
|
||||
|
||||
+36
@@ -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
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+52
-7
@@ -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<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,
|
||||
{ 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<string, Promise<AgentSessionAccountHome | null>>()
|
||||
@@ -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<void> {
|
||||
/** 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<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 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,
|
||||
|
||||
+21
-14
@@ -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
-13
@@ -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<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 {
|
||||
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<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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
@@ -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
|
||||
]
|
||||
|
||||
+16
-2
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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)) &&
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user