Skip to content
Open
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
39 changes: 28 additions & 11 deletions packages/core/src/cachekeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export type CacheKeepPrewarmResult =
cache_read_input_tokens?: number
}
}
| { ok: false; reason: string; status?: number }
| { ok: false; reason: string; status?: number; transient?: true }

export class CacheKeepManager {
private readonly targets = new Map<string, CacheKeepTarget>()
Expand Down Expand Up @@ -576,7 +576,15 @@ export class CacheKeepManager {
const dueAt = now + CACHE_KEEP_PREWARM_LEAD_MS
for (const target of this.targets.values()) {
if (target.cacheExpiresAt > dueAt) continue
await this.prewarm(target, now)
try {
await this.prewarm(target, now)
} catch (error) {
logger.warn('cachekeep', 'prewarm failed', {
session: target.id,
reason: error instanceof Error ? error.message : String(error),
})
target.cacheExpiresAt = now + CACHE_KEEP_PREWARM_LEAD_MS + 5 * 60_000
}
}
this.publishTrackedSessions()
}
Expand All @@ -597,14 +605,23 @@ export class CacheKeepManager {
: new Headers(target.headers)
headers.delete('content-length')
headers.delete('transfer-encoding')
const response = await fetchImpl(target.url, {
method: 'POST',
headers,
body: prewarm.bodyText,
signal: AbortSignal.timeout(
this.options.prewarmTimeoutMs ?? CACHE_KEEP_PREWARM_TIMEOUT_MS,
),
})
let response: Response
try {
response = await fetchImpl(target.url, {
method: 'POST',
headers,
body: prewarm.bodyText,
signal: AbortSignal.timeout(
this.options.prewarmTimeoutMs ?? CACHE_KEEP_PREWARM_TIMEOUT_MS,
),
})
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
transient: true,
}
}
if (!response.ok) {
return {
ok: false,
Expand Down Expand Up @@ -632,7 +649,7 @@ export class CacheKeepManager {
private async prewarm(target: CacheKeepTarget, now: number) {
const result = await this.sendPrewarm(target)
if (!result.ok) {
if (result.status == null) {
if (result.status == null && !result.transient) {
logger.debug('cachekeep', 'prewarm skipped', {
session: target.id,
reason: result.reason,
Expand Down
101 changes: 99 additions & 2 deletions packages/opencode/src/tests/cachekeep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ describe('CacheKeepManager', () => {
prewarmTimeoutMs: 5,
})

const result = manager.prewarmNow({
const result = await manager.prewarmNow({
sessionId: 'ses_timeout',
url: 'https://api.anthropic.com/v1/messages?beta=true',
headers: new Headers(),
Expand All @@ -330,7 +330,10 @@ describe('CacheKeepManager', () => {
}),
})

await expect(result).rejects.toMatchObject({ name: 'TimeoutError' })
expect(result.ok).toBe(false)
if (result.ok) throw new Error('expected prewarm to fail')
expect(result.transient).toBe(true)
expect(result.reason).toContain('timed out')
})

test('passes OAuth account identity to prewarm header refresh', async () => {
Expand Down Expand Up @@ -651,4 +654,98 @@ describe('CacheKeepManager', () => {
expect(fetchImpl).toHaveBeenCalledTimes(1)
manager.stop()
})

test('contains thrown prewarm errors and continues with later targets', async () => {
let now = new Date('2026-05-18T10:00:00').getTime()
const attempts: string[] = []
let publishCount = 0
const timeout = new Error('The operation timed out.')
timeout.name = 'TimeoutError'
const fetchImpl = mock((input: string | URL | Request) => {
const url = String(input)
attempts.push(url)
if (url.endsWith('/A')) throw timeout
return Promise.resolve(new Response('{}', { status: 200 }))
}) as unknown as typeof fetch
const manager = new CacheKeepManager({
loadStorage: () => Promise.resolve(hybridStorage()),
fetchImpl,
now: () => now,
onTrackedSessionsChanged: () => {
publishCount++
},
})
const body = JSON.stringify({
system: [
{ type: 'text', text: 'stable', cache_control: { type: 'ephemeral' } },
],
messages: [{ role: 'user', content: 'hello' }],
})

for (const id of ['A', 'B', 'C']) {
await manager.track({
sessionId: `ses_${id}`,
url: `https://api.anthropic.com/v1/messages/${id}`,
headers: new Headers(),
bodyText: body,
storage: hybridStorage(),
cacheMode: 'hybrid',
})
}

const publishedBeforeTick = publishCount
now += 55 * 60_000
await manager.tick()

expect(attempts).toEqual([
'https://api.anthropic.com/v1/messages/A',
'https://api.anthropic.com/v1/messages/B',
'https://api.anthropic.com/v1/messages/C',
])
expect(manager.trackedSessions().map((session) => session.id)).toEqual([
'ses_A',
'ses_B',
'ses_C',
])
expect(
manager.trackedSessions().find((session) => session.id === 'ses_A')
?.cacheExpiresAt,
).toBe(now + 10 * 60_000)
expect(publishCount).toBeGreaterThan(publishedBeforeTick)

attempts.length = 0
now += 60_000
await manager.tick()
expect(attempts).toEqual([])
manager.stop()
})

test('deletes a tracked target when its prewarm body is unbuildable', async () => {
let now = new Date('2026-05-18T10:00:00').getTime()
const fetchImpl = mock(() =>
Promise.resolve(new Response('{}', { status: 200 })),
) as unknown as typeof fetch
const manager = new CacheKeepManager({
loadStorage: () => Promise.resolve(hybridStorage()),
fetchImpl,
now: () => now,
})

await manager.track({
sessionId: 'ses_unbuildable',
url: 'https://api.anthropic.com/v1/messages/unbuildable',
headers: new Headers(),
bodyText: JSON.stringify({
messages: [{ role: 'user', content: 'hello' }],
}),
storage: hybridStorage(),
cacheMode: 'hybrid',
})

now += 55 * 60_000
await manager.tick()
expect(fetchImpl).not.toHaveBeenCalled()
expect(manager.trackedCount()).toBe(0)
manager.stop()
})
})