Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2242,6 +2242,12 @@ export async function CodexAuthPlugin(
candidate.quotaCheckedAt,
]),
)
const wireAccountIdByAccount = Object.fromEntries(
eligibleCandidates.map((candidate) => [
candidate.accountId,
candidate.wireAccountId,
]),
)
const excluded = new Set(input.excludeAccountIds)
let placement:
| {
Expand All @@ -2258,6 +2264,7 @@ export async function CodexAuthPlugin(
validPinnedAccountIds: [...candidatesById.keys()],
excludeAccountIds: input.excludeAccountIds,
quotaCheckedAtByAccount,
wireAccountIdByAccount,
choose: (pendingBytes) => {
const eligible = eligibleCandidates.filter(
(candidate) => !excluded.has(candidate.accountId),
Expand Down
53 changes: 50 additions & 3 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>

export interface StickyAssignment {
accountId: string
wireAccountId?: string
assignedAt: number
lastSeenAt: number
inputBytes: number
Expand All @@ -120,6 +121,7 @@ export interface ResolveStickyAssignmentInput {
validPinnedAccountIds: readonly string[]
excludeAccountIds?: readonly string[]
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
wireAccountIdByAccount?: Readonly<Record<string, string | undefined>>
choose: (
pendingBytes: ReadonlyMap<string, number>,
) => StickyAssignmentChoice | undefined
Expand Down Expand Up @@ -256,12 +258,14 @@ function normalizeStickyAssignments(
) {
continue
}
const wireAccountId = assignment.wireAccountId
normalized[sessionHash] = {
accountId: assignment.accountId,
assignedAt: assignment.assignedAt,
lastSeenAt: assignment.lastSeenAt,
inputBytes: assignment.inputBytes,
...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
...(typeof wireAccountId === 'string' ? { wireAccountId } : {}),
}
}

Expand Down Expand Up @@ -652,10 +656,24 @@ function stickyAssignmentNeedsMetadataUpdate(
assignment: StickyAssignment,
requestBytes: number,
now: number,
wireAccountId: string | undefined,
): boolean {
return (
requestBytes > assignment.inputBytes ||
now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS ||
(assignment.wireAccountId === undefined && wireAccountId !== undefined)
)
}

function hasStickyIdentityMismatch(
assignment: StickyAssignment,
wireAccountId: string | undefined,
): boolean {
// Missing identity metadata must retain the cache-warm pin until a known change proves it stale.
return (
typeof assignment.wireAccountId === 'string' &&
typeof wireAccountId === 'string' &&
assignment.wireAccountId !== wireAccountId
)
}

Expand Down Expand Up @@ -692,9 +710,11 @@ function readonlyPendingBytes(
function pendingBytesForAssignments(
assignments: StickyAssignmentMap | undefined,
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
excludedSessionHash?: string,
): ReadonlyMap<string, number> {
const pendingBytes = new Map<string, number>()
for (const assignment of Object.values(assignments ?? {})) {
for (const [sessionHash, assignment] of Object.entries(assignments ?? {})) {
if (sessionHash === excludedSessionHash) continue
if (
assignment.quotaCheckedAt !==
quotaCheckedAtByAccount[assignment.accountId]
Expand Down Expand Up @@ -1178,10 +1198,15 @@ export async function resolveSidebarStickyAssignment(
excludedAccountIds,
input.now,
) &&
!hasStickyIdentityMismatch(
Comment thread
iceteaSA marked this conversation as resolved.
existing,
input.wireAccountIdByAccount?.[existing.accountId],
) &&
!stickyAssignmentNeedsMetadataUpdate(
existing,
input.requestBytes,
input.now,
input.wireAccountIdByAccount?.[existing.accountId],
)
) {
return existing
Expand All @@ -1202,23 +1227,38 @@ export async function resolveSidebarStickyAssignment(
stickyAssignments,
)
const current = stickyAssignments?.[sessionHash]
const currentIdentityMismatch =
current !== undefined &&
hasStickyIdentityMismatch(
current,
input.wireAccountIdByAccount?.[current.accountId],
)
if (
isValidStickyAssignment(
current,
validPinnedAccountIds,
excludedAccountIds,
input.now,
)
) &&
!currentIdentityMismatch
) {
const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
current,
input.requestBytes,
input.now,
input.wireAccountIdByAccount?.[current.accountId],
)
const assignment = metadataNeedsUpdate
? {
...current,
inputBytes: Math.max(current.inputBytes, input.requestBytes),
...(current.wireAccountId === undefined &&
input.wireAccountIdByAccount?.[current.accountId] !== undefined
? {
wireAccountId:
input.wireAccountIdByAccount?.[current.accountId],
}
: {}),
...(input.now - current.lastSeenAt >=
STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
? { lastSeenAt: input.now }
Expand All @@ -1241,6 +1281,8 @@ export async function resolveSidebarStickyAssignment(
pendingBytesForAssignments(
stickyAssignments,
input.quotaCheckedAtByAccount,
// A mismatched pin belongs to a prior identity and cannot steer its replacement.
currentIdentityMismatch ? sessionHash : undefined,
),
)
if (!choice) {
Expand All @@ -1261,6 +1303,11 @@ export async function resolveSidebarStickyAssignment(
...(choice.quotaCheckedAt === undefined
? {}
: { quotaCheckedAt: choice.quotaCheckedAt }),
...(input.wireAccountIdByAccount?.[choice.accountId] === undefined
? {}
: {
wireAccountId: input.wireAccountIdByAccount?.[choice.accountId],
}),
}
return {
...latest,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2977,6 +2977,7 @@ describe('integration: active fallback routing', () => {
const firstAssignment =
firstState.stickyAssignments?.[hashSidebarSessionId('sticky-session')]
expect(firstAssignment?.accountId).toBe('fallback-2')
expect(firstAssignment?.wireAccountId).toBe('acc-fallback-2')

const changed = JSON.parse(readFileSync(sidebarFile, 'utf8'))
changed.fallbacks[0].quota = stickyQuota(100, Date.now())
Expand Down Expand Up @@ -3005,6 +3006,10 @@ describe('integration: active fallback routing', () => {
finalState.stickyAssignments?.[hashSidebarSessionId('cold-session')]
?.accountId,
).toBe('fallback-1')
expect(
finalState.stickyAssignments?.[hashSidebarSessionId('cold-session')]
?.wireAccountId,
).toBe('acc-fallback-1')
} finally {
globalThis.fetch = originalFetch
await hooks?.dispose?.()
Expand Down
Loading