@vikrant82/opencode-cache-keepalive 0.1.4 → 0.1.6
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/README.md +4 -1
- package/dist/index.js +313 -75
- package/dist/index.js.map +1 -1
- package/dist/lib/control.d.ts +34 -1
- package/dist/lib/control.d.ts.map +1 -1
- package/dist/lib/keepalive.d.ts +53 -8
- package/dist/lib/keepalive.d.ts.map +1 -1
- package/dist/lib/state.d.ts +48 -5
- package/dist/lib/state.d.ts.map +1 -1
- package/dist/lib/tui/commands.d.ts +7 -0
- package/dist/lib/tui/commands.d.ts.map +1 -1
- package/dist/lib/tui/footer.d.ts.map +1 -1
- package/dist/lib/tui/format.d.ts +8 -0
- package/dist/lib/tui/format.d.ts.map +1 -1
- package/lib/control.ts +96 -11
- package/lib/keepalive.ts +364 -89
- package/lib/state.ts +57 -7
- package/lib/tui/commands.tsx +184 -0
- package/lib/tui/footer.tsx +33 -6
- package/lib/tui/format.ts +36 -0
- package/package.json +3 -1
- package/lib/tui/commands.ts +0 -59
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
|
|
@@ -30,6 +43,7 @@ export class KeepaliveEngine {
|
|
|
30
43
|
private controlUpdatedAt = 0
|
|
31
44
|
private reconcilingStatus = false
|
|
32
45
|
private enabled: boolean
|
|
46
|
+
private intervalOverride: number | undefined
|
|
33
47
|
|
|
34
48
|
constructor(
|
|
35
49
|
private readonly client: any,
|
|
@@ -40,8 +54,15 @@ export class KeepaliveEngine {
|
|
|
40
54
|
) {
|
|
41
55
|
const control = readControl(directory)
|
|
42
56
|
this.enabled = control?.enabled ?? config.enabled
|
|
57
|
+
this.intervalOverride = control?.intervalMs
|
|
43
58
|
this.controlUpdatedAt = control?.updatedAt ?? 0
|
|
44
59
|
this.store.setEnabled(this.enabled)
|
|
60
|
+
this.store.setIntervalMs(this.intervalMs)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Effective ping interval: the runtime override from the control file, else config. */
|
|
64
|
+
private get intervalMs(): number {
|
|
65
|
+
return this.intervalOverride ?? this.config.intervalMs
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
start(): void {
|
|
@@ -52,7 +73,7 @@ export class KeepaliveEngine {
|
|
|
52
73
|
this.store.persist()
|
|
53
74
|
this.logger.info(
|
|
54
75
|
`started (${this.enabled ? "enabled" : "disabled"}, ` +
|
|
55
|
-
`interval=${Math.round(this.
|
|
76
|
+
`interval=${Math.round(this.intervalMs / 1000)}s, ` +
|
|
56
77
|
`window=${Math.round(this.config.windowMs / 60_000)}m)`,
|
|
57
78
|
)
|
|
58
79
|
}
|
|
@@ -67,9 +88,14 @@ export class KeepaliveEngine {
|
|
|
67
88
|
this.statusTimer = undefined
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
/**
|
|
92
|
+
* True while a ping turn owns the session, so tool calls must be blocked. Becomes
|
|
93
|
+
* false as soon as a real user message joins the in-flight ping run: the rest of
|
|
94
|
+
* that run is real work and needs its tools.
|
|
95
|
+
*/
|
|
96
|
+
shouldBlockTools(sessionID: string): boolean {
|
|
97
|
+
const s = this.store.get(sessionID)
|
|
98
|
+
return !!s?.warming && !s.ping?.interrupted
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
async onEvent(event: any): Promise<void> {
|
|
@@ -85,13 +111,26 @@ export class KeepaliveEngine {
|
|
|
85
111
|
return
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
if (type === "message.part.updated") {
|
|
115
|
+
const part = event.properties?.part
|
|
116
|
+
const sessionID: string | undefined = part?.sessionID ?? event.properties?.sessionID
|
|
117
|
+
if (!sessionID) return
|
|
118
|
+
const session = this.store.get(sessionID)
|
|
119
|
+
if (session?.warming) this.notePingRunPart(session, part)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
88
123
|
if (type === "message.updated") {
|
|
89
124
|
const info = event.properties?.info
|
|
90
125
|
const sessionID: string | undefined = info?.sessionID ?? event.properties?.sessionID
|
|
91
126
|
if (!sessionID) return
|
|
92
127
|
const session = this.store.get(sessionID)
|
|
93
|
-
//
|
|
94
|
-
|
|
128
|
+
// Everything inside our own ping turn is ignored — except a real user
|
|
129
|
+
// message, which opencode joins to the running ping loop.
|
|
130
|
+
if (session?.warming) {
|
|
131
|
+
if (info?.role === "user") this.notePingRunUser(session, info)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
95
134
|
const eventAt = info?.time?.completed ?? info?.time?.created
|
|
96
135
|
// Events are asynchronous: a completed ping may still emit message updates
|
|
97
136
|
// after `warming` clears. Their timestamps predate the recorded ping.
|
|
@@ -100,8 +139,11 @@ export class KeepaliveEngine {
|
|
|
100
139
|
|
|
101
140
|
if (info?.role === "user") {
|
|
102
141
|
const s = this.store.ensure(sessionID)
|
|
142
|
+
// opencode re-emits older user messages (e.g. summary updates after a
|
|
143
|
+
// turn); only a newly created message starts a turn.
|
|
144
|
+
if (!isNewUserMessage(s, info)) return
|
|
145
|
+
noteUserMessage(s, info)
|
|
103
146
|
s.busy = true
|
|
104
|
-
s.busySince = Date.now()
|
|
105
147
|
s.active = false
|
|
106
148
|
this.store.persist()
|
|
107
149
|
return
|
|
@@ -139,7 +181,6 @@ export class KeepaliveEngine {
|
|
|
139
181
|
|
|
140
182
|
const s = this.store.ensure(sessionID)
|
|
141
183
|
s.busy = true
|
|
142
|
-
s.busySince = Date.now()
|
|
143
184
|
s.active = false
|
|
144
185
|
this.store.persist()
|
|
145
186
|
return
|
|
@@ -154,6 +195,58 @@ export class KeepaliveEngine {
|
|
|
154
195
|
}
|
|
155
196
|
}
|
|
156
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Track a new user message observed while a ping is in flight. Event order alone
|
|
200
|
+
* cannot tell the ping's own message from a real prompt racing it, so messages are
|
|
201
|
+
* held until their text part arrives (see `notePingRunPart`). Any message seen
|
|
202
|
+
* before the ping is sent, or a second new message, is necessarily a real turn.
|
|
203
|
+
*/
|
|
204
|
+
private notePingRunUser(s: SessionKeepalive, info: any): void {
|
|
205
|
+
const ping = s.ping
|
|
206
|
+
if (!ping || typeof info?.id !== "string") return
|
|
207
|
+
const created = info?.time?.created
|
|
208
|
+
if (typeof created === "number" && created < ping.startedAt) return // re-emitted old message
|
|
209
|
+
if (info.id === ping.messageID || ping.pending.has(info.id)) return
|
|
210
|
+
if (!ping.sent || ping.messageID) {
|
|
211
|
+
this.interruptPing(s, info)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
ping.pending.set(info.id, info)
|
|
215
|
+
if (ping.pending.size > 1) this.interruptPing(s)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Classify a pending user message by its first text part: the ping token marks
|
|
220
|
+
* the ping's own message; any other text is a real turn joining the ping run.
|
|
221
|
+
* opencode publishes a user message's parts right after the message itself, before
|
|
222
|
+
* the run reaches any tool call.
|
|
223
|
+
*/
|
|
224
|
+
private notePingRunPart(s: SessionKeepalive, part: any): void {
|
|
225
|
+
const ping = s.ping
|
|
226
|
+
if (!ping || part?.type !== "text" || part?.synthetic) return
|
|
227
|
+
const id = part?.messageID
|
|
228
|
+
if (typeof id !== "string" || !ping.pending.has(id)) return
|
|
229
|
+
const info = ping.pending.get(id)
|
|
230
|
+
ping.pending.delete(id)
|
|
231
|
+
if (!ping.messageID && String(part.text ?? "").trim() === this.config.pingToken) {
|
|
232
|
+
ping.messageID = id
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
this.interruptPing(s, info)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A real turn joined the in-flight ping: unblock tools and treat the session as busy. */
|
|
239
|
+
private interruptPing(s: SessionKeepalive, info?: unknown): void {
|
|
240
|
+
const ping = s.ping
|
|
241
|
+
if (!ping) return
|
|
242
|
+
if (!ping.interrupted) this.logger.dbg(`real turn joined ping ${short(s.sessionID)}`)
|
|
243
|
+
ping.interrupted = true
|
|
244
|
+
if (info) noteUserMessage(s, info)
|
|
245
|
+
s.busy = true
|
|
246
|
+
s.active = false
|
|
247
|
+
this.store.persist()
|
|
248
|
+
}
|
|
249
|
+
|
|
157
250
|
private async armWindow(sessionID: string): Promise<void> {
|
|
158
251
|
const s = this.store.ensure(sessionID)
|
|
159
252
|
// Duplicate idle events (including late events from a ping) must not reset the
|
|
@@ -166,7 +259,6 @@ export class KeepaliveEngine {
|
|
|
166
259
|
s.arming = true
|
|
167
260
|
try {
|
|
168
261
|
s.busy = false
|
|
169
|
-
s.busySince = undefined
|
|
170
262
|
await this.resolveSession(s)
|
|
171
263
|
|
|
172
264
|
// A new turn may have started while session metadata was being resolved.
|
|
@@ -176,14 +268,17 @@ export class KeepaliveEngine {
|
|
|
176
268
|
return
|
|
177
269
|
}
|
|
178
270
|
|
|
271
|
+
// Only a recent real response leaves a cache worth warming. Idle events can
|
|
272
|
+
// also fire for sessions untouched for hours (restarts, re-emitted messages).
|
|
179
273
|
const now = Date.now()
|
|
180
|
-
if (!s.lastResponseAt
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
274
|
+
if (!s.lastResponseAt || !this.cacheMayBeWarm(s, now)) {
|
|
275
|
+
s.active = false
|
|
276
|
+
this.store.persist()
|
|
277
|
+
this.logger.dbg(`not armed ${short(sessionID)} — no recent real response`)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.openWindow(s, now)
|
|
187
282
|
this.store.persist()
|
|
188
283
|
this.logger.dbg(`armed ${short(sessionID)} model=${s.modelLabel}`)
|
|
189
284
|
} finally {
|
|
@@ -191,20 +286,26 @@ export class KeepaliveEngine {
|
|
|
191
286
|
}
|
|
192
287
|
}
|
|
193
288
|
|
|
289
|
+
/** Start a warm window anchored on the last real response and the last cache touch. */
|
|
290
|
+
private openWindow(s: SessionKeepalive, now: number): void {
|
|
291
|
+
s.idleSince = now
|
|
292
|
+
s.windowEndsAt = s.lastResponseAt + this.config.windowMs
|
|
293
|
+
s.nextPingAt = Math.max(now, lastCacheTouch(s) + this.intervalMs)
|
|
294
|
+
s.pingsSent = 0
|
|
295
|
+
s.lastPing = undefined
|
|
296
|
+
s.active = true
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** False once the cache has gone too long without a request to still be trusted warm. */
|
|
300
|
+
private cacheMayBeWarm(s: SessionKeepalive, now: number): boolean {
|
|
301
|
+
const touch = lastCacheTouch(s)
|
|
302
|
+
return touch > 0 && now - touch <= COLD_AFTER_INTERVALS * this.intervalMs
|
|
303
|
+
}
|
|
304
|
+
|
|
194
305
|
private async tick(): Promise<void> {
|
|
195
306
|
if (!this.enabled) return
|
|
196
307
|
const now = Date.now()
|
|
197
308
|
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
309
|
if (!s.eligible || !s.active || s.busy || s.warming) continue
|
|
209
310
|
|
|
210
311
|
if (now >= s.windowEndsAt) {
|
|
@@ -216,28 +317,85 @@ export class KeepaliveEngine {
|
|
|
216
317
|
continue
|
|
217
318
|
}
|
|
218
319
|
|
|
219
|
-
if (now
|
|
320
|
+
if (now < s.nextPingAt) continue
|
|
321
|
+
|
|
322
|
+
// Far overdue (host sleep, stalled event loop): the cache is presumed cold.
|
|
323
|
+
// Stop rather than pay a cold write or fire a catch-up burst.
|
|
324
|
+
if (!this.cacheMayBeWarm(s, now)) {
|
|
325
|
+
s.active = false
|
|
326
|
+
this.store.persist()
|
|
327
|
+
this.logger.dbg(
|
|
328
|
+
`window closed ${short(s.sessionID)} — ping overdue, cache presumed cold`,
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
void this.firePing(s.sessionID)
|
|
220
334
|
}
|
|
221
335
|
}
|
|
222
336
|
|
|
223
337
|
private async firePing(sessionID: string): Promise<void> {
|
|
224
338
|
const s = this.store.get(sessionID)
|
|
225
|
-
if (!s || s.warming || s.busy) return
|
|
339
|
+
if (!s || s.warming || s.busy || !s.active) return
|
|
226
340
|
|
|
341
|
+
const ping: InflightPing = {
|
|
342
|
+
startedAt: Date.now(),
|
|
343
|
+
sent: false,
|
|
344
|
+
pending: new Map(),
|
|
345
|
+
interrupted: false,
|
|
346
|
+
}
|
|
227
347
|
s.warming = true
|
|
228
|
-
|
|
229
|
-
// scheduled ping slot and must remain visible in the footer.
|
|
230
|
-
s.pingsSent += 1
|
|
348
|
+
s.ping = ping
|
|
231
349
|
this.store.persist()
|
|
232
|
-
const startAt = Date.now()
|
|
233
350
|
|
|
234
351
|
try {
|
|
352
|
+
// opencode joins a prompt to a running turn instead of rejecting it, so a
|
|
353
|
+
// missed busy signal would inject the ping into real work. Ask the server.
|
|
354
|
+
if (!(await this.isIdleOnServer(sessionID))) {
|
|
355
|
+
s.busy = true
|
|
356
|
+
s.active = false
|
|
357
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped — session busy`)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const request = s.request ?? (await this.lookupRequest(s))
|
|
362
|
+
if (!request) {
|
|
363
|
+
s.active = false
|
|
364
|
+
this.logger.dbg(`ping ${short(sessionID)} skipped — last real request unknown`)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A real turn may have started (or keepalive been disabled) while checking.
|
|
369
|
+
if (ping.interrupted || s.busy || !s.active || !this.enabled) return
|
|
370
|
+
|
|
371
|
+
ping.sent = true
|
|
372
|
+
// Count every request attempt. A transport/provider failure still consumed a
|
|
373
|
+
// scheduled ping slot and must remain visible in the footer.
|
|
374
|
+
s.pingsSent += 1
|
|
375
|
+
this.store.persist()
|
|
376
|
+
|
|
235
377
|
const res = await this.client.session.prompt({
|
|
236
378
|
path: { id: sessionID },
|
|
237
|
-
body: {
|
|
379
|
+
body: {
|
|
380
|
+
parts: [{ type: "text", text: this.config.pingToken }],
|
|
381
|
+
...(request.agent ? { agent: request.agent } : {}),
|
|
382
|
+
model: { providerID: request.providerID, modelID: request.modelID },
|
|
383
|
+
...(request.variant ? { variant: request.variant } : {}),
|
|
384
|
+
},
|
|
238
385
|
})
|
|
239
|
-
|
|
240
386
|
const info = res?.data?.info ?? res?.info
|
|
387
|
+
|
|
388
|
+
if (ping.interrupted) {
|
|
389
|
+
// A real turn ran inside this request: its reply is real work. Keep it,
|
|
390
|
+
// do not score it as a ping, and re-arm from its completion.
|
|
391
|
+
const completed = info?.time?.completed
|
|
392
|
+
if (typeof completed === "number") s.lastResponseAt = completed
|
|
393
|
+
this.logger.dbg(
|
|
394
|
+
`ping ${short(sessionID)} absorbed a real turn — kept, not reverted`,
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
241
399
|
const cache = info?.tokens?.cache ?? {}
|
|
242
400
|
const record = {
|
|
243
401
|
at: Date.now(),
|
|
@@ -253,51 +411,112 @@ export class KeepaliveEngine {
|
|
|
253
411
|
`in=${record.input} read=${record.cacheRead} write=${record.cacheWrite} out=${record.output}`,
|
|
254
412
|
)
|
|
255
413
|
|
|
256
|
-
if (this.config.revertPing) await this.revertPing(sessionID,
|
|
414
|
+
if (this.config.revertPing) await this.revertPing(sessionID, ping)
|
|
257
415
|
} catch (err) {
|
|
258
416
|
this.logger.warn(`ping ${short(sessionID)} failed`, errText(err))
|
|
259
417
|
} finally {
|
|
260
418
|
s.warming = false
|
|
261
|
-
s.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
419
|
+
s.ping = undefined
|
|
420
|
+
const now = Date.now()
|
|
421
|
+
if (ping.sent) {
|
|
422
|
+
s.lastPingAt = now
|
|
423
|
+
// Schedule relative to the intended time so ping duration doesn't
|
|
424
|
+
// compound drift, but never into the past: that would fire catch-up
|
|
425
|
+
// pings on consecutive ticks.
|
|
426
|
+
const next = s.nextPingAt + jitter(this.intervalMs)
|
|
427
|
+
s.nextPingAt = next > now ? next : now + jitter(this.intervalMs)
|
|
428
|
+
}
|
|
269
429
|
this.store.persist()
|
|
430
|
+
if (ping.sent && ping.interrupted) {
|
|
431
|
+
// The joined real turn has finished with this request; start its window.
|
|
432
|
+
s.busy = false
|
|
433
|
+
s.active = false
|
|
434
|
+
void this.armWindow(sessionID)
|
|
435
|
+
}
|
|
270
436
|
}
|
|
271
437
|
}
|
|
272
438
|
|
|
273
|
-
/**
|
|
274
|
-
private async
|
|
275
|
-
let messages: any[]
|
|
439
|
+
/** True only when the server positively reports the session idle. */
|
|
440
|
+
private async isIdleOnServer(sessionID: string): Promise<boolean> {
|
|
276
441
|
try {
|
|
277
|
-
const res = await this.client.session.
|
|
278
|
-
|
|
442
|
+
const res = await this.client.session.status()
|
|
443
|
+
const statuses = res?.data ?? res ?? {}
|
|
444
|
+
return (statuses?.[sessionID]?.type ?? "idle") === "idle"
|
|
279
445
|
} catch (err) {
|
|
280
|
-
this.logger.
|
|
281
|
-
return
|
|
446
|
+
this.logger.dbg(`status check ${short(sessionID)} failed`, errText(err))
|
|
447
|
+
return false
|
|
282
448
|
}
|
|
449
|
+
}
|
|
283
450
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
451
|
+
/** Recover the last real turn's request settings when no user event was observed. */
|
|
452
|
+
private async lookupRequest(s: SessionKeepalive): Promise<PingRequest | undefined> {
|
|
453
|
+
const messages = await this.recentMessages(s.sessionID)
|
|
454
|
+
if (!messages) return undefined
|
|
455
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
456
|
+
const m = messages[i]
|
|
457
|
+
if (m?.info?.role !== "user" || this.isPingMessage(m)) continue
|
|
458
|
+
const request = requestFrom(m.info)
|
|
459
|
+
if (request) {
|
|
460
|
+
s.request = request
|
|
461
|
+
return request
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return undefined
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async recentMessages(sessionID: string): Promise<any[] | undefined> {
|
|
468
|
+
try {
|
|
469
|
+
const res = await this.client.session.messages({
|
|
470
|
+
path: { id: sessionID },
|
|
471
|
+
query: { limit: MESSAGE_LOOKUP_LIMIT },
|
|
472
|
+
})
|
|
473
|
+
const messages = res?.data ?? res
|
|
474
|
+
return Array.isArray(messages) ? messages : undefined
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.logger.warn(`messages ${short(sessionID)} list failed`, errText(err))
|
|
477
|
+
return undefined
|
|
294
478
|
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private isPingMessage(m: any): boolean {
|
|
482
|
+
const text = (m?.parts ?? [])
|
|
483
|
+
.filter((p: any) => p?.type === "text")
|
|
484
|
+
.map((p: any) => p?.text ?? "")
|
|
485
|
+
.join("")
|
|
486
|
+
.trim()
|
|
487
|
+
return text === this.config.pingToken
|
|
488
|
+
}
|
|
295
489
|
|
|
296
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Remove the `~` user + `~` assistant turn so it never enters real context.
|
|
492
|
+
* Revert drops the target and everything after it (and rolls back file
|
|
493
|
+
* snapshots), so it is skipped unless only the ping's own replies follow.
|
|
494
|
+
*/
|
|
495
|
+
private async revertPing(sessionID: string, ping: InflightPing): Promise<void> {
|
|
496
|
+
const messages = await this.recentMessages(sessionID)
|
|
497
|
+
if (!messages) return
|
|
498
|
+
|
|
499
|
+
let index = -1
|
|
500
|
+
for (let i = 0; i < messages.length; i++) {
|
|
501
|
+
const m = messages[i]
|
|
502
|
+
if (m?.info?.role !== "user") continue
|
|
503
|
+
if (ping.messageID ? m.info.id === ping.messageID : this.isRecentPing(m, ping))
|
|
504
|
+
index = i
|
|
505
|
+
}
|
|
506
|
+
if (index < 0) {
|
|
297
507
|
this.logger.dbg(`ping ${short(sessionID)} revert: ping message not found`)
|
|
298
508
|
return
|
|
299
509
|
}
|
|
300
510
|
|
|
511
|
+
const target: string = messages[index].info.id
|
|
512
|
+
const foreign = messages
|
|
513
|
+
.slice(index + 1)
|
|
514
|
+
.some((m: any) => m?.info?.role !== "assistant" || m?.info?.parentID !== target)
|
|
515
|
+
if (foreign) {
|
|
516
|
+
this.logger.warn(`ping ${short(sessionID)} not reverted — other messages followed it`)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
301
520
|
try {
|
|
302
521
|
await this.client.session.revert({
|
|
303
522
|
path: { id: sessionID },
|
|
@@ -309,6 +528,12 @@ export class KeepaliveEngine {
|
|
|
309
528
|
}
|
|
310
529
|
}
|
|
311
530
|
|
|
531
|
+
private isRecentPing(m: any, ping: InflightPing): boolean {
|
|
532
|
+
return (
|
|
533
|
+
Number(m?.info?.time?.created ?? 0) >= ping.startedAt - 2_000 && this.isPingMessage(m)
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
312
537
|
private async resolveSession(s: SessionKeepalive): Promise<void> {
|
|
313
538
|
try {
|
|
314
539
|
const res = await this.client.session.get({ path: { id: s.sessionID } })
|
|
@@ -330,29 +555,45 @@ export class KeepaliveEngine {
|
|
|
330
555
|
const control = readControl(this.directory)
|
|
331
556
|
if (!control || control.updatedAt <= this.controlUpdatedAt) return
|
|
332
557
|
this.controlUpdatedAt = control.updatedAt
|
|
333
|
-
if (control.enabled === this.enabled) return
|
|
334
|
-
|
|
335
|
-
this.enabled = control.enabled
|
|
336
|
-
this.store.setEnabled(control.enabled)
|
|
337
558
|
const now = Date.now()
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
559
|
+
let changed = false
|
|
560
|
+
|
|
561
|
+
if (control.intervalMs !== this.intervalOverride) {
|
|
562
|
+
this.intervalOverride = control.intervalMs
|
|
563
|
+
this.store.setIntervalMs(this.intervalMs)
|
|
564
|
+
// Re-anchor pending pings on the new cadence. An in-flight ping schedules
|
|
565
|
+
// its successor with the new interval when it finishes.
|
|
566
|
+
for (const session of this.store.all()) {
|
|
567
|
+
if (session.active && !session.warming)
|
|
568
|
+
session.nextPingAt = Math.max(now, lastCacheTouch(session) + this.intervalMs)
|
|
569
|
+
}
|
|
570
|
+
this.logger.info(
|
|
571
|
+
`runtime interval ${Math.round(this.intervalMs / 1000)}s` +
|
|
572
|
+
(control.intervalMs === undefined ? " (config)" : ""),
|
|
573
|
+
)
|
|
574
|
+
changed = true
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const enabled = control.enabled ?? this.config.enabled
|
|
578
|
+
if (enabled !== this.enabled) {
|
|
579
|
+
this.enabled = enabled
|
|
580
|
+
this.store.setEnabled(enabled)
|
|
581
|
+
for (const session of this.store.all()) {
|
|
582
|
+
if (!enabled) {
|
|
583
|
+
session.active = false
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
586
|
+
// Only re-arm sessions with a real response whose cache may still be warm,
|
|
587
|
+
// and that are not currently occupied.
|
|
588
|
+
if (!session.eligible || session.busy || session.warming) continue
|
|
589
|
+
if (!session.lastResponseAt || !this.cacheMayBeWarm(session, now)) continue
|
|
590
|
+
this.openWindow(session, now)
|
|
342
591
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (!session.eligible || session.busy || session.warming) continue
|
|
346
|
-
if (!session.lastResponseAt) continue
|
|
347
|
-
session.idleSince = now
|
|
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
|
|
592
|
+
this.logger.info(`runtime ${enabled ? "enabled" : "disabled"}`)
|
|
593
|
+
changed = true
|
|
353
594
|
}
|
|
354
|
-
|
|
355
|
-
this.
|
|
595
|
+
|
|
596
|
+
if (changed) this.store.persist()
|
|
356
597
|
}
|
|
357
598
|
|
|
358
599
|
/** Recover when a session.status idle event is missed by reconciling with the API. */
|
|
@@ -377,6 +618,40 @@ export class KeepaliveEngine {
|
|
|
377
618
|
}
|
|
378
619
|
}
|
|
379
620
|
|
|
621
|
+
/** Last time the session's prompt cache was known to be used (real response or ping). */
|
|
622
|
+
function lastCacheTouch(s: SessionKeepalive): number {
|
|
623
|
+
return Math.max(s.lastResponseAt, s.lastPingAt ?? 0)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isNewUserMessage(s: SessionKeepalive, info: any): boolean {
|
|
627
|
+
if (typeof info?.id === "string" && info.id === s.lastUserID) return false
|
|
628
|
+
const created = info?.time?.created
|
|
629
|
+
return typeof created !== "number" || created >= (s.lastUserAt ?? 0)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function noteUserMessage(s: SessionKeepalive, info: any): void {
|
|
633
|
+
if (typeof info?.id === "string") s.lastUserID = info.id
|
|
634
|
+
const created = info?.time?.created
|
|
635
|
+
if (typeof created === "number") s.lastUserAt = created
|
|
636
|
+
const request = requestFrom(info)
|
|
637
|
+
if (request) s.request = request
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Extract replayable request settings from a user message's info. */
|
|
641
|
+
function requestFrom(info: any): PingRequest | undefined {
|
|
642
|
+
const model = info?.model
|
|
643
|
+
const providerID = model?.providerID
|
|
644
|
+
const modelID = model?.modelID ?? model?.id
|
|
645
|
+
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
646
|
+
const variant = model?.variant ?? info?.variant
|
|
647
|
+
return {
|
|
648
|
+
agent: typeof info?.agent === "string" ? info.agent : undefined,
|
|
649
|
+
providerID,
|
|
650
|
+
modelID,
|
|
651
|
+
variant: typeof variant === "string" && variant !== "default" ? variant : undefined,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
380
655
|
function short(sessionID: string): string {
|
|
381
656
|
return sessionID.slice(-6)
|
|
382
657
|
}
|