@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +255 -56
- package/dist/index.js.map +1 -1
- package/dist/lib/keepalive.d.ts +50 -8
- package/dist/lib/keepalive.d.ts.map +1 -1
- package/dist/lib/state.d.ts +45 -5
- package/dist/lib/state.d.ts.map +1 -1
- package/lib/keepalive.ts +323 -77
- package/lib/state.ts +48 -5
- package/package.json +3 -1
package/lib/keepalive.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { KeepaliveConfig } from "./config"
|
|
2
2
|
import { readControl } from "./control"
|
|
3
3
|
import { isEligibleModel } from "./model"
|
|
4
|
-
import type { KeepaliveStore, SessionKeepalive } from "./state"
|
|
4
|
+
import type { InflightPing, KeepaliveStore, PingRequest, SessionKeepalive } from "./state"
|
|
5
5
|
import type { Logger } from "./logger"
|
|
6
6
|
|
|
7
7
|
const TICK_MS = 15_000
|
|
@@ -9,19 +9,32 @@ const JITTER_MS = 15_000
|
|
|
9
9
|
const CONTROL_POLL_MS = 1_000
|
|
10
10
|
const STATUS_POLL_MS = 2_000
|
|
11
11
|
const PING_STATUS_GRACE_MS = 5_000
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* A cache last touched more than this many intervals ago is presumed cold. Pinging
|
|
14
|
+
* it would pay a full cache write for a session nobody is using, so the window
|
|
15
|
+
* stays closed until the next real turn re-arms it.
|
|
16
|
+
*/
|
|
17
|
+
const COLD_AFTER_INTERVALS = 2
|
|
18
|
+
/** Recent messages scanned for ping reverts and request-setting recovery. */
|
|
19
|
+
const MESSAGE_LOOKUP_LIMIT = 50
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* The keepalive engine.
|
|
17
23
|
*
|
|
18
24
|
* Lifecycle per session:
|
|
19
|
-
* 1. A real turn finishes -> `session.
|
|
25
|
+
* 1. A real turn finishes -> `session.status` idle -> arm a warm window anchored on
|
|
26
|
+
* the real response and schedule pings.
|
|
20
27
|
* 2. On each tick past `nextPingAt` (and within the window) -> `firePing`.
|
|
21
|
-
* 3. `firePing`
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
24
|
-
* 4. A new real user turn
|
|
28
|
+
* 3. `firePing` confirms with the server that the session is idle, sends a `~`
|
|
29
|
+
* prompt with the last real turn's agent/model/variant (exact cached prefix),
|
|
30
|
+
* reads `usage` to confirm a cache hit, then optionally reverts the `~`/`~` turn.
|
|
31
|
+
* 4. A new real user turn, the window closing, or the cache presumed cold stops warming.
|
|
32
|
+
*
|
|
33
|
+
* Threading: all state is mutated on the single plugin event loop. opencode invokes
|
|
34
|
+
* the `event` hook without awaiting it, so async handlers interleave; per-session
|
|
35
|
+
* `arming` / `warming` flags serialize the async sections.
|
|
36
|
+
*
|
|
37
|
+
* Errors: network/API failures are logged and never thrown to opencode.
|
|
25
38
|
*/
|
|
26
39
|
export class KeepaliveEngine {
|
|
27
40
|
private timer: ReturnType<typeof setInterval> | undefined
|
|
@@ -67,9 +80,14 @@ export class KeepaliveEngine {
|
|
|
67
80
|
this.statusTimer = undefined
|
|
68
81
|
}
|
|
69
82
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* True while a ping turn owns the session, so tool calls must be blocked. Becomes
|
|
85
|
+
* false as soon as a real user message joins the in-flight ping run: the rest of
|
|
86
|
+
* that run is real work and needs its tools.
|
|
87
|
+
*/
|
|
88
|
+
shouldBlockTools(sessionID: string): boolean {
|
|
89
|
+
const s = this.store.get(sessionID)
|
|
90
|
+
return !!s?.warming && !s.ping?.interrupted
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
async onEvent(event: any): Promise<void> {
|
|
@@ -85,13 +103,26 @@ export class KeepaliveEngine {
|
|
|
85
103
|
return
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
if (type === "message.part.updated") {
|
|
107
|
+
const part = event.properties?.part
|
|
108
|
+
const sessionID: string | undefined = part?.sessionID ?? event.properties?.sessionID
|
|
109
|
+
if (!sessionID) return
|
|
110
|
+
const session = this.store.get(sessionID)
|
|
111
|
+
if (session?.warming) this.notePingRunPart(session, part)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
if (type === "message.updated") {
|
|
89
116
|
const info = event.properties?.info
|
|
90
117
|
const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
|
|
91
118
|
if (!sessionID) return
|
|
92
119
|
const session = this.store.get(sessionID)
|
|
93
|
-
//
|
|
94
|
-
|
|
120
|
+
// Everything inside our own ping turn is ignored — except a real user
|
|
121
|
+
// message, which opencode joins to the running ping loop.
|
|
122
|
+
if (session?.warming) {
|
|
123
|
+
if (info?.role === "user") this.notePingRunUser(session, info)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
95
126
|
const eventAt = info?.time?.completed ?? info?.time?.created
|
|
96
127
|
// Events are asynchronous: a completed ping may still emit message updates
|
|
97
128
|
// after `warming` clears. Their timestamps predate the recorded ping.
|
|
@@ -100,8 +131,11 @@ export class KeepaliveEngine {
|
|
|
100
131
|
|
|
101
132
|
if (info?.role === "user") {
|
|
102
133
|
const s = this.store.ensure(sessionID)
|
|
134
|
+
// opencode re-emits older user messages (e.g. summary updates after a
|
|
135
|
+
// turn); only a newly created message starts a turn.
|
|
136
|
+
if (!isNewUserMessage(s, info)) return
|
|
137
|
+
noteUserMessage(s, info)
|
|
103
138
|
s.busy = true
|
|
104
|
-
s.busySince = Date.now()
|
|
105
139
|
s.active = false
|
|
106
140
|
this.store.persist()
|
|
107
141
|
return
|
|
@@ -139,7 +173,6 @@ export class KeepaliveEngine {
|
|
|
139
173
|
|
|
140
174
|
const s = this.store.ensure(sessionID)
|
|
141
175
|
s.busy = true
|
|
142
|
-
s.busySince = Date.now()
|
|
143
176
|
s.active = false
|
|
144
177
|
this.store.persist()
|
|
145
178
|
return
|
|
@@ -154,6 +187,58 @@ export class KeepaliveEngine {
|
|
|
154
187
|
}
|
|
155
188
|
}
|
|
156
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Track a new user message observed while a ping is in flight. Event order alone
|
|
192
|
+
* cannot tell the ping's own message from a real prompt racing it, so messages are
|
|
193
|
+
* held until their text part arrives (see `notePingRunPart`). Any message seen
|
|
194
|
+
* before the ping is sent, or a second new message, is necessarily a real turn.
|
|
195
|
+
*/
|
|
196
|
+
private notePingRunUser(s: SessionKeepalive, info: any): void {
|
|
197
|
+
const ping = s.ping
|
|
198
|
+
if (!ping || typeof info?.id !== "string") return
|
|
199
|
+
const created = info?.time?.created
|
|
200
|
+
if (typeof created === "number" && created < ping.startedAt) return // re-emitted old message
|
|
201
|
+
if (info.id === ping.messageID || ping.pending.has(info.id)) return
|
|
202
|
+
if (!ping.sent || ping.messageID) {
|
|
203
|
+
this.interruptPing(s, info)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
ping.pending.set(info.id, info)
|
|
207
|
+
if (ping.pending.size > 1) this.interruptPing(s)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Classify a pending user message by its first text part: the ping token marks
|
|
212
|
+
* the ping's own message; any other text is a real turn joining the ping run.
|
|
213
|
+
* opencode publishes a user message's parts right after the message itself, before
|
|
214
|
+
* the run reaches any tool call.
|
|
215
|
+
*/
|
|
216
|
+
private notePingRunPart(s: SessionKeepalive, part: any): void {
|
|
217
|
+
const ping = s.ping
|
|
218
|
+
if (!ping || part?.type !== "text" || part?.synthetic) return
|
|
219
|
+
const id = part?.messageID
|
|
220
|
+
if (typeof id !== "string" || !ping.pending.has(id)) return
|
|
221
|
+
const info = ping.pending.get(id)
|
|
222
|
+
ping.pending.delete(id)
|
|
223
|
+
if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
|
|
224
|
+
ping.messageID = id
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
this.interruptPing(s, info)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
|
|
231
|
+
private interruptPing(s: SessionKeepalive, info?: unknown): void {
|
|
232
|
+
const ping = s.ping
|
|
233
|
+
if (!ping) return
|
|
234
|
+
if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`)
|
|
235
|
+
ping.interrupted = true
|
|
236
|
+
if (info) noteUserMessage(s, info)
|
|
237
|
+
s.busy = true
|
|
238
|
+
s.active = false
|
|
239
|
+
this.store.persist()
|
|
240
|
+
}
|
|
241
|
+
|
|
157
242
|
private async armWindow(sessionID: string): Promise<void> {
|
|
158
243
|
const s = this.store.ensure(sessionID)
|
|
159
244
|
// Duplicate idle events (including late events from a ping) must not reset the
|
|
@@ -166,7 +251,6 @@ export class KeepaliveEngine {
|
|
|
166
251
|
s.arming = true
|
|
167
252
|
try {
|
|
168
253
|
s.busy = false
|
|
169
|
-
s.busySince = undefined
|
|
170
254
|
await this.resolveSession(s)
|
|
171
255
|
|
|
172
256
|
// A new turn may have started while session metadata was being resolved.
|
|
@@ -176,14 +260,17 @@ export class KeepaliveEngine {
|
|
|
176
260
|
return
|
|
177
261
|
}
|
|
178
262
|
|
|
263
|
+
// Only a recent real response leaves a cache worth warming. Idle events can
|
|
264
|
+
// also fire for sessions untouched for hours (restarts, re-emitted messages).
|
|
179
265
|
const now = Date.now()
|
|
180
|
-
if (!s.lastResponseAt
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
266
|
+
if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
|
|
267
|
+
s.active = false
|
|
268
|
+
this.store.persist()
|
|
269
|
+
this.logger.dbg(`not armed ${short(sessionID)} — no recent real response`)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.openWindow(s, now)
|
|
187
274
|
this.store.persist()
|
|
188
275
|
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
|
|
189
276
|
} finally {
|
|
@@ -191,20 +278,26 @@ export class KeepaliveEngine {
|
|
|
191
278
|
}
|
|
192
279
|
}
|
|
193
280
|
|
|
281
|
+
/** Start a warm window anchored on the last real response and the last cache touch. */
|
|
282
|
+
private openWindow(s: SessionKeepalive, now: number): void {
|
|
283
|
+
s.idleSince = now
|
|
284
|
+
s.windowEndsAt = s.lastResponseAt + this.config.windowMs
|
|
285
|
+
s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.config.intervalMs)
|
|
286
|
+
s.pingsSent = 0
|
|
287
|
+
s.lastPing = undefined
|
|
288
|
+
s.active = true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** False once the cache has gone too long without a request to still be trusted warm. */
|
|
292
|
+
private cacheMayBeWarm(s: SessionKeepalive, now: number): boolean {
|
|
293
|
+
const touch = lastCacheTouch(s)
|
|
294
|
+
return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.config.intervalMs
|
|
295
|
+
}
|
|
296
|
+
|
|
194
297
|
private async tick(): Promise<void> {
|
|
195
298
|
if (!this.enabled) return
|
|
196
299
|
const now = Date.now()
|
|
197
300
|
for (const s of this.store.all()) {
|
|
198
|
-
// Backstop: if busy is stuck (missed idle event + reconcile failure),
|
|
199
|
-
// clear it after BUSY_TIMEOUT_MS so the session can re-arm.
|
|
200
|
-
if (s.busy && s.busySince && now - s.busySince > BUSY_TIMEOUT_MS) {
|
|
201
|
-
this.logger.dbg(`busy timeout ${short(s.sessionID)} — clearing stuck flag`)
|
|
202
|
-
s.busy = false
|
|
203
|
-
s.busySince = undefined
|
|
204
|
-
void this.armWindow(s.sessionID)
|
|
205
|
-
continue
|
|
206
|
-
}
|
|
207
|
-
|
|
208
301
|
if (!s.eligible || !s.active || s.busy || s.warming) continue
|
|
209
302
|
|
|
210
303
|
if (now >= s.windowEndsAt) {
|
|
@@ -216,28 +309,85 @@ export class KeepaliveEngine {
|
|
|
216
309
|
continue
|
|
217
310
|
}
|
|
218
311
|
|
|
219
|
-
if (now
|
|
312
|
+
if (now < s.nextPingAt) continue
|
|
313
|
+
|
|
314
|
+
// Far overdue (host sleep, stalled event loop): the cache is presumed cold.
|
|
315
|
+
// Stop rather than pay a cold write or fire a catch-up burst.
|
|
316
|
+
if (!this.cacheMayBeWarm(s, now)) {
|
|
317
|
+
s.active = false
|
|
318
|
+
this.store.persist()
|
|
319
|
+
this.logger.dbg(
|
|
320
|
+
`window closed ${short(s.sessionID)} — ping overdue, cache presumed cold`,
|
|
321
|
+
)
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
void this.firePing(s.sessionID)
|
|
220
326
|
}
|
|
221
327
|
}
|
|
222
328
|
|
|
223
329
|
private async firePing(sessionID: string): Promise<void> {
|
|
224
330
|
const s = this.store.get(sessionID)
|
|
225
|
-
if (!s || s.warming || s.busy) return
|
|
331
|
+
if (!s || s.warming || s.busy || !s.active) return
|
|
226
332
|
|
|
333
|
+
const ping: InflightPing = {
|
|
334
|
+
startedAt: Date.now(),
|
|
335
|
+
sent: false,
|
|
336
|
+
pending: new Map(),
|
|
337
|
+
interrupted: false,
|
|
338
|
+
}
|
|
227
339
|
s.warming = true
|
|
228
|
-
|
|
229
|
-
// scheduled ping slot and must remain visible in the footer.
|
|
230
|
-
s.pingsSent += 1
|
|
340
|
+
s.ping = ping
|
|
231
341
|
this.store.persist()
|
|
232
|
-
const startAt = Date.now()
|
|
233
342
|
|
|
234
343
|
try {
|
|
344
|
+
// opencode joins a prompt to a running turn instead of rejecting it, so a
|
|
345
|
+
// missed busy signal would inject the ping into real work. Ask the server.
|
|
346
|
+
if (!(await this.isIdleOnServer(sessionID))) {
|
|
347
|
+
s.busy = true
|
|
348
|
+
s.active = false
|
|
349
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped — session busy`)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const request = s.request ?? (await this.lookupRequest(s))
|
|
354
|
+
if (!request) {
|
|
355
|
+
s.active = false
|
|
356
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped — last real request unknown`)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// A real turn may have started (or keepalive been disabled) while checking.
|
|
361
|
+
if (ping.interrupted || s.busy || !s.active || !this.enabled) return
|
|
362
|
+
|
|
363
|
+
ping.sent = true
|
|
364
|
+
// Count every request attempt. A transport/provider failure still consumed a
|
|
365
|
+
// scheduled ping slot and must remain visible in the footer.
|
|
366
|
+
s.pingsSent += 1
|
|
367
|
+
this.store.persist()
|
|
368
|
+
|
|
235
369
|
const res = await this.client.session.prompt({
|
|
236
370
|
path: { id: sessionID },
|
|
237
|
-
body: {
|
|
371
|
+
body: {
|
|
372
|
+
parts: [{ type: "text", text: this.config.pingToken }],
|
|
373
|
+
...(request.agent ? { agent: request.agent } : {}),
|
|
374
|
+
model: { providerID: request.providerID, modelID: request.modelID },
|
|
375
|
+
...(request.variant ? { variant: request.variant } : {}),
|
|
376
|
+
},
|
|
238
377
|
})
|
|
239
|
-
|
|
240
378
|
const info = res?.data?.info ?? res?.info
|
|
379
|
+
|
|
380
|
+
if (ping.interrupted) {
|
|
381
|
+
// A real turn ran inside this request: its reply is real work. Keep it,
|
|
382
|
+
// do not score it as a ping, and re-arm from its completion.
|
|
383
|
+
const completed = info?.time?.completed
|
|
384
|
+
if (typeof completed === "number") s.lastResponseAt = completed
|
|
385
|
+
this.logger.dbg(
|
|
386
|
+
`ping ${short(sessionID)} absorbed a real turn — kept, not reverted`,
|
|
387
|
+
)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
241
391
|
const cache = info?.tokens?.cache ?? {}
|
|
242
392
|
const record = {
|
|
243
393
|
at: Date.now(),
|
|
@@ -253,51 +403,112 @@ export class KeepaliveEngine {
|
|
|
253
403
|
`in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
|
|
254
404
|
)
|
|
255
405
|
|
|
256
|
-
if (this.config.revertPing) await this.revertPing(sessionID,
|
|
406
|
+
if (this.config.revertPing) await this.revertPing(sessionID, ping)
|
|
257
407
|
} catch (err) {
|
|
258
408
|
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
|
|
259
409
|
} finally {
|
|
260
410
|
s.warming = false
|
|
261
|
-
s.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
411
|
+
s.ping = undefined
|
|
412
|
+
const now = Date.now()
|
|
413
|
+
if (ping.sent) {
|
|
414
|
+
s.lastPingAt = now
|
|
415
|
+
// Schedule relative to the intended time so ping duration doesn't
|
|
416
|
+
// compound drift, but never into the past: that would fire catch-up
|
|
417
|
+
// pings on consecutive ticks.
|
|
418
|
+
const next = s.nextPingAt + jitter(this.config.intervalMs)
|
|
419
|
+
s.nextPingAt = next > now ? next : now + jitter(this.config.intervalMs)
|
|
420
|
+
}
|
|
269
421
|
this.store.persist()
|
|
422
|
+
if (ping.sent && ping.interrupted) {
|
|
423
|
+
// The joined real turn has finished with this request; start its window.
|
|
424
|
+
s.busy = false
|
|
425
|
+
s.active = false
|
|
426
|
+
void this.armWindow(sessionID)
|
|
427
|
+
}
|
|
270
428
|
}
|
|
271
429
|
}
|
|
272
430
|
|
|
273
|
-
/**
|
|
274
|
-
private async
|
|
275
|
-
let messages: any[]
|
|
431
|
+
/** True only when the server positively reports the session idle. */
|
|
432
|
+
private async isIdleOnServer(sessionID: string): Promise<boolean> {
|
|
276
433
|
try {
|
|
277
|
-
const res = await this.client.session.
|
|
278
|
-
|
|
434
|
+
const res = await this.client.session.status()
|
|
435
|
+
const statuses = res?.data ?? res ?? {}
|
|
436
|
+
return (statuses?.[sessionID]?.type ?? "idle") === "idle"
|
|
279
437
|
} catch (err) {
|
|
280
|
-
this.logger.
|
|
281
|
-
return
|
|
438
|
+
this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err))
|
|
439
|
+
return false
|
|
282
440
|
}
|
|
441
|
+
}
|
|
283
442
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
443
|
+
/** Recover the last real turn's request settings when no user event was observed. */
|
|
444
|
+
private async lookupRequest(s: SessionKeepalive): Promise<PingRequest | undefined> {
|
|
445
|
+
const messages = await this.recentMessages(s.sessionID)
|
|
446
|
+
if (!messages) return undefined
|
|
447
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
448
|
+
const m = messages[i]
|
|
449
|
+
if (m?.info?.role !== "user" || this.isPingMessage(m)) continue
|
|
450
|
+
const request = requestFrom(m.info)
|
|
451
|
+
if (request) {
|
|
452
|
+
s.request = request
|
|
453
|
+
return request
|
|
454
|
+
}
|
|
294
455
|
}
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
295
458
|
|
|
296
|
-
|
|
459
|
+
private async recentMessages(sessionID: string): Promise<any[] | undefined> {
|
|
460
|
+
try {
|
|
461
|
+
const res = await this.client.session.messages({
|
|
462
|
+
path: { id: sessionID },
|
|
463
|
+
query: { limit: MESSAGE_LOOKUP_LIMIT },
|
|
464
|
+
})
|
|
465
|
+
const messages = res?.data ?? res
|
|
466
|
+
return Array.isArray(messages) ? messages : undefined
|
|
467
|
+
} catch (err) {
|
|
468
|
+
this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err))
|
|
469
|
+
return undefined
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private isPingMessage(m: any): boolean {
|
|
474
|
+
const text = (m?.parts ?? [])
|
|
475
|
+
.filter((p: any) => p?.type === "text")
|
|
476
|
+
.map((p: any) => p?.text ?? "")
|
|
477
|
+
.join("")
|
|
478
|
+
.trim()
|
|
479
|
+
return text === this.config.pingToken
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Remove the `~` user + `~` assistant turn so it never enters real context.
|
|
484
|
+
* Revert drops the target and everything after it (and rolls back file
|
|
485
|
+
* snapshots), so it is skipped unless only the ping's own replies follow.
|
|
486
|
+
*/
|
|
487
|
+
private async revertPing(sessionID: string, ping: InflightPing): Promise<void> {
|
|
488
|
+
const messages = await this.recentMessages(sessionID)
|
|
489
|
+
if (!messages) return
|
|
490
|
+
|
|
491
|
+
let index = -1
|
|
492
|
+
for (let i = 0; i < messages.length; i++) {
|
|
493
|
+
const m = messages[i]
|
|
494
|
+
if (m?.info?.role !== "user") continue
|
|
495
|
+
if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
|
|
496
|
+
index = i
|
|
497
|
+
}
|
|
498
|
+
if (index < 0) {
|
|
297
499
|
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
|
|
298
500
|
return
|
|
299
501
|
}
|
|
300
502
|
|
|
503
|
+
const target: string = messages[index].info.id
|
|
504
|
+
const foreign = messages
|
|
505
|
+
.slice(index + 1)
|
|
506
|
+
.some((m: any) => m?.info?.role !== "assistant" || m?.info?.parentID !== target)
|
|
507
|
+
if (foreign) {
|
|
508
|
+
this.logger.warn(`ping ${short(sessionID)} not reverted — other messages followed it`)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
|
|
301
512
|
try {
|
|
302
513
|
await this.client.session.revert({
|
|
303
514
|
path: { id: sessionID },
|
|
@@ -309,6 +520,12 @@ export class KeepaliveEngine {
|
|
|
309
520
|
}
|
|
310
521
|
}
|
|
311
522
|
|
|
523
|
+
private isRecentPing(m: any, ping: InflightPing): boolean {
|
|
524
|
+
return (
|
|
525
|
+
Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2_000 && this.isPingMessage(m)
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
312
529
|
private async resolveSession(s: SessionKeepalive): Promise<void> {
|
|
313
530
|
try {
|
|
314
531
|
const res = await this.client.session.get({ path: { id: s.sessionID } })
|
|
@@ -340,16 +557,11 @@ export class KeepaliveEngine {
|
|
|
340
557
|
session.active = false
|
|
341
558
|
continue
|
|
342
559
|
}
|
|
343
|
-
// Only re-arm sessions
|
|
344
|
-
// are not currently occupied.
|
|
560
|
+
// Only re-arm sessions with a real response whose cache may still be warm,
|
|
561
|
+
// and that are not currently occupied.
|
|
345
562
|
if (!session.eligible || session.busy || session.warming) continue
|
|
346
|
-
if (!session.lastResponseAt) continue
|
|
347
|
-
session
|
|
348
|
-
session.windowEndsAt = now + this.config.windowMs
|
|
349
|
-
session.nextPingAt = now + this.config.intervalMs
|
|
350
|
-
session.pingsSent = 0
|
|
351
|
-
session.lastPing = undefined
|
|
352
|
-
session.active = true
|
|
563
|
+
if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
|
|
564
|
+
this.openWindow(session, now)
|
|
353
565
|
}
|
|
354
566
|
this.store.persist()
|
|
355
567
|
this.logger.info(`runtime ${control.enabled ? "enabled" : "disabled"}`)
|
|
@@ -377,6 +589,40 @@ export class KeepaliveEngine {
|
|
|
377
589
|
}
|
|
378
590
|
}
|
|
379
591
|
|
|
592
|
+
/** Last time the session's prompt cache was known to be used (real response or ping). */
|
|
593
|
+
function lastCacheTouch(s: SessionKeepalive): number {
|
|
594
|
+
return Math.max(s.lastResponseAt, s.lastPingAt ?? 0)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function isNewUserMessage(s: SessionKeepalive, info: any): boolean {
|
|
598
|
+
if (typeof info?.id === "string" && info.id === s.lastUserID) return false
|
|
599
|
+
const created = info?.time?.created
|
|
600
|
+
return typeof created !== "number" || created >= (s.lastUserAt ?? 0)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function noteUserMessage(s: SessionKeepalive, info: any): void {
|
|
604
|
+
if (typeof info?.id === "string") s.lastUserID = info.id
|
|
605
|
+
const created = info?.time?.created
|
|
606
|
+
if (typeof created === "number") s.lastUserAt = created
|
|
607
|
+
const request = requestFrom(info)
|
|
608
|
+
if (request) s.request = request
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Extract replayable request settings from a user message's info. */
|
|
612
|
+
function requestFrom(info: any): PingRequest | undefined {
|
|
613
|
+
const model = info?.model
|
|
614
|
+
const providerID = model?.providerID
|
|
615
|
+
const modelID = model?.modelID ?? model?.id
|
|
616
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
617
|
+
const variant = model?.variant ?? info?.variant
|
|
618
|
+
return {
|
|
619
|
+
agent: typeof info?.agent === "string" ? info.agent : undefined,
|
|
620
|
+
providerID,
|
|
621
|
+
modelID,
|
|
622
|
+
variant: typeof variant === "string" && variant !== "default" ? variant : undefined,
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
380
626
|
function short(sessionID: string): string {
|
|
381
627
|
return sessionID.slice(-6)
|
|
382
628
|
}
|
package/lib/state.ts
CHANGED
|
@@ -12,6 +12,40 @@ export type PingRecord = {
|
|
|
12
12
|
output: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Request settings of the last real user turn. A ping must replay them exactly:
|
|
17
|
+
* a prompt without explicit settings runs as opencode's default agent with no
|
|
18
|
+
* variant, which changes the request shape (missing the cache) and overwrites
|
|
19
|
+
* the session's persisted agent/model/variant.
|
|
20
|
+
*/
|
|
21
|
+
export type PingRequest = {
|
|
22
|
+
agent?: string
|
|
23
|
+
providerID: string
|
|
24
|
+
modelID: string
|
|
25
|
+
variant?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bookkeeping for the ping request currently in flight. */
|
|
29
|
+
export type InflightPing = {
|
|
30
|
+
/** Epoch ms the ping attempt began (before the pre-flight status check). */
|
|
31
|
+
startedAt: number
|
|
32
|
+
/** The prompt request has been issued. */
|
|
33
|
+
sent: boolean
|
|
34
|
+
/** The ping's own user message, identified by its ping-token text part. */
|
|
35
|
+
messageID?: string
|
|
36
|
+
/**
|
|
37
|
+
* New user messages seen during the ping whose text has not yet been observed.
|
|
38
|
+
* Each is classified by its first text part: the ping token marks our own
|
|
39
|
+
* message, anything else a real turn.
|
|
40
|
+
*/
|
|
41
|
+
pending: Map<string, unknown>
|
|
42
|
+
/**
|
|
43
|
+
* A real user message appeared while the ping was in flight. opencode joins such
|
|
44
|
+
* a prompt to the running ping loop, so the rest of the run is real work.
|
|
45
|
+
*/
|
|
46
|
+
interrupted: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
/** Live per-session bookkeeping held by the server plugin. */
|
|
16
50
|
export type SessionKeepalive = {
|
|
17
51
|
sessionID: string
|
|
@@ -30,15 +64,20 @@ export type SessionKeepalive = {
|
|
|
30
64
|
pingsSent: number
|
|
31
65
|
/** A real turn is currently running. */
|
|
32
66
|
busy: boolean
|
|
33
|
-
/** Epoch ms when `busy` was last set; used as a stuck-busy timeout backstop. */
|
|
34
|
-
busySince?: number
|
|
35
67
|
/** A keepalive ping is currently in flight. */
|
|
36
68
|
warming: boolean
|
|
69
|
+
/** The in-flight ping; set exactly while `warming` is true. */
|
|
70
|
+
ping?: InflightPing
|
|
71
|
+
/** ID and creation time of the newest user message seen, to tell new turns from re-emitted old messages. */
|
|
72
|
+
lastUserID?: string
|
|
73
|
+
lastUserAt?: number
|
|
74
|
+
/** Settings of the last real user turn, replayed by pings. */
|
|
75
|
+
request?: PingRequest
|
|
37
76
|
/** Within the warm window and actively scheduling pings. */
|
|
38
77
|
active: boolean
|
|
39
78
|
/** True while `armWindow` is resolving session metadata (async guard). */
|
|
40
79
|
arming?: boolean
|
|
41
|
-
/** Epoch ms
|
|
80
|
+
/** Epoch ms the last sent ping finished; ignores its late events and marks the last cache touch. */
|
|
42
81
|
lastPingAt?: number
|
|
43
82
|
lastPing?: PingRecord
|
|
44
83
|
}
|
|
@@ -115,8 +154,11 @@ export class KeepaliveStore {
|
|
|
115
154
|
return [...this.sessions.values()]
|
|
116
155
|
}
|
|
117
156
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Atomically persist a lean snapshot for the TUI. Never throws or rejects; the
|
|
159
|
+
* returned promise resolves once this snapshot and every earlier one are written.
|
|
160
|
+
*/
|
|
161
|
+
persist(): Promise<void> {
|
|
120
162
|
const snapshot: PersistedState = {
|
|
121
163
|
version: 1,
|
|
122
164
|
updatedAt: Date.now(),
|
|
@@ -146,6 +188,7 @@ export class KeepaliveStore {
|
|
|
146
188
|
|
|
147
189
|
const write = () => atomicWrite(stateFilePath(this.directory), JSON.stringify(snapshot))
|
|
148
190
|
this.writeQueue = this.writeQueue.then(write, write)
|
|
191
|
+
return this.writeQueue
|
|
149
192
|
}
|
|
150
193
|
}
|
|
151
194
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@vikrant82/opencode-cache-keepalive",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenCode plugin that keeps provider prompt caches warm by sending invisible keepalive pings, with a live TUI readout",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"clean": "rm -rf dist",
|
|
25
25
|
"build": "npm run clean && tsup && tsc --emitDeclarationOnly",
|
|
26
26
|
"dev": "opencode plugin dev",
|
|
27
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
27
28
|
"typecheck": "tsc --noEmit",
|
|
28
29
|
"format": "prettier --write .",
|
|
29
30
|
"format:check": "prettier --check ."
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"prettier": "^3.4.0",
|
|
60
61
|
"solid-js": "^1.9.12",
|
|
61
62
|
"tsup": "^8.5.1",
|
|
63
|
+
"tsx": "^4.23.15",
|
|
62
64
|
"typescript": "^5.7.0"
|
|
63
65
|
},
|
|
64
66
|
"files": [
|