dsh-tdai-memory 0.3.4 → 0.3.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/LICENSE +21 -21
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/THIRD-PARTY-NOTICES.md +20 -20
- package/cordis.patch.yml +8 -8
- package/index.js +13 -13
- package/package.json +95 -94
- package/recall-inject.js +86 -86
- package/vendor/tdai/adapters/index.js +15 -15
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
- package/vendor/tdai/adapters/openclaw/index.js +5 -5
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
- package/vendor/tdai/adapters/standalone/index.js +5 -5
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
- package/vendor/tdai/core/hooks/auto-capture.js +236 -236
- package/vendor/tdai/core/hooks/auto-recall.js +640 -640
- package/vendor/tdai/core/index.js +8 -8
- package/vendor/tdai/core/persona/persona-generator.js +184 -184
- package/vendor/tdai/core/persona/persona-trigger.js +94 -94
- package/vendor/tdai/core/profile/profile-sync.js +200 -200
- package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
- package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
- package/vendor/tdai/core/prompts/persona-generation.js +35 -35
- package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
- package/vendor/tdai/core/record/l1-dedup.js +318 -318
- package/vendor/tdai/core/record/l1-extractor.js +379 -379
- package/vendor/tdai/core/record/l1-reader.js +178 -178
- package/vendor/tdai/core/record/l1-writer.js +141 -141
- package/vendor/tdai/core/report/reporter.js +86 -86
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
- package/vendor/tdai/core/scene/scene-extractor.js +410 -410
- package/vendor/tdai/core/scene/scene-format.js +53 -53
- package/vendor/tdai/core/scene/scene-index.js +82 -82
- package/vendor/tdai/core/scene/scene-navigation.js +57 -57
- package/vendor/tdai/core/seed/input.js +404 -404
- package/vendor/tdai/core/seed/types.js +9 -9
- package/vendor/tdai/core/store/bm25-client.js +117 -117
- package/vendor/tdai/core/store/bm25-local.js +68 -68
- package/vendor/tdai/core/store/embedding.js +632 -632
- package/vendor/tdai/core/store/factory.js +95 -95
- package/vendor/tdai/core/store/search-utils.js +54 -54
- package/vendor/tdai/core/store/sqlite.js +1632 -1632
- package/vendor/tdai/core/store/tcvdb-client.js +221 -221
- package/vendor/tdai/core/store/tcvdb.js +1047 -1047
- package/vendor/tdai/core/store/types.js +17 -17
- package/vendor/tdai/core/tools/conversation-search.js +199 -199
- package/vendor/tdai/core/tools/memory-search.js +207 -207
- package/vendor/tdai/core/types.js +12 -12
- package/vendor/tdai/utils/backup.js +189 -189
- package/vendor/tdai/utils/checkpoint.js +360 -360
- package/vendor/tdai/utils/clean-context-runner.js +472 -472
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
- package/vendor/tdai/utils/env.js +13 -13
- package/vendor/tdai/utils/managed-timer.js +127 -127
- package/vendor/tdai/utils/manifest.js +91 -91
- package/vendor/tdai/utils/memory-cleaner.js +300 -300
- package/vendor/tdai/utils/no-think-fetch.js +109 -109
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
- package/vendor/tdai/utils/pipeline-factory.js +517 -517
- package/vendor/tdai/utils/pipeline-manager.js +892 -892
- package/vendor/tdai/utils/sanitize.js +360 -360
- package/vendor/tdai/utils/serial-queue.js +103 -103
- package/vendor/tdai/utils/session-filter.js +88 -88
- package/vendor/tdai/utils/text-utils.js +29 -29
- package/vendor/tdai/utils/time.js +252 -252
|
@@ -1,892 +1,892 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MemoryPipelineManager: manages the L0→L1→L2→L3 memory extraction pipeline.
|
|
3
|
-
*
|
|
4
|
-
* ## Layered architecture
|
|
5
|
-
*
|
|
6
|
-
* - **L0 (capture)**: `auto-capture.ts` extracts new messages from each
|
|
7
|
-
* `agent_end` event, sanitizes them, and passes them to the pipeline via
|
|
8
|
-
* `notifyConversation(sessionKey, messages)`. Messages are buffered
|
|
9
|
-
* locally per-session — NO remote call happens at this stage.
|
|
10
|
-
*
|
|
11
|
-
* - **L1 (batch extraction / ingest)**: When the conversation count reaches
|
|
12
|
-
* `everyNConversations` OR the session goes idle for `l1IdleTimeoutSeconds`,
|
|
13
|
-
* the L1 Runner is invoked with all buffered messages. The runner receives
|
|
14
|
-
* `{ sessionKey, msg, bg_msg }` and is responsible for ingesting/extracting
|
|
15
|
-
* them (e.g. calling appendEvent, or running local extraction logic).
|
|
16
|
-
* `bg_msg` is reserved for background context; currently always empty.
|
|
17
|
-
*
|
|
18
|
-
* - **L2 (scene extraction)**: Per-session downward-only timer. After each
|
|
19
|
-
* L2 completion, the next fire time is set to `now + maxInterval`. When
|
|
20
|
-
* L1 completes (new memory event), the fire time is advanced (but never
|
|
21
|
-
* postponed) to `max(now + delay, lastL2 + minInterval)`. When the timer
|
|
22
|
-
* fires, if the session is cold (inactive > `sessionActiveWindowHours`),
|
|
23
|
-
* the timer is cancelled rather than triggering L2 — it will be re-armed
|
|
24
|
-
* by the next L1 event.
|
|
25
|
-
*
|
|
26
|
-
* - **L3 (persona generation)**: Global mutex (concurrency=1) + pending flag
|
|
27
|
-
* dedup. Triggered after L2 completes.
|
|
28
|
-
*
|
|
29
|
-
* ## Timer semantics
|
|
30
|
-
*
|
|
31
|
-
* L1 uses a **resettable timer** (classic idle/debounce): each conversation
|
|
32
|
-
* resets the countdown to `l1IdleTimeoutSeconds`. When the timer fires,
|
|
33
|
-
* buffered messages are flushed through L1.
|
|
34
|
-
*
|
|
35
|
-
* L2 uses a **downward-only timer**: the scheduled fire time can only be
|
|
36
|
-
* moved earlier, never later. This ensures both the maxInterval guarantee
|
|
37
|
-
* and the delay-after-L1 responsiveness, while minInterval acts as a floor.
|
|
38
|
-
*
|
|
39
|
-
* Both timer types are implemented via `ManagedTimer` to eliminate
|
|
40
|
-
* repetitive clear→set→fire→clean boilerplate.
|
|
41
|
-
*
|
|
42
|
-
* ## Trigger paths for L1
|
|
43
|
-
* A. **Conversation threshold** (primary): when `conversation_count >=
|
|
44
|
-
* effectiveThreshold` in `notifyConversation()`, L1 is triggered
|
|
45
|
-
* immediately with all buffered messages. The effective threshold
|
|
46
|
-
* is influenced by warm-up mode (see below).
|
|
47
|
-
* B. **Idle timeout** (catch-up): when a session goes idle for
|
|
48
|
-
* `l1IdleTimeoutSeconds`, L1 fires with whatever messages have
|
|
49
|
-
* been buffered (below threshold).
|
|
50
|
-
* C. **Shutdown flush**: on graceful shutdown, all pending buffers
|
|
51
|
-
* are flushed through L1 then L2.
|
|
52
|
-
*
|
|
53
|
-
* ## Warm-up mode
|
|
54
|
-
*
|
|
55
|
-
* When `enableWarmup` is true (default), new sessions use an exponentially
|
|
56
|
-
* increasing L1 trigger threshold instead of jumping straight to
|
|
57
|
-
* `everyNConversations`. The sequence is: 1 → 2 → 4 → 8 → ... →
|
|
58
|
-
* everyNConversations. This ensures early conversations are processed
|
|
59
|
-
* quickly (first conversation triggers L1 immediately), while gradually
|
|
60
|
-
* reducing processing frequency as the session matures.
|
|
61
|
-
*
|
|
62
|
-
* The `warmup_threshold` field in PipelineSessionState tracks the current
|
|
63
|
-
* threshold. A value of 0 means warm-up is complete (graduated to
|
|
64
|
-
* steady-state). The threshold doubles after each successful L1 run.
|
|
65
|
-
*
|
|
66
|
-
* ## Trigger paths for L2
|
|
67
|
-
* A. **Delay-after-L1**: L1 completes → timer advanced to
|
|
68
|
-
* `max(now + delay, lastL2 + min)` → fires → enqueue L2.
|
|
69
|
-
* B. **MaxInterval guarantee**: L2 completes → timer set to
|
|
70
|
-
* `now + maxInterval` → fires → enqueue L2 (if session active).
|
|
71
|
-
* C. **Shutdown flush**: all pending L2 timers are flushed.
|
|
72
|
-
*
|
|
73
|
-
* All queues use SerialQueue (concurrency=1) for serial execution.
|
|
74
|
-
*
|
|
75
|
-
* ## Design doc
|
|
76
|
-
* See `docs/08-pipeline-refactor-design.md` for full architecture.
|
|
77
|
-
*/
|
|
78
|
-
import { SessionFilter } from "./session-filter.js";
|
|
79
|
-
import { ManagedTimer } from "./managed-timer.js";
|
|
80
|
-
import { SerialQueue } from "./serial-queue.js";
|
|
81
|
-
import { report } from "../core/report/reporter.js";
|
|
82
|
-
const TAG = "[memory-tdai] [pipeline]";
|
|
83
|
-
export class MemoryPipelineManager {
|
|
84
|
-
// Config (converted to ms internally)
|
|
85
|
-
l1IdleTimeoutMs;
|
|
86
|
-
everyNConversations;
|
|
87
|
-
enableWarmup;
|
|
88
|
-
l2DelayAfterL1Ms;
|
|
89
|
-
l2MinIntervalMs;
|
|
90
|
-
l2MaxIntervalMs;
|
|
91
|
-
sessionActiveWindowMs;
|
|
92
|
-
/** Delay before retrying a failed L1 (ms). */
|
|
93
|
-
L1_RETRY_DELAY_MS = 30_000; // 30 seconds
|
|
94
|
-
/** Max consecutive L1 retries per session before giving up. */
|
|
95
|
-
L1_MAX_RETRIES = 5;
|
|
96
|
-
// Queues (named for diagnostics)
|
|
97
|
-
l1Queue = new SerialQueue("L1");
|
|
98
|
-
l2Queue = new SerialQueue("L2");
|
|
99
|
-
l3Queue = new SerialQueue("L3");
|
|
100
|
-
// L3 dedup flag
|
|
101
|
-
l3Pending = false;
|
|
102
|
-
l3Running = false;
|
|
103
|
-
// Per-session state
|
|
104
|
-
sessionStates = new Map();
|
|
105
|
-
sessionTimers = new Map();
|
|
106
|
-
// Per-session message buffer: messages accumulated since last L1 run
|
|
107
|
-
messageBuffers = new Map();
|
|
108
|
-
// Per-session L2 last run time (epoch ms, for minInterval floor)
|
|
109
|
-
l2LastRunTime = new Map();
|
|
110
|
-
// Callbacks
|
|
111
|
-
l1Runner = null;
|
|
112
|
-
l2Runner = null;
|
|
113
|
-
l3Runner = null;
|
|
114
|
-
persister = null;
|
|
115
|
-
logger;
|
|
116
|
-
// Unified session filter (internal sessions + excludeAgents)
|
|
117
|
-
sessionFilter;
|
|
118
|
-
// Lifecycle
|
|
119
|
-
destroyed = false;
|
|
120
|
-
/** Plugin instance ID for metric reporting (set externally after async init). */
|
|
121
|
-
instanceId;
|
|
122
|
-
// Session GC: runs periodically to evict cold sessions from memory
|
|
123
|
-
/** Multiplier on sessionActiveWindowMs to determine GC eligibility. */
|
|
124
|
-
SESSION_GC_INACTIVE_MULTIPLIER = 3;
|
|
125
|
-
/** Run GC every N calls to notifyConversation. */
|
|
126
|
-
SESSION_GC_EVERY_N_NOTIFICATIONS = 50;
|
|
127
|
-
/** Counter for GC scheduling. */
|
|
128
|
-
notifyCounter = 0;
|
|
129
|
-
constructor(config, logger, sessionFilter) {
|
|
130
|
-
this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000;
|
|
131
|
-
this.everyNConversations = config.everyNConversations;
|
|
132
|
-
this.enableWarmup = config.enableWarmup;
|
|
133
|
-
this.l2DelayAfterL1Ms = config.l2.delayAfterL1Seconds * 1000;
|
|
134
|
-
this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000;
|
|
135
|
-
this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000;
|
|
136
|
-
this.sessionActiveWindowMs = config.l2.sessionActiveWindowHours * 60 * 60 * 1000;
|
|
137
|
-
this.logger = logger;
|
|
138
|
-
this.sessionFilter = sessionFilter ?? new SessionFilter();
|
|
139
|
-
this.logger?.debug?.(`${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` +
|
|
140
|
-
`warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` +
|
|
141
|
-
`l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` +
|
|
142
|
-
`l2DelayAfterL1=${config.l2.delayAfterL1Seconds}s, ` +
|
|
143
|
-
`l2MinInterval=${config.l2.minIntervalSeconds}s, ` +
|
|
144
|
-
`l2MaxInterval=${config.l2.maxIntervalSeconds}s, ` +
|
|
145
|
-
`sessionActiveWindow=${config.l2.sessionActiveWindowHours}h`);
|
|
146
|
-
// Wire up queue debug logging
|
|
147
|
-
if (this.logger?.debug) {
|
|
148
|
-
const debugFn = (msg) => this.logger?.debug?.(`${TAG} ${msg}`);
|
|
149
|
-
this.l1Queue.setDebugLogger(debugFn);
|
|
150
|
-
this.l2Queue.setDebugLogger(debugFn);
|
|
151
|
-
this.l3Queue.setDebugLogger(debugFn);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// ============================
|
|
155
|
-
// Setup
|
|
156
|
-
// ============================
|
|
157
|
-
setL1Runner(runner) {
|
|
158
|
-
this.l1Runner = runner;
|
|
159
|
-
}
|
|
160
|
-
setL2Runner(runner) {
|
|
161
|
-
this.l2Runner = runner;
|
|
162
|
-
}
|
|
163
|
-
setL3Runner(runner) {
|
|
164
|
-
this.l3Runner = runner;
|
|
165
|
-
}
|
|
166
|
-
setPersister(persister) {
|
|
167
|
-
this.persister = persister;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Restore session states from checkpoint and start the pipeline.
|
|
171
|
-
* Sessions with pending counts will be immediately re-enqueued.
|
|
172
|
-
*/
|
|
173
|
-
start(restoredStates) {
|
|
174
|
-
if (this.destroyed)
|
|
175
|
-
return;
|
|
176
|
-
if (restoredStates) {
|
|
177
|
-
let skipped = 0;
|
|
178
|
-
for (const [sessionKey, state] of Object.entries(restoredStates)) {
|
|
179
|
-
if (this.sessionFilter.shouldSkip(sessionKey)) {
|
|
180
|
-
skipped++;
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
// Backfill warmup_threshold for sessions persisted before warm-up feature.
|
|
184
|
-
// Missing field → treat as graduated (warmup already complete).
|
|
185
|
-
const patched = { ...state };
|
|
186
|
-
if (patched.warmup_threshold == null) {
|
|
187
|
-
patched.warmup_threshold = 0;
|
|
188
|
-
}
|
|
189
|
-
this.sessionStates.set(sessionKey, patched);
|
|
190
|
-
}
|
|
191
|
-
this.logger?.info(`${TAG} Restored ${this.sessionStates.size} session state(s) from checkpoint` +
|
|
192
|
-
(skipped > 0 ? ` (filtered ${skipped} internal)` : ""));
|
|
193
|
-
}
|
|
194
|
-
// Recovery: re-enqueue sessions with pending work
|
|
195
|
-
this.recoverPendingSessions();
|
|
196
|
-
this.logger?.info(`${TAG} Pipeline started`);
|
|
197
|
-
}
|
|
198
|
-
// ============================
|
|
199
|
-
// L0→L1: Notify (called from auto-capture on agent_end)
|
|
200
|
-
// ============================
|
|
201
|
-
/**
|
|
202
|
-
* Get the effective conversation threshold for a session, considering warm-up.
|
|
203
|
-
*
|
|
204
|
-
* When warm-up is enabled, new sessions start with threshold=1 and double
|
|
205
|
-
* after each successful L1 run: 1 → 2 → 4 → 8 → ... → everyNConversations.
|
|
206
|
-
* Once the threshold reaches everyNConversations, warm-up is considered complete
|
|
207
|
-
* (warmup_threshold is set to 0) and the fixed config value is used.
|
|
208
|
-
*/
|
|
209
|
-
getEffectiveThreshold(state) {
|
|
210
|
-
if (!this.enableWarmup)
|
|
211
|
-
return this.everyNConversations;
|
|
212
|
-
// warmup_threshold === 0 means warm-up completed; use steady-state config
|
|
213
|
-
if (state.warmup_threshold <= 0)
|
|
214
|
-
return this.everyNConversations;
|
|
215
|
-
return Math.min(state.warmup_threshold, this.everyNConversations);
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Advance the warm-up threshold for a session after a successful L1 run.
|
|
219
|
-
* Doubles the threshold until it reaches everyNConversations, then marks
|
|
220
|
-
* warm-up as complete (warmup_threshold = 0).
|
|
221
|
-
*/
|
|
222
|
-
advanceWarmupThreshold(state) {
|
|
223
|
-
if (!this.enableWarmup)
|
|
224
|
-
return;
|
|
225
|
-
if (state.warmup_threshold <= 0)
|
|
226
|
-
return; // already graduated
|
|
227
|
-
const next = state.warmup_threshold * 2;
|
|
228
|
-
if (next >= this.everyNConversations) {
|
|
229
|
-
// Graduated: switch to steady-state
|
|
230
|
-
state.warmup_threshold = 0;
|
|
231
|
-
this.logger?.debug?.(`${TAG} Warm-up graduated → using steady-state threshold ${this.everyNConversations}`);
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
state.warmup_threshold = next;
|
|
235
|
-
this.logger?.debug?.(`${TAG} Warm-up advanced → next threshold ${next}`);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Notify the pipeline that a conversation round has ended for a session,
|
|
240
|
-
* and buffer the captured messages for L1 batch processing.
|
|
241
|
-
*
|
|
242
|
-
* Two trigger paths start here:
|
|
243
|
-
* - **Path A (threshold)**: if conversation_count >= effective threshold
|
|
244
|
-
* (warm-up or steady-state), trigger L1 immediately with all buffered messages.
|
|
245
|
-
* - **Path B (idle)**: reset the L1 idle timer. When the timer fires (user
|
|
246
|
-
* stops chatting), L1 runs with whatever has been buffered.
|
|
247
|
-
*/
|
|
248
|
-
async notifyConversation(sessionKey, messages) {
|
|
249
|
-
if (this.destroyed)
|
|
250
|
-
return;
|
|
251
|
-
if (this.sessionFilter.shouldSkip(sessionKey))
|
|
252
|
-
return;
|
|
253
|
-
const state = this.getOrCreateState(sessionKey);
|
|
254
|
-
state.conversation_count += 1;
|
|
255
|
-
state.last_active_time = Date.now();
|
|
256
|
-
// Reset L1 retry count on new conversation (environment may have recovered)
|
|
257
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
258
|
-
timers.l1RetryCount = 0;
|
|
259
|
-
// Buffer messages for L1
|
|
260
|
-
const buffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
261
|
-
buffer.push(...messages);
|
|
262
|
-
this.messageBuffers.set(sessionKey, buffer);
|
|
263
|
-
const effectiveThreshold = this.getEffectiveThreshold(state);
|
|
264
|
-
const warmupInfo = this.enableWarmup && state.warmup_threshold > 0
|
|
265
|
-
? ` (warmup: ${state.warmup_threshold})`
|
|
266
|
-
: "";
|
|
267
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] notify: conversation_count=${state.conversation_count}/${effectiveThreshold}${warmupInfo}, ` +
|
|
268
|
-
`buffered_messages=${buffer.length} (+${messages.length} new)`);
|
|
269
|
-
await this.persistStates();
|
|
270
|
-
// Path A: conversation count reached effective threshold → trigger L1 batch
|
|
271
|
-
if (state.conversation_count >= effectiveThreshold) {
|
|
272
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Conversation threshold reached (${state.conversation_count}>=${effectiveThreshold}${warmupInfo}), triggering L1`);
|
|
273
|
-
this.enqueueL1(sessionKey);
|
|
274
|
-
return; // skip idle timer reset — L1 is already triggered
|
|
275
|
-
}
|
|
276
|
-
// Path B: below threshold → reset L1 idle timer (catch residual later)
|
|
277
|
-
timers.l1Idle.schedule(this.l1IdleTimeoutMs, () => this.onL1IdleTimeout(sessionKey));
|
|
278
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timer reset (${this.l1IdleTimeoutMs / 1000}s)`);
|
|
279
|
-
// Periodic GC: evict cold sessions from memory
|
|
280
|
-
this.notifyCounter += 1;
|
|
281
|
-
if (this.notifyCounter >= this.SESSION_GC_EVERY_N_NOTIFICATIONS) {
|
|
282
|
-
this.notifyCounter = 0;
|
|
283
|
-
this.gcStaleSessions();
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
// ============================
|
|
287
|
-
// Graceful shutdown
|
|
288
|
-
// ============================
|
|
289
|
-
/**
|
|
290
|
-
* Per-session flush — scoped end-of-session handling.
|
|
291
|
-
*
|
|
292
|
-
* Semantically different from {@link destroy}:
|
|
293
|
-
* - ``destroy`` tears down the *whole* scheduler (meant for process
|
|
294
|
-
* shutdown such as OpenClaw's ``gateway_stop``).
|
|
295
|
-
* - ``flushSession`` only processes the one session identified by
|
|
296
|
-
* ``sessionKey`` and leaves every other session's timers, buffers
|
|
297
|
-
* and pipeline state untouched. This is the correct semantic for
|
|
298
|
-
* the Gateway's ``POST /session/end`` endpoint and for Hermes'
|
|
299
|
-
* ``on_session_end`` callback, which fire when one conversation
|
|
300
|
-
* ends while the process keeps serving other concurrent sessions.
|
|
301
|
-
*
|
|
302
|
-
* What it does:
|
|
303
|
-
* 1. Cancel the session's pending L1 idle timer (no further idle
|
|
304
|
-
* fires for this key).
|
|
305
|
-
* 2. If the session's message buffer still holds work, enqueue an
|
|
306
|
-
* immediate L1 run for this session (``triggerReason="flush"``).
|
|
307
|
-
* 3. Await the shared ``l1Queue`` so the caller observes L1
|
|
308
|
-
* completion before returning. We do not selectively wait
|
|
309
|
-
* because L1 is already a single-consumer SerialQueue — waiting
|
|
310
|
-
* for ``onIdle`` is the cheapest correct signal.
|
|
311
|
-
*
|
|
312
|
-
* What it deliberately does NOT do:
|
|
313
|
-
* - Touch other sessions' timers / buffers / pipeline state.
|
|
314
|
-
* - Destroy the scheduler or any of its queues.
|
|
315
|
-
* - Reset global fields such as ``destroyed``.
|
|
316
|
-
*
|
|
317
|
-
* Unknown session keys are a no-op: the scheduler may legitimately
|
|
318
|
-
* have evicted the session earlier via GC, or the session may never
|
|
319
|
-
* have produced any captures.
|
|
320
|
-
*/
|
|
321
|
-
async flushSession(sessionKey) {
|
|
322
|
-
if (this.destroyed)
|
|
323
|
-
return;
|
|
324
|
-
if (this.sessionFilter.shouldSkip(sessionKey))
|
|
325
|
-
return;
|
|
326
|
-
const timers = this.sessionTimers.get(sessionKey);
|
|
327
|
-
const buffer = this.messageBuffers.get(sessionKey);
|
|
328
|
-
// Step 1: cancel the idle timer so it won't fire after we return.
|
|
329
|
-
if (timers?.l1Idle.pending) {
|
|
330
|
-
timers.l1Idle.cancel();
|
|
331
|
-
}
|
|
332
|
-
// Step 2: flush pending buffered messages through L1 if any.
|
|
333
|
-
if (buffer && buffer.length > 0) {
|
|
334
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: enqueuing L1 for ${buffer.length} buffered message(s)`);
|
|
335
|
-
this.enqueueL1(sessionKey, "flush");
|
|
336
|
-
}
|
|
337
|
-
// Step 3: wait for L1 to drain. L1 is a single-consumer SerialQueue
|
|
338
|
-
// so this is the cheapest correct signal; it will not starve other
|
|
339
|
-
// sessions because any cross-session interleaving L1 work was either
|
|
340
|
-
// already queued or will be queued concurrently by their own capture
|
|
341
|
-
// paths.
|
|
342
|
-
await this.l1Queue.onIdle();
|
|
343
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: complete`);
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* Maximum time (ms) to wait for pipeline flush during destroy.
|
|
347
|
-
* Must be shorter than the gateway_stop hook timeout (3 s) to leave
|
|
348
|
-
* headroom for VectorStore / EmbeddingService cleanup that runs after.
|
|
349
|
-
*/
|
|
350
|
-
DESTROY_TIMEOUT_MS = 2_000;
|
|
351
|
-
/**
|
|
352
|
-
* Graceful shutdown with timeout protection:
|
|
353
|
-
* 1. Mark destroyed, stop accepting new work
|
|
354
|
-
* 2. Attempt to flush pending L1/L2/L3 work within DESTROY_TIMEOUT_MS
|
|
355
|
-
* 3. If flush times out or fails, persist current state for recovery on next startup
|
|
356
|
-
* 4. Pending work is never lost — it will be recovered via checkpoint on next start()
|
|
357
|
-
*/
|
|
358
|
-
async destroy() {
|
|
359
|
-
if (this.destroyed)
|
|
360
|
-
return;
|
|
361
|
-
this.destroyed = true;
|
|
362
|
-
this.logger?.info(`${TAG} Destroying pipeline (timeout=${this.DESTROY_TIMEOUT_MS}ms)...`);
|
|
363
|
-
try {
|
|
364
|
-
let timeoutId;
|
|
365
|
-
await Promise.race([
|
|
366
|
-
this._doFlush(),
|
|
367
|
-
new Promise((_, reject) => {
|
|
368
|
-
timeoutId = setTimeout(() => reject(new Error("destroy timeout")), this.DESTROY_TIMEOUT_MS);
|
|
369
|
-
}),
|
|
370
|
-
]).finally(() => {
|
|
371
|
-
if (timeoutId !== undefined)
|
|
372
|
-
clearTimeout(timeoutId);
|
|
373
|
-
});
|
|
374
|
-
this.logger?.info(`${TAG} Pipeline flushed successfully`);
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
this.logger?.warn(`${TAG} Pipeline flush timed out or failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
378
|
-
`Pending work will be recovered on next startup.`);
|
|
379
|
-
}
|
|
380
|
-
// Always persist state — whether flush succeeded, timed out, or failed.
|
|
381
|
-
// This ensures pending work (buffered messages, L2 pending counts) is
|
|
382
|
-
// saved to checkpoint and can be recovered by recoverPendingSessions().
|
|
383
|
-
try {
|
|
384
|
-
await this.persistStates();
|
|
385
|
-
}
|
|
386
|
-
catch (err) {
|
|
387
|
-
this.logger?.error(`${TAG} Failed to persist states during destroy: ${err instanceof Error ? err.message : String(err)}`);
|
|
388
|
-
}
|
|
389
|
-
this.logger?.info(`${TAG} Pipeline destroyed`);
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* Internal: attempt to flush all pending pipeline work (L1 → L2 → L3).
|
|
393
|
-
* Extracted from destroy() so it can be wrapped with a timeout.
|
|
394
|
-
*/
|
|
395
|
-
async _doFlush() {
|
|
396
|
-
// Step 1: Flush all L1 idle timers — only enqueue if there are buffered messages
|
|
397
|
-
for (const [sessionKey, timers] of this.sessionTimers) {
|
|
398
|
-
if (timers.l1Idle.pending) {
|
|
399
|
-
timers.l1Idle.cancel(); // don't fire the idle callback directly
|
|
400
|
-
const buffer = this.messageBuffers.get(sessionKey);
|
|
401
|
-
if (buffer && buffer.length > 0) {
|
|
402
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: enqueuing L1 for ${buffer.length} buffered messages`);
|
|
403
|
-
this.enqueueL1(sessionKey, "flush");
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
// Step 2: Wait for L1 queue to drain
|
|
408
|
-
this.logger?.debug?.(`${TAG} Waiting for L1 queue to drain (size=${this.l1Queue.size})`);
|
|
409
|
-
await this.l1Queue.onIdle();
|
|
410
|
-
// Step 3: Flush all L2 schedule timers
|
|
411
|
-
for (const [sessionKey, timers] of this.sessionTimers) {
|
|
412
|
-
if (timers.l2Schedule.pending) {
|
|
413
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: triggering L2 schedule timer`);
|
|
414
|
-
timers.l2Schedule.flush();
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
// Step 4: Wait for all remaining queues to drain
|
|
418
|
-
this.logger?.debug?.(`${TAG} Waiting for queues to drain (l2=${this.l2Queue.size}, l3=${this.l3Queue.size})`);
|
|
419
|
-
await Promise.all([
|
|
420
|
-
this.l2Queue.onIdle(),
|
|
421
|
-
this.l3Queue.onIdle(),
|
|
422
|
-
]);
|
|
423
|
-
}
|
|
424
|
-
// ============================
|
|
425
|
-
// Internal: L1 idle timeout handler
|
|
426
|
-
// ============================
|
|
427
|
-
onL1IdleTimeout(sessionKey) {
|
|
428
|
-
const buffer = this.messageBuffers.get(sessionKey);
|
|
429
|
-
const state = this.sessionStates.get(sessionKey);
|
|
430
|
-
if ((!buffer || buffer.length === 0) && (!state || state.conversation_count === 0)) {
|
|
431
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout but no pending messages or conversations`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout fired (buffered=${buffer?.length ?? 0}, conversations=${state?.conversation_count ?? 0})`);
|
|
435
|
-
this.enqueueL1(sessionKey, "idle_timeout");
|
|
436
|
-
}
|
|
437
|
-
// ============================
|
|
438
|
-
// Internal: L1 queue
|
|
439
|
-
// ============================
|
|
440
|
-
enqueueL1(sessionKey, triggerReason = "threshold") {
|
|
441
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
442
|
-
// Don't double-queue
|
|
443
|
-
if (timers.l1Queued) {
|
|
444
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 already queued, skipping`);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
// Cancel idle timer if running (threshold beat it)
|
|
448
|
-
timers.l1Idle.cancel();
|
|
449
|
-
timers.l1Queued = true;
|
|
450
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L1 (queue=${this.l1Queue.name})`);
|
|
451
|
-
// ── pipeline_l1_trigger metric ──
|
|
452
|
-
const state = this.sessionStates.get(sessionKey);
|
|
453
|
-
const buffer = this.messageBuffers.get(sessionKey);
|
|
454
|
-
if (this.instanceId && this.logger) {
|
|
455
|
-
report("pipeline_l1_trigger", {
|
|
456
|
-
sessionKey,
|
|
457
|
-
triggerReason,
|
|
458
|
-
conversationCount: state?.conversation_count ?? 0,
|
|
459
|
-
bufferedMessageCount: buffer?.length ?? 0,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
this.l1Queue.add(async () => {
|
|
463
|
-
await this.runL1(sessionKey);
|
|
464
|
-
}).catch((err) => {
|
|
465
|
-
this.logger?.error(`${TAG} [${sessionKey}] L1 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
466
|
-
}).finally(() => {
|
|
467
|
-
timers.l1Queued = false;
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* L1 runner: Takes all buffered messages for a session and passes them
|
|
472
|
-
* to the L1Runner for batch processing (e.g. appendEvent, local extraction).
|
|
473
|
-
*
|
|
474
|
-
* After L1 completes successfully:
|
|
475
|
-
* - conversation_count and message buffer are reset
|
|
476
|
-
* - L2 timer is advanced (downward-only) to allow remote record generation
|
|
477
|
-
*
|
|
478
|
-
* If L1 fails, conversation_count and buffer are preserved for retry
|
|
479
|
-
* on next idle timeout or threshold trigger.
|
|
480
|
-
*/
|
|
481
|
-
async runL1(sessionKey) {
|
|
482
|
-
const state = this.sessionStates.get(sessionKey);
|
|
483
|
-
if (!state)
|
|
484
|
-
return;
|
|
485
|
-
// Drain the message buffer (take ownership, clear the shared ref)
|
|
486
|
-
const buffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
487
|
-
this.messageBuffers.set(sessionKey, []);
|
|
488
|
-
if (buffer.length === 0 && state.conversation_count === 0) {
|
|
489
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 skipped: no messages and no pending conversations`);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 running: messages=${buffer.length}, conversation_count=${state.conversation_count}`);
|
|
493
|
-
if (!this.l1Runner) {
|
|
494
|
-
this.logger?.warn(`${TAG} [${sessionKey}] No L1 runner set, skipping`);
|
|
495
|
-
state.l2_pending_l1_count = state.conversation_count;
|
|
496
|
-
state.conversation_count = 0;
|
|
497
|
-
this.advanceWarmupThreshold(state);
|
|
498
|
-
await this.persistStates();
|
|
499
|
-
this.advanceL2Timer(sessionKey);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
try {
|
|
503
|
-
await this.l1Runner({
|
|
504
|
-
sessionKey,
|
|
505
|
-
msg: buffer,
|
|
506
|
-
bg_msg: [], // reserved for future use
|
|
507
|
-
});
|
|
508
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 complete: processed ${buffer.length} messages`);
|
|
509
|
-
}
|
|
510
|
-
catch (err) {
|
|
511
|
-
this.logger?.error(`${TAG} [${sessionKey}] L1 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
512
|
-
// On failure: put messages back into the buffer for retry
|
|
513
|
-
const currentBuffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
514
|
-
this.messageBuffers.set(sessionKey, [...buffer, ...currentBuffer]);
|
|
515
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 failure: restored ${buffer.length} messages to buffer (total=${buffer.length + currentBuffer.length})`);
|
|
516
|
-
// Re-arm L1 idle timer for automatic retry (with max retry limit)
|
|
517
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
518
|
-
timers.l1RetryCount += 1;
|
|
519
|
-
if (timers.l1RetryCount <= this.L1_MAX_RETRIES) {
|
|
520
|
-
timers.l1Idle.schedule(this.L1_RETRY_DELAY_MS, () => this.onL1IdleTimeout(sessionKey));
|
|
521
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 retry scheduled in ${this.L1_RETRY_DELAY_MS / 1000}s ` +
|
|
522
|
-
`(attempt ${timers.l1RetryCount}/${this.L1_MAX_RETRIES})`);
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
this.logger?.warn(`${TAG} [${sessionKey}] L1 max retries reached (${this.L1_MAX_RETRIES}), ` +
|
|
526
|
-
`giving up auto-retry. ${buffer.length + currentBuffer.length} messages remain buffered. ` +
|
|
527
|
-
`Will resume on next user conversation.`);
|
|
528
|
-
}
|
|
529
|
-
return; // don't advance state or trigger L2
|
|
530
|
-
}
|
|
531
|
-
// Success: reset retry count and advance state
|
|
532
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
533
|
-
timers.l1RetryCount = 0;
|
|
534
|
-
state.l2_pending_l1_count = state.conversation_count;
|
|
535
|
-
state.conversation_count = 0;
|
|
536
|
-
this.advanceWarmupThreshold(state);
|
|
537
|
-
await this.persistStates();
|
|
538
|
-
// Advance the L2 timer (downward-only) to fire after delay, respecting minInterval
|
|
539
|
-
this.advanceL2Timer(sessionKey);
|
|
540
|
-
}
|
|
541
|
-
// ============================
|
|
542
|
-
// Internal: L2 timer management (downward-only)
|
|
543
|
-
// ============================
|
|
544
|
-
/**
|
|
545
|
-
* Advance the per-session L2 timer after an L1 event (new memory generated).
|
|
546
|
-
*
|
|
547
|
-
* Computes the desired fire time as:
|
|
548
|
-
* T_desired = max(now + l2DelayAfterL1, lastL2Time + l2MinInterval)
|
|
549
|
-
*
|
|
550
|
-
* The timer is only moved if T_desired is earlier than the current schedule
|
|
551
|
-
* (downward-only semantics). If no timer is pending, it's set unconditionally.
|
|
552
|
-
*/
|
|
553
|
-
advanceL2Timer(sessionKey) {
|
|
554
|
-
if (this.destroyed)
|
|
555
|
-
return;
|
|
556
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
557
|
-
const now = Date.now();
|
|
558
|
-
// Compute the floor: lastL2 + minInterval (rate-limit protection)
|
|
559
|
-
const lastL2 = this.l2LastRunTime.get(sessionKey) ?? 0;
|
|
560
|
-
const minIntervalFloor = lastL2 > 0 ? lastL2 + this.l2MinIntervalMs : 0;
|
|
561
|
-
// Desired fire time: delay after L1, but no earlier than minInterval floor
|
|
562
|
-
const desiredTime = Math.max(now + this.l2DelayAfterL1Ms, minIntervalFloor);
|
|
563
|
-
const advanced = timers.l2Schedule.tryAdvanceTo(desiredTime, () => this.onL2TimerFired(sessionKey, "delay-after-l1"));
|
|
564
|
-
if (advanced) {
|
|
565
|
-
const delaySec = Math.round((desiredTime - now) / 1000);
|
|
566
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer advanced: firing in ${delaySec}s` +
|
|
567
|
-
(timers.l2Schedule.scheduledTime > 0
|
|
568
|
-
? ` (was ${Math.round((timers.l2Schedule.scheduledTime - now) / 1000)}s)`
|
|
569
|
-
: " (newly armed)"));
|
|
570
|
-
}
|
|
571
|
-
else {
|
|
572
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer not advanced: current schedule is already earlier`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Arm the L2 timer for the maxInterval guarantee after L2 completes.
|
|
577
|
-
* Sets T = now + l2MaxInterval (unconditional, replaces any pending timer).
|
|
578
|
-
*/
|
|
579
|
-
armL2MaxInterval(sessionKey) {
|
|
580
|
-
if (this.destroyed)
|
|
581
|
-
return;
|
|
582
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
583
|
-
const fireAt = Date.now() + this.l2MaxIntervalMs;
|
|
584
|
-
timers.l2Schedule.scheduleAt(fireAt, () => this.onL2TimerFired(sessionKey, "max-interval"));
|
|
585
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 maxInterval timer armed: ${Math.round(this.l2MaxIntervalMs / 1000)}s`);
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* Called when a per-session L2 timer fires.
|
|
589
|
-
*
|
|
590
|
-
* Checks session activity: if the session is cold (inactive > activeWindow),
|
|
591
|
-
* the timer is NOT re-armed — it will be revived by the next L1 event.
|
|
592
|
-
* Otherwise, enqueues L2.
|
|
593
|
-
*
|
|
594
|
-
* The `source` parameter distinguishes the trigger origin:
|
|
595
|
-
* - "delay-after-l1": fired shortly after L1 completed — skip cold check
|
|
596
|
-
* because L1 completion itself proves recent activity.
|
|
597
|
-
* - "max-interval": periodic timer — apply cold check normally.
|
|
598
|
-
*/
|
|
599
|
-
onL2TimerFired(sessionKey, source) {
|
|
600
|
-
const state = this.sessionStates.get(sessionKey);
|
|
601
|
-
if (!state)
|
|
602
|
-
return;
|
|
603
|
-
const now = Date.now();
|
|
604
|
-
// Cold session check: only applies to periodic (maxInterval) triggers.
|
|
605
|
-
// Delay-after-L1 triggers are exempt because L1 just completed, proving
|
|
606
|
-
// the session was recently active.
|
|
607
|
-
if (source === "max-interval" && now - state.last_active_time >= this.sessionActiveWindowMs) {
|
|
608
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer fired but session is cold ` +
|
|
609
|
-
`(inactive ${Math.round((now - state.last_active_time) / 3600_000)}h), timer stopped. ` +
|
|
610
|
-
`Will re-arm on next L1 event.`);
|
|
611
|
-
return; // timer not re-armed — advanceL2Timer() in runL1 will revive it
|
|
612
|
-
}
|
|
613
|
-
this.enqueueL2(sessionKey, `timer:${source}`);
|
|
614
|
-
}
|
|
615
|
-
// ============================
|
|
616
|
-
// Internal: L2 queue
|
|
617
|
-
// ============================
|
|
618
|
-
enqueueL2(sessionKey, trigger) {
|
|
619
|
-
const timers = this.getOrCreateTimers(sessionKey);
|
|
620
|
-
// Cancel any pending L2 timer (we're about to run L2)
|
|
621
|
-
timers.l2Schedule.cancel();
|
|
622
|
-
// Conflict detection: warn if L2 is already queued
|
|
623
|
-
if (timers.l2Queued) {
|
|
624
|
-
this.logger?.warn(`${TAG} [${sessionKey}] L2 enqueue conflict on queue "${this.l2Queue.name}": ` +
|
|
625
|
-
`task already queued/running (trigger=${trigger}), skipping`);
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
timers.l2Queued = true;
|
|
629
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L2 (trigger=${trigger}, queue=${this.l2Queue.name})`);
|
|
630
|
-
this.l2Queue.add(async () => {
|
|
631
|
-
await this.runL2(sessionKey);
|
|
632
|
-
}).catch((err) => {
|
|
633
|
-
this.logger?.error(`${TAG} [${sessionKey}] L2 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
634
|
-
}).finally(() => {
|
|
635
|
-
timers.l2Queued = false;
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
async runL2(sessionKey) {
|
|
639
|
-
const state = this.sessionStates.get(sessionKey);
|
|
640
|
-
if (!state)
|
|
641
|
-
return;
|
|
642
|
-
if (!this.l2Runner) {
|
|
643
|
-
this.logger?.warn(`${TAG} [${sessionKey}] No L2 runner set, skipping`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 running: l2_pending_l1_count=${state.l2_pending_l1_count}`);
|
|
647
|
-
const cursor = state.last_extraction_updated_time || undefined;
|
|
648
|
-
let result;
|
|
649
|
-
try {
|
|
650
|
-
result = await this.l2Runner(sessionKey, cursor);
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
this.logger?.error(`${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
654
|
-
// Even on failure, arm maxInterval so we retry eventually
|
|
655
|
-
this.armL2MaxInterval(sessionKey);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
// After L2: update state
|
|
659
|
-
const now = Date.now();
|
|
660
|
-
state.l2_pending_l1_count = 0;
|
|
661
|
-
// Cold-start optimization: if this is the very first L2 run for this session
|
|
662
|
-
// and it was skipped (no new records), do NOT update l2LastRunTime.
|
|
663
|
-
// This prevents l2MinIntervalSeconds from blocking the next L2 trigger
|
|
664
|
-
// when the first L1 extraction produces actual memories shortly after.
|
|
665
|
-
const isFirstL2 = !this.l2LastRunTime.has(sessionKey);
|
|
666
|
-
const wasSkipped = result?.skipped === true;
|
|
667
|
-
if (isFirstL2 && wasSkipped) {
|
|
668
|
-
this.logger?.info?.(`${TAG} [${sessionKey}] L2 cold-start skip: not updating l2LastRunTime ` +
|
|
669
|
-
`(minInterval won't block next trigger)`);
|
|
670
|
-
this.armL2MaxInterval(sessionKey);
|
|
671
|
-
await this.persistStates();
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
state.last_extraction_time = new Date().toISOString();
|
|
675
|
-
state.l2_last_extraction_time = new Date().toISOString();
|
|
676
|
-
this.l2LastRunTime.set(sessionKey, now);
|
|
677
|
-
// Advance cursor using the record timestamp returned by the runner
|
|
678
|
-
if (result?.latestCursor) {
|
|
679
|
-
state.last_extraction_updated_time = result.latestCursor;
|
|
680
|
-
}
|
|
681
|
-
else if (!state.last_extraction_updated_time) {
|
|
682
|
-
// Cold-start guard: if runner returned void (e.g. extraction failure) and
|
|
683
|
-
// last_extraction_updated_time is still empty, initialize it to now so
|
|
684
|
-
// the next L2 run doesn't do a full table scan.
|
|
685
|
-
state.last_extraction_updated_time = new Date().toISOString();
|
|
686
|
-
}
|
|
687
|
-
await this.persistStates();
|
|
688
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 complete`);
|
|
689
|
-
// Arm the maxInterval timer for the next cycle
|
|
690
|
-
this.armL2MaxInterval(sessionKey);
|
|
691
|
-
// Trigger L3
|
|
692
|
-
this.triggerL3();
|
|
693
|
-
}
|
|
694
|
-
// ============================
|
|
695
|
-
// Internal: L3 queue (global, dedup)
|
|
696
|
-
// ============================
|
|
697
|
-
triggerL3() {
|
|
698
|
-
if (this.destroyed)
|
|
699
|
-
return;
|
|
700
|
-
if (this.l3Running) {
|
|
701
|
-
// L3 is in progress — mark pending so it runs again after current finishes
|
|
702
|
-
this.l3Pending = true;
|
|
703
|
-
this.logger?.debug?.(`${TAG} L3 already running, marking pending`);
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
this.logger?.debug?.(`${TAG} Triggering L3`);
|
|
707
|
-
this.enqueueL3();
|
|
708
|
-
}
|
|
709
|
-
enqueueL3() {
|
|
710
|
-
this.l3Running = true;
|
|
711
|
-
this.l3Pending = false;
|
|
712
|
-
this.logger?.debug?.(`${TAG} Enqueuing L3 (queue=${this.l3Queue.name})`);
|
|
713
|
-
this.l3Queue.add(async () => {
|
|
714
|
-
await this.runL3();
|
|
715
|
-
}).catch((err) => {
|
|
716
|
-
this.logger?.error(`${TAG} L3 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
717
|
-
}).finally(() => {
|
|
718
|
-
this.l3Running = false;
|
|
719
|
-
// If new L2 completions happened while L3 was running, run again
|
|
720
|
-
if (this.l3Pending && !this.destroyed) {
|
|
721
|
-
this.logger?.debug?.(`${TAG} L3 has pending work, re-running`);
|
|
722
|
-
this.enqueueL3();
|
|
723
|
-
}
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
async runL3() {
|
|
727
|
-
if (!this.l3Runner) {
|
|
728
|
-
this.logger?.warn(`${TAG} No L3 runner set, skipping`);
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
this.logger?.debug?.(`${TAG} L3 running`);
|
|
732
|
-
try {
|
|
733
|
-
await this.l3Runner();
|
|
734
|
-
this.logger?.debug?.(`${TAG} L3 complete`);
|
|
735
|
-
}
|
|
736
|
-
catch (err) {
|
|
737
|
-
this.logger?.error(`${TAG} L3 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
// ============================
|
|
741
|
-
// Internal: state management
|
|
742
|
-
// ============================
|
|
743
|
-
getOrCreateState(sessionKey) {
|
|
744
|
-
let state = this.sessionStates.get(sessionKey);
|
|
745
|
-
if (!state) {
|
|
746
|
-
state = {
|
|
747
|
-
conversation_count: 0,
|
|
748
|
-
last_extraction_time: "",
|
|
749
|
-
last_extraction_updated_time: "",
|
|
750
|
-
last_active_time: Date.now(),
|
|
751
|
-
l2_pending_l1_count: 0,
|
|
752
|
-
warmup_threshold: this.enableWarmup ? 1 : 0,
|
|
753
|
-
l2_last_extraction_time: "",
|
|
754
|
-
};
|
|
755
|
-
this.sessionStates.set(sessionKey, state);
|
|
756
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Created new session state`);
|
|
757
|
-
}
|
|
758
|
-
return state;
|
|
759
|
-
}
|
|
760
|
-
getOrCreateTimers(sessionKey) {
|
|
761
|
-
let timers = this.sessionTimers.get(sessionKey);
|
|
762
|
-
if (!timers) {
|
|
763
|
-
const isDestroyed = () => this.destroyed;
|
|
764
|
-
timers = {
|
|
765
|
-
l1Idle: new ManagedTimer(`L1-idle:${sessionKey}`, isDestroyed),
|
|
766
|
-
l2Schedule: new ManagedTimer(`L2-schedule:${sessionKey}`, isDestroyed),
|
|
767
|
-
l1Queued: false,
|
|
768
|
-
l2Queued: false,
|
|
769
|
-
l1RetryCount: 0,
|
|
770
|
-
};
|
|
771
|
-
this.sessionTimers.set(sessionKey, timers);
|
|
772
|
-
}
|
|
773
|
-
return timers;
|
|
774
|
-
}
|
|
775
|
-
async persistStates() {
|
|
776
|
-
if (!this.persister)
|
|
777
|
-
return;
|
|
778
|
-
// PipelineSessionState only contains pipeline-owned fields, so we can
|
|
779
|
-
// safely persist the entire object without risk of overwriting runner state.
|
|
780
|
-
const obj = {};
|
|
781
|
-
for (const [k, v] of this.sessionStates) {
|
|
782
|
-
obj[k] = { ...v };
|
|
783
|
-
}
|
|
784
|
-
try {
|
|
785
|
-
this.logger?.debug?.(`Persisting states: ${JSON.stringify(obj)}`);
|
|
786
|
-
await this.persister(obj);
|
|
787
|
-
}
|
|
788
|
-
catch (err) {
|
|
789
|
-
this.logger?.error(`${TAG} Failed to persist states: ${err instanceof Error ? err.message : String(err)}`);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Evict cold sessions from in-memory maps to prevent unbounded growth.
|
|
794
|
-
*
|
|
795
|
-
* A session is eligible for GC when:
|
|
796
|
-
* 1. Inactive for > sessionActiveWindowMs * SESSION_GC_INACTIVE_MULTIPLIER
|
|
797
|
-
* 2. No queued/running L1 or L2 tasks
|
|
798
|
-
* 3. No buffered messages pending processing
|
|
799
|
-
*
|
|
800
|
-
* Evicted sessions can be fully restored from checkpoint on next
|
|
801
|
-
* `notifyConversation()` (state) or `start()` (recovery).
|
|
802
|
-
*/
|
|
803
|
-
gcStaleSessions() {
|
|
804
|
-
const now = Date.now();
|
|
805
|
-
const maxInactiveMs = this.sessionActiveWindowMs * this.SESSION_GC_INACTIVE_MULTIPLIER;
|
|
806
|
-
let evictedCount = 0;
|
|
807
|
-
for (const [sessionKey, state] of this.sessionStates) {
|
|
808
|
-
if (now - state.last_active_time < maxInactiveMs)
|
|
809
|
-
continue;
|
|
810
|
-
// Safety: don't evict sessions with active work
|
|
811
|
-
const timers = this.sessionTimers.get(sessionKey);
|
|
812
|
-
if (timers?.l1Queued || timers?.l2Queued)
|
|
813
|
-
continue;
|
|
814
|
-
const buffer = this.messageBuffers.get(sessionKey);
|
|
815
|
-
if (buffer && buffer.length > 0)
|
|
816
|
-
continue;
|
|
817
|
-
// Evict: cancel any pending timers, then remove from all maps
|
|
818
|
-
if (timers) {
|
|
819
|
-
timers.l1Idle.cancel();
|
|
820
|
-
timers.l2Schedule.cancel();
|
|
821
|
-
}
|
|
822
|
-
this.sessionStates.delete(sessionKey);
|
|
823
|
-
this.sessionTimers.delete(sessionKey);
|
|
824
|
-
this.messageBuffers.delete(sessionKey);
|
|
825
|
-
this.l2LastRunTime.delete(sessionKey);
|
|
826
|
-
evictedCount++;
|
|
827
|
-
}
|
|
828
|
-
if (evictedCount > 0) {
|
|
829
|
-
this.logger?.debug?.(`${TAG} Session GC: evicted ${evictedCount} cold session(s), ` +
|
|
830
|
-
`${this.sessionStates.size} remaining`);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
/**
|
|
834
|
-
* Recovery: re-enqueue sessions that have pending work from before restart.
|
|
835
|
-
*
|
|
836
|
-
* On restart, message buffers are empty (in-memory only). Sessions with
|
|
837
|
-
* non-zero conversation_count had messages that were either:
|
|
838
|
-
* 1. Already processed by L1 (l2_pending_l1_count > 0) → arm L2 timer
|
|
839
|
-
* 2. Never reached L1 (conversation_count > 0, messages lost) → arm L2
|
|
840
|
-
* as best-effort recovery
|
|
841
|
-
*
|
|
842
|
-
* We arm L2 timers (with delay) rather than enqueuing immediately,
|
|
843
|
-
* because the pipeline may be starting during management commands.
|
|
844
|
-
*/
|
|
845
|
-
recoverPendingSessions() {
|
|
846
|
-
for (const [sessionKey, state] of this.sessionStates) {
|
|
847
|
-
if (state.conversation_count === 0 && state.l2_pending_l1_count === 0)
|
|
848
|
-
continue;
|
|
849
|
-
this.logger?.debug?.(`${TAG} [${sessionKey}] Recovery: conversation_count=${state.conversation_count}, ` +
|
|
850
|
-
`l2_pending_l1_count=${state.l2_pending_l1_count}, arming L2 timer`);
|
|
851
|
-
// Reset conversation_count since we can't recover the messages
|
|
852
|
-
state.l2_pending_l1_count = Math.max(state.l2_pending_l1_count, state.conversation_count);
|
|
853
|
-
state.conversation_count = 0;
|
|
854
|
-
// Arm L2 timer with delay (gives the system time to fully start)
|
|
855
|
-
this.advanceL2Timer(sessionKey);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
// ============================
|
|
859
|
-
// Public accessors (for testing / status)
|
|
860
|
-
// ============================
|
|
861
|
-
/** Get the pipeline session state for a session (read-only copy). */
|
|
862
|
-
getSessionState(sessionKey) {
|
|
863
|
-
const state = this.sessionStates.get(sessionKey);
|
|
864
|
-
return state ? { ...state } : undefined;
|
|
865
|
-
}
|
|
866
|
-
/** Get the buffered message count for a session. */
|
|
867
|
-
getBufferedMessageCount(sessionKey) {
|
|
868
|
-
return this.messageBuffers.get(sessionKey)?.length ?? 0;
|
|
869
|
-
}
|
|
870
|
-
/** Get all session keys being tracked. */
|
|
871
|
-
getSessionKeys() {
|
|
872
|
-
return Array.from(this.sessionStates.keys());
|
|
873
|
-
}
|
|
874
|
-
/** Whether the pipeline has been destroyed. */
|
|
875
|
-
get isDestroyed() {
|
|
876
|
-
return this.destroyed;
|
|
877
|
-
}
|
|
878
|
-
/** Queue sizes and running state for monitoring. */
|
|
879
|
-
getQueueSizes() {
|
|
880
|
-
return {
|
|
881
|
-
l1: this.l1Queue.size,
|
|
882
|
-
l2: this.l2Queue.size,
|
|
883
|
-
l3: this.l3Queue.size,
|
|
884
|
-
l1Pending: this.l1Queue.pending,
|
|
885
|
-
l2Pending: this.l2Queue.pending,
|
|
886
|
-
l3Pending: this.l3Queue.pending,
|
|
887
|
-
l1Idle: this.l1Queue.idle,
|
|
888
|
-
l2Idle: this.l2Queue.idle,
|
|
889
|
-
l3Idle: this.l3Queue.idle,
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MemoryPipelineManager: manages the L0→L1→L2→L3 memory extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* ## Layered architecture
|
|
5
|
+
*
|
|
6
|
+
* - **L0 (capture)**: `auto-capture.ts` extracts new messages from each
|
|
7
|
+
* `agent_end` event, sanitizes them, and passes them to the pipeline via
|
|
8
|
+
* `notifyConversation(sessionKey, messages)`. Messages are buffered
|
|
9
|
+
* locally per-session — NO remote call happens at this stage.
|
|
10
|
+
*
|
|
11
|
+
* - **L1 (batch extraction / ingest)**: When the conversation count reaches
|
|
12
|
+
* `everyNConversations` OR the session goes idle for `l1IdleTimeoutSeconds`,
|
|
13
|
+
* the L1 Runner is invoked with all buffered messages. The runner receives
|
|
14
|
+
* `{ sessionKey, msg, bg_msg }` and is responsible for ingesting/extracting
|
|
15
|
+
* them (e.g. calling appendEvent, or running local extraction logic).
|
|
16
|
+
* `bg_msg` is reserved for background context; currently always empty.
|
|
17
|
+
*
|
|
18
|
+
* - **L2 (scene extraction)**: Per-session downward-only timer. After each
|
|
19
|
+
* L2 completion, the next fire time is set to `now + maxInterval`. When
|
|
20
|
+
* L1 completes (new memory event), the fire time is advanced (but never
|
|
21
|
+
* postponed) to `max(now + delay, lastL2 + minInterval)`. When the timer
|
|
22
|
+
* fires, if the session is cold (inactive > `sessionActiveWindowHours`),
|
|
23
|
+
* the timer is cancelled rather than triggering L2 — it will be re-armed
|
|
24
|
+
* by the next L1 event.
|
|
25
|
+
*
|
|
26
|
+
* - **L3 (persona generation)**: Global mutex (concurrency=1) + pending flag
|
|
27
|
+
* dedup. Triggered after L2 completes.
|
|
28
|
+
*
|
|
29
|
+
* ## Timer semantics
|
|
30
|
+
*
|
|
31
|
+
* L1 uses a **resettable timer** (classic idle/debounce): each conversation
|
|
32
|
+
* resets the countdown to `l1IdleTimeoutSeconds`. When the timer fires,
|
|
33
|
+
* buffered messages are flushed through L1.
|
|
34
|
+
*
|
|
35
|
+
* L2 uses a **downward-only timer**: the scheduled fire time can only be
|
|
36
|
+
* moved earlier, never later. This ensures both the maxInterval guarantee
|
|
37
|
+
* and the delay-after-L1 responsiveness, while minInterval acts as a floor.
|
|
38
|
+
*
|
|
39
|
+
* Both timer types are implemented via `ManagedTimer` to eliminate
|
|
40
|
+
* repetitive clear→set→fire→clean boilerplate.
|
|
41
|
+
*
|
|
42
|
+
* ## Trigger paths for L1
|
|
43
|
+
* A. **Conversation threshold** (primary): when `conversation_count >=
|
|
44
|
+
* effectiveThreshold` in `notifyConversation()`, L1 is triggered
|
|
45
|
+
* immediately with all buffered messages. The effective threshold
|
|
46
|
+
* is influenced by warm-up mode (see below).
|
|
47
|
+
* B. **Idle timeout** (catch-up): when a session goes idle for
|
|
48
|
+
* `l1IdleTimeoutSeconds`, L1 fires with whatever messages have
|
|
49
|
+
* been buffered (below threshold).
|
|
50
|
+
* C. **Shutdown flush**: on graceful shutdown, all pending buffers
|
|
51
|
+
* are flushed through L1 then L2.
|
|
52
|
+
*
|
|
53
|
+
* ## Warm-up mode
|
|
54
|
+
*
|
|
55
|
+
* When `enableWarmup` is true (default), new sessions use an exponentially
|
|
56
|
+
* increasing L1 trigger threshold instead of jumping straight to
|
|
57
|
+
* `everyNConversations`. The sequence is: 1 → 2 → 4 → 8 → ... →
|
|
58
|
+
* everyNConversations. This ensures early conversations are processed
|
|
59
|
+
* quickly (first conversation triggers L1 immediately), while gradually
|
|
60
|
+
* reducing processing frequency as the session matures.
|
|
61
|
+
*
|
|
62
|
+
* The `warmup_threshold` field in PipelineSessionState tracks the current
|
|
63
|
+
* threshold. A value of 0 means warm-up is complete (graduated to
|
|
64
|
+
* steady-state). The threshold doubles after each successful L1 run.
|
|
65
|
+
*
|
|
66
|
+
* ## Trigger paths for L2
|
|
67
|
+
* A. **Delay-after-L1**: L1 completes → timer advanced to
|
|
68
|
+
* `max(now + delay, lastL2 + min)` → fires → enqueue L2.
|
|
69
|
+
* B. **MaxInterval guarantee**: L2 completes → timer set to
|
|
70
|
+
* `now + maxInterval` → fires → enqueue L2 (if session active).
|
|
71
|
+
* C. **Shutdown flush**: all pending L2 timers are flushed.
|
|
72
|
+
*
|
|
73
|
+
* All queues use SerialQueue (concurrency=1) for serial execution.
|
|
74
|
+
*
|
|
75
|
+
* ## Design doc
|
|
76
|
+
* See `docs/08-pipeline-refactor-design.md` for full architecture.
|
|
77
|
+
*/
|
|
78
|
+
import { SessionFilter } from "./session-filter.js";
|
|
79
|
+
import { ManagedTimer } from "./managed-timer.js";
|
|
80
|
+
import { SerialQueue } from "./serial-queue.js";
|
|
81
|
+
import { report } from "../core/report/reporter.js";
|
|
82
|
+
const TAG = "[memory-tdai] [pipeline]";
|
|
83
|
+
export class MemoryPipelineManager {
|
|
84
|
+
// Config (converted to ms internally)
|
|
85
|
+
l1IdleTimeoutMs;
|
|
86
|
+
everyNConversations;
|
|
87
|
+
enableWarmup;
|
|
88
|
+
l2DelayAfterL1Ms;
|
|
89
|
+
l2MinIntervalMs;
|
|
90
|
+
l2MaxIntervalMs;
|
|
91
|
+
sessionActiveWindowMs;
|
|
92
|
+
/** Delay before retrying a failed L1 (ms). */
|
|
93
|
+
L1_RETRY_DELAY_MS = 30_000; // 30 seconds
|
|
94
|
+
/** Max consecutive L1 retries per session before giving up. */
|
|
95
|
+
L1_MAX_RETRIES = 5;
|
|
96
|
+
// Queues (named for diagnostics)
|
|
97
|
+
l1Queue = new SerialQueue("L1");
|
|
98
|
+
l2Queue = new SerialQueue("L2");
|
|
99
|
+
l3Queue = new SerialQueue("L3");
|
|
100
|
+
// L3 dedup flag
|
|
101
|
+
l3Pending = false;
|
|
102
|
+
l3Running = false;
|
|
103
|
+
// Per-session state
|
|
104
|
+
sessionStates = new Map();
|
|
105
|
+
sessionTimers = new Map();
|
|
106
|
+
// Per-session message buffer: messages accumulated since last L1 run
|
|
107
|
+
messageBuffers = new Map();
|
|
108
|
+
// Per-session L2 last run time (epoch ms, for minInterval floor)
|
|
109
|
+
l2LastRunTime = new Map();
|
|
110
|
+
// Callbacks
|
|
111
|
+
l1Runner = null;
|
|
112
|
+
l2Runner = null;
|
|
113
|
+
l3Runner = null;
|
|
114
|
+
persister = null;
|
|
115
|
+
logger;
|
|
116
|
+
// Unified session filter (internal sessions + excludeAgents)
|
|
117
|
+
sessionFilter;
|
|
118
|
+
// Lifecycle
|
|
119
|
+
destroyed = false;
|
|
120
|
+
/** Plugin instance ID for metric reporting (set externally after async init). */
|
|
121
|
+
instanceId;
|
|
122
|
+
// Session GC: runs periodically to evict cold sessions from memory
|
|
123
|
+
/** Multiplier on sessionActiveWindowMs to determine GC eligibility. */
|
|
124
|
+
SESSION_GC_INACTIVE_MULTIPLIER = 3;
|
|
125
|
+
/** Run GC every N calls to notifyConversation. */
|
|
126
|
+
SESSION_GC_EVERY_N_NOTIFICATIONS = 50;
|
|
127
|
+
/** Counter for GC scheduling. */
|
|
128
|
+
notifyCounter = 0;
|
|
129
|
+
constructor(config, logger, sessionFilter) {
|
|
130
|
+
this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000;
|
|
131
|
+
this.everyNConversations = config.everyNConversations;
|
|
132
|
+
this.enableWarmup = config.enableWarmup;
|
|
133
|
+
this.l2DelayAfterL1Ms = config.l2.delayAfterL1Seconds * 1000;
|
|
134
|
+
this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000;
|
|
135
|
+
this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000;
|
|
136
|
+
this.sessionActiveWindowMs = config.l2.sessionActiveWindowHours * 60 * 60 * 1000;
|
|
137
|
+
this.logger = logger;
|
|
138
|
+
this.sessionFilter = sessionFilter ?? new SessionFilter();
|
|
139
|
+
this.logger?.debug?.(`${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` +
|
|
140
|
+
`warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` +
|
|
141
|
+
`l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` +
|
|
142
|
+
`l2DelayAfterL1=${config.l2.delayAfterL1Seconds}s, ` +
|
|
143
|
+
`l2MinInterval=${config.l2.minIntervalSeconds}s, ` +
|
|
144
|
+
`l2MaxInterval=${config.l2.maxIntervalSeconds}s, ` +
|
|
145
|
+
`sessionActiveWindow=${config.l2.sessionActiveWindowHours}h`);
|
|
146
|
+
// Wire up queue debug logging
|
|
147
|
+
if (this.logger?.debug) {
|
|
148
|
+
const debugFn = (msg) => this.logger?.debug?.(`${TAG} ${msg}`);
|
|
149
|
+
this.l1Queue.setDebugLogger(debugFn);
|
|
150
|
+
this.l2Queue.setDebugLogger(debugFn);
|
|
151
|
+
this.l3Queue.setDebugLogger(debugFn);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ============================
|
|
155
|
+
// Setup
|
|
156
|
+
// ============================
|
|
157
|
+
setL1Runner(runner) {
|
|
158
|
+
this.l1Runner = runner;
|
|
159
|
+
}
|
|
160
|
+
setL2Runner(runner) {
|
|
161
|
+
this.l2Runner = runner;
|
|
162
|
+
}
|
|
163
|
+
setL3Runner(runner) {
|
|
164
|
+
this.l3Runner = runner;
|
|
165
|
+
}
|
|
166
|
+
setPersister(persister) {
|
|
167
|
+
this.persister = persister;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Restore session states from checkpoint and start the pipeline.
|
|
171
|
+
* Sessions with pending counts will be immediately re-enqueued.
|
|
172
|
+
*/
|
|
173
|
+
start(restoredStates) {
|
|
174
|
+
if (this.destroyed)
|
|
175
|
+
return;
|
|
176
|
+
if (restoredStates) {
|
|
177
|
+
let skipped = 0;
|
|
178
|
+
for (const [sessionKey, state] of Object.entries(restoredStates)) {
|
|
179
|
+
if (this.sessionFilter.shouldSkip(sessionKey)) {
|
|
180
|
+
skipped++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Backfill warmup_threshold for sessions persisted before warm-up feature.
|
|
184
|
+
// Missing field → treat as graduated (warmup already complete).
|
|
185
|
+
const patched = { ...state };
|
|
186
|
+
if (patched.warmup_threshold == null) {
|
|
187
|
+
patched.warmup_threshold = 0;
|
|
188
|
+
}
|
|
189
|
+
this.sessionStates.set(sessionKey, patched);
|
|
190
|
+
}
|
|
191
|
+
this.logger?.info(`${TAG} Restored ${this.sessionStates.size} session state(s) from checkpoint` +
|
|
192
|
+
(skipped > 0 ? ` (filtered ${skipped} internal)` : ""));
|
|
193
|
+
}
|
|
194
|
+
// Recovery: re-enqueue sessions with pending work
|
|
195
|
+
this.recoverPendingSessions();
|
|
196
|
+
this.logger?.info(`${TAG} Pipeline started`);
|
|
197
|
+
}
|
|
198
|
+
// ============================
|
|
199
|
+
// L0→L1: Notify (called from auto-capture on agent_end)
|
|
200
|
+
// ============================
|
|
201
|
+
/**
|
|
202
|
+
* Get the effective conversation threshold for a session, considering warm-up.
|
|
203
|
+
*
|
|
204
|
+
* When warm-up is enabled, new sessions start with threshold=1 and double
|
|
205
|
+
* after each successful L1 run: 1 → 2 → 4 → 8 → ... → everyNConversations.
|
|
206
|
+
* Once the threshold reaches everyNConversations, warm-up is considered complete
|
|
207
|
+
* (warmup_threshold is set to 0) and the fixed config value is used.
|
|
208
|
+
*/
|
|
209
|
+
getEffectiveThreshold(state) {
|
|
210
|
+
if (!this.enableWarmup)
|
|
211
|
+
return this.everyNConversations;
|
|
212
|
+
// warmup_threshold === 0 means warm-up completed; use steady-state config
|
|
213
|
+
if (state.warmup_threshold <= 0)
|
|
214
|
+
return this.everyNConversations;
|
|
215
|
+
return Math.min(state.warmup_threshold, this.everyNConversations);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Advance the warm-up threshold for a session after a successful L1 run.
|
|
219
|
+
* Doubles the threshold until it reaches everyNConversations, then marks
|
|
220
|
+
* warm-up as complete (warmup_threshold = 0).
|
|
221
|
+
*/
|
|
222
|
+
advanceWarmupThreshold(state) {
|
|
223
|
+
if (!this.enableWarmup)
|
|
224
|
+
return;
|
|
225
|
+
if (state.warmup_threshold <= 0)
|
|
226
|
+
return; // already graduated
|
|
227
|
+
const next = state.warmup_threshold * 2;
|
|
228
|
+
if (next >= this.everyNConversations) {
|
|
229
|
+
// Graduated: switch to steady-state
|
|
230
|
+
state.warmup_threshold = 0;
|
|
231
|
+
this.logger?.debug?.(`${TAG} Warm-up graduated → using steady-state threshold ${this.everyNConversations}`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
state.warmup_threshold = next;
|
|
235
|
+
this.logger?.debug?.(`${TAG} Warm-up advanced → next threshold ${next}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Notify the pipeline that a conversation round has ended for a session,
|
|
240
|
+
* and buffer the captured messages for L1 batch processing.
|
|
241
|
+
*
|
|
242
|
+
* Two trigger paths start here:
|
|
243
|
+
* - **Path A (threshold)**: if conversation_count >= effective threshold
|
|
244
|
+
* (warm-up or steady-state), trigger L1 immediately with all buffered messages.
|
|
245
|
+
* - **Path B (idle)**: reset the L1 idle timer. When the timer fires (user
|
|
246
|
+
* stops chatting), L1 runs with whatever has been buffered.
|
|
247
|
+
*/
|
|
248
|
+
async notifyConversation(sessionKey, messages) {
|
|
249
|
+
if (this.destroyed)
|
|
250
|
+
return;
|
|
251
|
+
if (this.sessionFilter.shouldSkip(sessionKey))
|
|
252
|
+
return;
|
|
253
|
+
const state = this.getOrCreateState(sessionKey);
|
|
254
|
+
state.conversation_count += 1;
|
|
255
|
+
state.last_active_time = Date.now();
|
|
256
|
+
// Reset L1 retry count on new conversation (environment may have recovered)
|
|
257
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
258
|
+
timers.l1RetryCount = 0;
|
|
259
|
+
// Buffer messages for L1
|
|
260
|
+
const buffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
261
|
+
buffer.push(...messages);
|
|
262
|
+
this.messageBuffers.set(sessionKey, buffer);
|
|
263
|
+
const effectiveThreshold = this.getEffectiveThreshold(state);
|
|
264
|
+
const warmupInfo = this.enableWarmup && state.warmup_threshold > 0
|
|
265
|
+
? ` (warmup: ${state.warmup_threshold})`
|
|
266
|
+
: "";
|
|
267
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] notify: conversation_count=${state.conversation_count}/${effectiveThreshold}${warmupInfo}, ` +
|
|
268
|
+
`buffered_messages=${buffer.length} (+${messages.length} new)`);
|
|
269
|
+
await this.persistStates();
|
|
270
|
+
// Path A: conversation count reached effective threshold → trigger L1 batch
|
|
271
|
+
if (state.conversation_count >= effectiveThreshold) {
|
|
272
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Conversation threshold reached (${state.conversation_count}>=${effectiveThreshold}${warmupInfo}), triggering L1`);
|
|
273
|
+
this.enqueueL1(sessionKey);
|
|
274
|
+
return; // skip idle timer reset — L1 is already triggered
|
|
275
|
+
}
|
|
276
|
+
// Path B: below threshold → reset L1 idle timer (catch residual later)
|
|
277
|
+
timers.l1Idle.schedule(this.l1IdleTimeoutMs, () => this.onL1IdleTimeout(sessionKey));
|
|
278
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timer reset (${this.l1IdleTimeoutMs / 1000}s)`);
|
|
279
|
+
// Periodic GC: evict cold sessions from memory
|
|
280
|
+
this.notifyCounter += 1;
|
|
281
|
+
if (this.notifyCounter >= this.SESSION_GC_EVERY_N_NOTIFICATIONS) {
|
|
282
|
+
this.notifyCounter = 0;
|
|
283
|
+
this.gcStaleSessions();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ============================
|
|
287
|
+
// Graceful shutdown
|
|
288
|
+
// ============================
|
|
289
|
+
/**
|
|
290
|
+
* Per-session flush — scoped end-of-session handling.
|
|
291
|
+
*
|
|
292
|
+
* Semantically different from {@link destroy}:
|
|
293
|
+
* - ``destroy`` tears down the *whole* scheduler (meant for process
|
|
294
|
+
* shutdown such as OpenClaw's ``gateway_stop``).
|
|
295
|
+
* - ``flushSession`` only processes the one session identified by
|
|
296
|
+
* ``sessionKey`` and leaves every other session's timers, buffers
|
|
297
|
+
* and pipeline state untouched. This is the correct semantic for
|
|
298
|
+
* the Gateway's ``POST /session/end`` endpoint and for Hermes'
|
|
299
|
+
* ``on_session_end`` callback, which fire when one conversation
|
|
300
|
+
* ends while the process keeps serving other concurrent sessions.
|
|
301
|
+
*
|
|
302
|
+
* What it does:
|
|
303
|
+
* 1. Cancel the session's pending L1 idle timer (no further idle
|
|
304
|
+
* fires for this key).
|
|
305
|
+
* 2. If the session's message buffer still holds work, enqueue an
|
|
306
|
+
* immediate L1 run for this session (``triggerReason="flush"``).
|
|
307
|
+
* 3. Await the shared ``l1Queue`` so the caller observes L1
|
|
308
|
+
* completion before returning. We do not selectively wait
|
|
309
|
+
* because L1 is already a single-consumer SerialQueue — waiting
|
|
310
|
+
* for ``onIdle`` is the cheapest correct signal.
|
|
311
|
+
*
|
|
312
|
+
* What it deliberately does NOT do:
|
|
313
|
+
* - Touch other sessions' timers / buffers / pipeline state.
|
|
314
|
+
* - Destroy the scheduler or any of its queues.
|
|
315
|
+
* - Reset global fields such as ``destroyed``.
|
|
316
|
+
*
|
|
317
|
+
* Unknown session keys are a no-op: the scheduler may legitimately
|
|
318
|
+
* have evicted the session earlier via GC, or the session may never
|
|
319
|
+
* have produced any captures.
|
|
320
|
+
*/
|
|
321
|
+
async flushSession(sessionKey) {
|
|
322
|
+
if (this.destroyed)
|
|
323
|
+
return;
|
|
324
|
+
if (this.sessionFilter.shouldSkip(sessionKey))
|
|
325
|
+
return;
|
|
326
|
+
const timers = this.sessionTimers.get(sessionKey);
|
|
327
|
+
const buffer = this.messageBuffers.get(sessionKey);
|
|
328
|
+
// Step 1: cancel the idle timer so it won't fire after we return.
|
|
329
|
+
if (timers?.l1Idle.pending) {
|
|
330
|
+
timers.l1Idle.cancel();
|
|
331
|
+
}
|
|
332
|
+
// Step 2: flush pending buffered messages through L1 if any.
|
|
333
|
+
if (buffer && buffer.length > 0) {
|
|
334
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: enqueuing L1 for ${buffer.length} buffered message(s)`);
|
|
335
|
+
this.enqueueL1(sessionKey, "flush");
|
|
336
|
+
}
|
|
337
|
+
// Step 3: wait for L1 to drain. L1 is a single-consumer SerialQueue
|
|
338
|
+
// so this is the cheapest correct signal; it will not starve other
|
|
339
|
+
// sessions because any cross-session interleaving L1 work was either
|
|
340
|
+
// already queued or will be queued concurrently by their own capture
|
|
341
|
+
// paths.
|
|
342
|
+
await this.l1Queue.onIdle();
|
|
343
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: complete`);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Maximum time (ms) to wait for pipeline flush during destroy.
|
|
347
|
+
* Must be shorter than the gateway_stop hook timeout (3 s) to leave
|
|
348
|
+
* headroom for VectorStore / EmbeddingService cleanup that runs after.
|
|
349
|
+
*/
|
|
350
|
+
DESTROY_TIMEOUT_MS = 2_000;
|
|
351
|
+
/**
|
|
352
|
+
* Graceful shutdown with timeout protection:
|
|
353
|
+
* 1. Mark destroyed, stop accepting new work
|
|
354
|
+
* 2. Attempt to flush pending L1/L2/L3 work within DESTROY_TIMEOUT_MS
|
|
355
|
+
* 3. If flush times out or fails, persist current state for recovery on next startup
|
|
356
|
+
* 4. Pending work is never lost — it will be recovered via checkpoint on next start()
|
|
357
|
+
*/
|
|
358
|
+
async destroy() {
|
|
359
|
+
if (this.destroyed)
|
|
360
|
+
return;
|
|
361
|
+
this.destroyed = true;
|
|
362
|
+
this.logger?.info(`${TAG} Destroying pipeline (timeout=${this.DESTROY_TIMEOUT_MS}ms)...`);
|
|
363
|
+
try {
|
|
364
|
+
let timeoutId;
|
|
365
|
+
await Promise.race([
|
|
366
|
+
this._doFlush(),
|
|
367
|
+
new Promise((_, reject) => {
|
|
368
|
+
timeoutId = setTimeout(() => reject(new Error("destroy timeout")), this.DESTROY_TIMEOUT_MS);
|
|
369
|
+
}),
|
|
370
|
+
]).finally(() => {
|
|
371
|
+
if (timeoutId !== undefined)
|
|
372
|
+
clearTimeout(timeoutId);
|
|
373
|
+
});
|
|
374
|
+
this.logger?.info(`${TAG} Pipeline flushed successfully`);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
this.logger?.warn(`${TAG} Pipeline flush timed out or failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
378
|
+
`Pending work will be recovered on next startup.`);
|
|
379
|
+
}
|
|
380
|
+
// Always persist state — whether flush succeeded, timed out, or failed.
|
|
381
|
+
// This ensures pending work (buffered messages, L2 pending counts) is
|
|
382
|
+
// saved to checkpoint and can be recovered by recoverPendingSessions().
|
|
383
|
+
try {
|
|
384
|
+
await this.persistStates();
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
this.logger?.error(`${TAG} Failed to persist states during destroy: ${err instanceof Error ? err.message : String(err)}`);
|
|
388
|
+
}
|
|
389
|
+
this.logger?.info(`${TAG} Pipeline destroyed`);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Internal: attempt to flush all pending pipeline work (L1 → L2 → L3).
|
|
393
|
+
* Extracted from destroy() so it can be wrapped with a timeout.
|
|
394
|
+
*/
|
|
395
|
+
async _doFlush() {
|
|
396
|
+
// Step 1: Flush all L1 idle timers — only enqueue if there are buffered messages
|
|
397
|
+
for (const [sessionKey, timers] of this.sessionTimers) {
|
|
398
|
+
if (timers.l1Idle.pending) {
|
|
399
|
+
timers.l1Idle.cancel(); // don't fire the idle callback directly
|
|
400
|
+
const buffer = this.messageBuffers.get(sessionKey);
|
|
401
|
+
if (buffer && buffer.length > 0) {
|
|
402
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: enqueuing L1 for ${buffer.length} buffered messages`);
|
|
403
|
+
this.enqueueL1(sessionKey, "flush");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Step 2: Wait for L1 queue to drain
|
|
408
|
+
this.logger?.debug?.(`${TAG} Waiting for L1 queue to drain (size=${this.l1Queue.size})`);
|
|
409
|
+
await this.l1Queue.onIdle();
|
|
410
|
+
// Step 3: Flush all L2 schedule timers
|
|
411
|
+
for (const [sessionKey, timers] of this.sessionTimers) {
|
|
412
|
+
if (timers.l2Schedule.pending) {
|
|
413
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: triggering L2 schedule timer`);
|
|
414
|
+
timers.l2Schedule.flush();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Step 4: Wait for all remaining queues to drain
|
|
418
|
+
this.logger?.debug?.(`${TAG} Waiting for queues to drain (l2=${this.l2Queue.size}, l3=${this.l3Queue.size})`);
|
|
419
|
+
await Promise.all([
|
|
420
|
+
this.l2Queue.onIdle(),
|
|
421
|
+
this.l3Queue.onIdle(),
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
// ============================
|
|
425
|
+
// Internal: L1 idle timeout handler
|
|
426
|
+
// ============================
|
|
427
|
+
onL1IdleTimeout(sessionKey) {
|
|
428
|
+
const buffer = this.messageBuffers.get(sessionKey);
|
|
429
|
+
const state = this.sessionStates.get(sessionKey);
|
|
430
|
+
if ((!buffer || buffer.length === 0) && (!state || state.conversation_count === 0)) {
|
|
431
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout but no pending messages or conversations`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout fired (buffered=${buffer?.length ?? 0}, conversations=${state?.conversation_count ?? 0})`);
|
|
435
|
+
this.enqueueL1(sessionKey, "idle_timeout");
|
|
436
|
+
}
|
|
437
|
+
// ============================
|
|
438
|
+
// Internal: L1 queue
|
|
439
|
+
// ============================
|
|
440
|
+
enqueueL1(sessionKey, triggerReason = "threshold") {
|
|
441
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
442
|
+
// Don't double-queue
|
|
443
|
+
if (timers.l1Queued) {
|
|
444
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 already queued, skipping`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Cancel idle timer if running (threshold beat it)
|
|
448
|
+
timers.l1Idle.cancel();
|
|
449
|
+
timers.l1Queued = true;
|
|
450
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L1 (queue=${this.l1Queue.name})`);
|
|
451
|
+
// ── pipeline_l1_trigger metric ──
|
|
452
|
+
const state = this.sessionStates.get(sessionKey);
|
|
453
|
+
const buffer = this.messageBuffers.get(sessionKey);
|
|
454
|
+
if (this.instanceId && this.logger) {
|
|
455
|
+
report("pipeline_l1_trigger", {
|
|
456
|
+
sessionKey,
|
|
457
|
+
triggerReason,
|
|
458
|
+
conversationCount: state?.conversation_count ?? 0,
|
|
459
|
+
bufferedMessageCount: buffer?.length ?? 0,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
this.l1Queue.add(async () => {
|
|
463
|
+
await this.runL1(sessionKey);
|
|
464
|
+
}).catch((err) => {
|
|
465
|
+
this.logger?.error(`${TAG} [${sessionKey}] L1 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
466
|
+
}).finally(() => {
|
|
467
|
+
timers.l1Queued = false;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* L1 runner: Takes all buffered messages for a session and passes them
|
|
472
|
+
* to the L1Runner for batch processing (e.g. appendEvent, local extraction).
|
|
473
|
+
*
|
|
474
|
+
* After L1 completes successfully:
|
|
475
|
+
* - conversation_count and message buffer are reset
|
|
476
|
+
* - L2 timer is advanced (downward-only) to allow remote record generation
|
|
477
|
+
*
|
|
478
|
+
* If L1 fails, conversation_count and buffer are preserved for retry
|
|
479
|
+
* on next idle timeout or threshold trigger.
|
|
480
|
+
*/
|
|
481
|
+
async runL1(sessionKey) {
|
|
482
|
+
const state = this.sessionStates.get(sessionKey);
|
|
483
|
+
if (!state)
|
|
484
|
+
return;
|
|
485
|
+
// Drain the message buffer (take ownership, clear the shared ref)
|
|
486
|
+
const buffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
487
|
+
this.messageBuffers.set(sessionKey, []);
|
|
488
|
+
if (buffer.length === 0 && state.conversation_count === 0) {
|
|
489
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 skipped: no messages and no pending conversations`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 running: messages=${buffer.length}, conversation_count=${state.conversation_count}`);
|
|
493
|
+
if (!this.l1Runner) {
|
|
494
|
+
this.logger?.warn(`${TAG} [${sessionKey}] No L1 runner set, skipping`);
|
|
495
|
+
state.l2_pending_l1_count = state.conversation_count;
|
|
496
|
+
state.conversation_count = 0;
|
|
497
|
+
this.advanceWarmupThreshold(state);
|
|
498
|
+
await this.persistStates();
|
|
499
|
+
this.advanceL2Timer(sessionKey);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
await this.l1Runner({
|
|
504
|
+
sessionKey,
|
|
505
|
+
msg: buffer,
|
|
506
|
+
bg_msg: [], // reserved for future use
|
|
507
|
+
});
|
|
508
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 complete: processed ${buffer.length} messages`);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
this.logger?.error(`${TAG} [${sessionKey}] L1 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
512
|
+
// On failure: put messages back into the buffer for retry
|
|
513
|
+
const currentBuffer = this.messageBuffers.get(sessionKey) ?? [];
|
|
514
|
+
this.messageBuffers.set(sessionKey, [...buffer, ...currentBuffer]);
|
|
515
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 failure: restored ${buffer.length} messages to buffer (total=${buffer.length + currentBuffer.length})`);
|
|
516
|
+
// Re-arm L1 idle timer for automatic retry (with max retry limit)
|
|
517
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
518
|
+
timers.l1RetryCount += 1;
|
|
519
|
+
if (timers.l1RetryCount <= this.L1_MAX_RETRIES) {
|
|
520
|
+
timers.l1Idle.schedule(this.L1_RETRY_DELAY_MS, () => this.onL1IdleTimeout(sessionKey));
|
|
521
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L1 retry scheduled in ${this.L1_RETRY_DELAY_MS / 1000}s ` +
|
|
522
|
+
`(attempt ${timers.l1RetryCount}/${this.L1_MAX_RETRIES})`);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
this.logger?.warn(`${TAG} [${sessionKey}] L1 max retries reached (${this.L1_MAX_RETRIES}), ` +
|
|
526
|
+
`giving up auto-retry. ${buffer.length + currentBuffer.length} messages remain buffered. ` +
|
|
527
|
+
`Will resume on next user conversation.`);
|
|
528
|
+
}
|
|
529
|
+
return; // don't advance state or trigger L2
|
|
530
|
+
}
|
|
531
|
+
// Success: reset retry count and advance state
|
|
532
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
533
|
+
timers.l1RetryCount = 0;
|
|
534
|
+
state.l2_pending_l1_count = state.conversation_count;
|
|
535
|
+
state.conversation_count = 0;
|
|
536
|
+
this.advanceWarmupThreshold(state);
|
|
537
|
+
await this.persistStates();
|
|
538
|
+
// Advance the L2 timer (downward-only) to fire after delay, respecting minInterval
|
|
539
|
+
this.advanceL2Timer(sessionKey);
|
|
540
|
+
}
|
|
541
|
+
// ============================
|
|
542
|
+
// Internal: L2 timer management (downward-only)
|
|
543
|
+
// ============================
|
|
544
|
+
/**
|
|
545
|
+
* Advance the per-session L2 timer after an L1 event (new memory generated).
|
|
546
|
+
*
|
|
547
|
+
* Computes the desired fire time as:
|
|
548
|
+
* T_desired = max(now + l2DelayAfterL1, lastL2Time + l2MinInterval)
|
|
549
|
+
*
|
|
550
|
+
* The timer is only moved if T_desired is earlier than the current schedule
|
|
551
|
+
* (downward-only semantics). If no timer is pending, it's set unconditionally.
|
|
552
|
+
*/
|
|
553
|
+
advanceL2Timer(sessionKey) {
|
|
554
|
+
if (this.destroyed)
|
|
555
|
+
return;
|
|
556
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
557
|
+
const now = Date.now();
|
|
558
|
+
// Compute the floor: lastL2 + minInterval (rate-limit protection)
|
|
559
|
+
const lastL2 = this.l2LastRunTime.get(sessionKey) ?? 0;
|
|
560
|
+
const minIntervalFloor = lastL2 > 0 ? lastL2 + this.l2MinIntervalMs : 0;
|
|
561
|
+
// Desired fire time: delay after L1, but no earlier than minInterval floor
|
|
562
|
+
const desiredTime = Math.max(now + this.l2DelayAfterL1Ms, minIntervalFloor);
|
|
563
|
+
const advanced = timers.l2Schedule.tryAdvanceTo(desiredTime, () => this.onL2TimerFired(sessionKey, "delay-after-l1"));
|
|
564
|
+
if (advanced) {
|
|
565
|
+
const delaySec = Math.round((desiredTime - now) / 1000);
|
|
566
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer advanced: firing in ${delaySec}s` +
|
|
567
|
+
(timers.l2Schedule.scheduledTime > 0
|
|
568
|
+
? ` (was ${Math.round((timers.l2Schedule.scheduledTime - now) / 1000)}s)`
|
|
569
|
+
: " (newly armed)"));
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer not advanced: current schedule is already earlier`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Arm the L2 timer for the maxInterval guarantee after L2 completes.
|
|
577
|
+
* Sets T = now + l2MaxInterval (unconditional, replaces any pending timer).
|
|
578
|
+
*/
|
|
579
|
+
armL2MaxInterval(sessionKey) {
|
|
580
|
+
if (this.destroyed)
|
|
581
|
+
return;
|
|
582
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
583
|
+
const fireAt = Date.now() + this.l2MaxIntervalMs;
|
|
584
|
+
timers.l2Schedule.scheduleAt(fireAt, () => this.onL2TimerFired(sessionKey, "max-interval"));
|
|
585
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 maxInterval timer armed: ${Math.round(this.l2MaxIntervalMs / 1000)}s`);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Called when a per-session L2 timer fires.
|
|
589
|
+
*
|
|
590
|
+
* Checks session activity: if the session is cold (inactive > activeWindow),
|
|
591
|
+
* the timer is NOT re-armed — it will be revived by the next L1 event.
|
|
592
|
+
* Otherwise, enqueues L2.
|
|
593
|
+
*
|
|
594
|
+
* The `source` parameter distinguishes the trigger origin:
|
|
595
|
+
* - "delay-after-l1": fired shortly after L1 completed — skip cold check
|
|
596
|
+
* because L1 completion itself proves recent activity.
|
|
597
|
+
* - "max-interval": periodic timer — apply cold check normally.
|
|
598
|
+
*/
|
|
599
|
+
onL2TimerFired(sessionKey, source) {
|
|
600
|
+
const state = this.sessionStates.get(sessionKey);
|
|
601
|
+
if (!state)
|
|
602
|
+
return;
|
|
603
|
+
const now = Date.now();
|
|
604
|
+
// Cold session check: only applies to periodic (maxInterval) triggers.
|
|
605
|
+
// Delay-after-L1 triggers are exempt because L1 just completed, proving
|
|
606
|
+
// the session was recently active.
|
|
607
|
+
if (source === "max-interval" && now - state.last_active_time >= this.sessionActiveWindowMs) {
|
|
608
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer fired but session is cold ` +
|
|
609
|
+
`(inactive ${Math.round((now - state.last_active_time) / 3600_000)}h), timer stopped. ` +
|
|
610
|
+
`Will re-arm on next L1 event.`);
|
|
611
|
+
return; // timer not re-armed — advanceL2Timer() in runL1 will revive it
|
|
612
|
+
}
|
|
613
|
+
this.enqueueL2(sessionKey, `timer:${source}`);
|
|
614
|
+
}
|
|
615
|
+
// ============================
|
|
616
|
+
// Internal: L2 queue
|
|
617
|
+
// ============================
|
|
618
|
+
enqueueL2(sessionKey, trigger) {
|
|
619
|
+
const timers = this.getOrCreateTimers(sessionKey);
|
|
620
|
+
// Cancel any pending L2 timer (we're about to run L2)
|
|
621
|
+
timers.l2Schedule.cancel();
|
|
622
|
+
// Conflict detection: warn if L2 is already queued
|
|
623
|
+
if (timers.l2Queued) {
|
|
624
|
+
this.logger?.warn(`${TAG} [${sessionKey}] L2 enqueue conflict on queue "${this.l2Queue.name}": ` +
|
|
625
|
+
`task already queued/running (trigger=${trigger}), skipping`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
timers.l2Queued = true;
|
|
629
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L2 (trigger=${trigger}, queue=${this.l2Queue.name})`);
|
|
630
|
+
this.l2Queue.add(async () => {
|
|
631
|
+
await this.runL2(sessionKey);
|
|
632
|
+
}).catch((err) => {
|
|
633
|
+
this.logger?.error(`${TAG} [${sessionKey}] L2 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
634
|
+
}).finally(() => {
|
|
635
|
+
timers.l2Queued = false;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
async runL2(sessionKey) {
|
|
639
|
+
const state = this.sessionStates.get(sessionKey);
|
|
640
|
+
if (!state)
|
|
641
|
+
return;
|
|
642
|
+
if (!this.l2Runner) {
|
|
643
|
+
this.logger?.warn(`${TAG} [${sessionKey}] No L2 runner set, skipping`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 running: l2_pending_l1_count=${state.l2_pending_l1_count}`);
|
|
647
|
+
const cursor = state.last_extraction_updated_time || undefined;
|
|
648
|
+
let result;
|
|
649
|
+
try {
|
|
650
|
+
result = await this.l2Runner(sessionKey, cursor);
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
this.logger?.error(`${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
654
|
+
// Even on failure, arm maxInterval so we retry eventually
|
|
655
|
+
this.armL2MaxInterval(sessionKey);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// After L2: update state
|
|
659
|
+
const now = Date.now();
|
|
660
|
+
state.l2_pending_l1_count = 0;
|
|
661
|
+
// Cold-start optimization: if this is the very first L2 run for this session
|
|
662
|
+
// and it was skipped (no new records), do NOT update l2LastRunTime.
|
|
663
|
+
// This prevents l2MinIntervalSeconds from blocking the next L2 trigger
|
|
664
|
+
// when the first L1 extraction produces actual memories shortly after.
|
|
665
|
+
const isFirstL2 = !this.l2LastRunTime.has(sessionKey);
|
|
666
|
+
const wasSkipped = result?.skipped === true;
|
|
667
|
+
if (isFirstL2 && wasSkipped) {
|
|
668
|
+
this.logger?.info?.(`${TAG} [${sessionKey}] L2 cold-start skip: not updating l2LastRunTime ` +
|
|
669
|
+
`(minInterval won't block next trigger)`);
|
|
670
|
+
this.armL2MaxInterval(sessionKey);
|
|
671
|
+
await this.persistStates();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
state.last_extraction_time = new Date().toISOString();
|
|
675
|
+
state.l2_last_extraction_time = new Date().toISOString();
|
|
676
|
+
this.l2LastRunTime.set(sessionKey, now);
|
|
677
|
+
// Advance cursor using the record timestamp returned by the runner
|
|
678
|
+
if (result?.latestCursor) {
|
|
679
|
+
state.last_extraction_updated_time = result.latestCursor;
|
|
680
|
+
}
|
|
681
|
+
else if (!state.last_extraction_updated_time) {
|
|
682
|
+
// Cold-start guard: if runner returned void (e.g. extraction failure) and
|
|
683
|
+
// last_extraction_updated_time is still empty, initialize it to now so
|
|
684
|
+
// the next L2 run doesn't do a full table scan.
|
|
685
|
+
state.last_extraction_updated_time = new Date().toISOString();
|
|
686
|
+
}
|
|
687
|
+
await this.persistStates();
|
|
688
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] L2 complete`);
|
|
689
|
+
// Arm the maxInterval timer for the next cycle
|
|
690
|
+
this.armL2MaxInterval(sessionKey);
|
|
691
|
+
// Trigger L3
|
|
692
|
+
this.triggerL3();
|
|
693
|
+
}
|
|
694
|
+
// ============================
|
|
695
|
+
// Internal: L3 queue (global, dedup)
|
|
696
|
+
// ============================
|
|
697
|
+
triggerL3() {
|
|
698
|
+
if (this.destroyed)
|
|
699
|
+
return;
|
|
700
|
+
if (this.l3Running) {
|
|
701
|
+
// L3 is in progress — mark pending so it runs again after current finishes
|
|
702
|
+
this.l3Pending = true;
|
|
703
|
+
this.logger?.debug?.(`${TAG} L3 already running, marking pending`);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
this.logger?.debug?.(`${TAG} Triggering L3`);
|
|
707
|
+
this.enqueueL3();
|
|
708
|
+
}
|
|
709
|
+
enqueueL3() {
|
|
710
|
+
this.l3Running = true;
|
|
711
|
+
this.l3Pending = false;
|
|
712
|
+
this.logger?.debug?.(`${TAG} Enqueuing L3 (queue=${this.l3Queue.name})`);
|
|
713
|
+
this.l3Queue.add(async () => {
|
|
714
|
+
await this.runL3();
|
|
715
|
+
}).catch((err) => {
|
|
716
|
+
this.logger?.error(`${TAG} L3 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
717
|
+
}).finally(() => {
|
|
718
|
+
this.l3Running = false;
|
|
719
|
+
// If new L2 completions happened while L3 was running, run again
|
|
720
|
+
if (this.l3Pending && !this.destroyed) {
|
|
721
|
+
this.logger?.debug?.(`${TAG} L3 has pending work, re-running`);
|
|
722
|
+
this.enqueueL3();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async runL3() {
|
|
727
|
+
if (!this.l3Runner) {
|
|
728
|
+
this.logger?.warn(`${TAG} No L3 runner set, skipping`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
this.logger?.debug?.(`${TAG} L3 running`);
|
|
732
|
+
try {
|
|
733
|
+
await this.l3Runner();
|
|
734
|
+
this.logger?.debug?.(`${TAG} L3 complete`);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
this.logger?.error(`${TAG} L3 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// ============================
|
|
741
|
+
// Internal: state management
|
|
742
|
+
// ============================
|
|
743
|
+
getOrCreateState(sessionKey) {
|
|
744
|
+
let state = this.sessionStates.get(sessionKey);
|
|
745
|
+
if (!state) {
|
|
746
|
+
state = {
|
|
747
|
+
conversation_count: 0,
|
|
748
|
+
last_extraction_time: "",
|
|
749
|
+
last_extraction_updated_time: "",
|
|
750
|
+
last_active_time: Date.now(),
|
|
751
|
+
l2_pending_l1_count: 0,
|
|
752
|
+
warmup_threshold: this.enableWarmup ? 1 : 0,
|
|
753
|
+
l2_last_extraction_time: "",
|
|
754
|
+
};
|
|
755
|
+
this.sessionStates.set(sessionKey, state);
|
|
756
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Created new session state`);
|
|
757
|
+
}
|
|
758
|
+
return state;
|
|
759
|
+
}
|
|
760
|
+
getOrCreateTimers(sessionKey) {
|
|
761
|
+
let timers = this.sessionTimers.get(sessionKey);
|
|
762
|
+
if (!timers) {
|
|
763
|
+
const isDestroyed = () => this.destroyed;
|
|
764
|
+
timers = {
|
|
765
|
+
l1Idle: new ManagedTimer(`L1-idle:${sessionKey}`, isDestroyed),
|
|
766
|
+
l2Schedule: new ManagedTimer(`L2-schedule:${sessionKey}`, isDestroyed),
|
|
767
|
+
l1Queued: false,
|
|
768
|
+
l2Queued: false,
|
|
769
|
+
l1RetryCount: 0,
|
|
770
|
+
};
|
|
771
|
+
this.sessionTimers.set(sessionKey, timers);
|
|
772
|
+
}
|
|
773
|
+
return timers;
|
|
774
|
+
}
|
|
775
|
+
async persistStates() {
|
|
776
|
+
if (!this.persister)
|
|
777
|
+
return;
|
|
778
|
+
// PipelineSessionState only contains pipeline-owned fields, so we can
|
|
779
|
+
// safely persist the entire object without risk of overwriting runner state.
|
|
780
|
+
const obj = {};
|
|
781
|
+
for (const [k, v] of this.sessionStates) {
|
|
782
|
+
obj[k] = { ...v };
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
this.logger?.debug?.(`Persisting states: ${JSON.stringify(obj)}`);
|
|
786
|
+
await this.persister(obj);
|
|
787
|
+
}
|
|
788
|
+
catch (err) {
|
|
789
|
+
this.logger?.error(`${TAG} Failed to persist states: ${err instanceof Error ? err.message : String(err)}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Evict cold sessions from in-memory maps to prevent unbounded growth.
|
|
794
|
+
*
|
|
795
|
+
* A session is eligible for GC when:
|
|
796
|
+
* 1. Inactive for > sessionActiveWindowMs * SESSION_GC_INACTIVE_MULTIPLIER
|
|
797
|
+
* 2. No queued/running L1 or L2 tasks
|
|
798
|
+
* 3. No buffered messages pending processing
|
|
799
|
+
*
|
|
800
|
+
* Evicted sessions can be fully restored from checkpoint on next
|
|
801
|
+
* `notifyConversation()` (state) or `start()` (recovery).
|
|
802
|
+
*/
|
|
803
|
+
gcStaleSessions() {
|
|
804
|
+
const now = Date.now();
|
|
805
|
+
const maxInactiveMs = this.sessionActiveWindowMs * this.SESSION_GC_INACTIVE_MULTIPLIER;
|
|
806
|
+
let evictedCount = 0;
|
|
807
|
+
for (const [sessionKey, state] of this.sessionStates) {
|
|
808
|
+
if (now - state.last_active_time < maxInactiveMs)
|
|
809
|
+
continue;
|
|
810
|
+
// Safety: don't evict sessions with active work
|
|
811
|
+
const timers = this.sessionTimers.get(sessionKey);
|
|
812
|
+
if (timers?.l1Queued || timers?.l2Queued)
|
|
813
|
+
continue;
|
|
814
|
+
const buffer = this.messageBuffers.get(sessionKey);
|
|
815
|
+
if (buffer && buffer.length > 0)
|
|
816
|
+
continue;
|
|
817
|
+
// Evict: cancel any pending timers, then remove from all maps
|
|
818
|
+
if (timers) {
|
|
819
|
+
timers.l1Idle.cancel();
|
|
820
|
+
timers.l2Schedule.cancel();
|
|
821
|
+
}
|
|
822
|
+
this.sessionStates.delete(sessionKey);
|
|
823
|
+
this.sessionTimers.delete(sessionKey);
|
|
824
|
+
this.messageBuffers.delete(sessionKey);
|
|
825
|
+
this.l2LastRunTime.delete(sessionKey);
|
|
826
|
+
evictedCount++;
|
|
827
|
+
}
|
|
828
|
+
if (evictedCount > 0) {
|
|
829
|
+
this.logger?.debug?.(`${TAG} Session GC: evicted ${evictedCount} cold session(s), ` +
|
|
830
|
+
`${this.sessionStates.size} remaining`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Recovery: re-enqueue sessions that have pending work from before restart.
|
|
835
|
+
*
|
|
836
|
+
* On restart, message buffers are empty (in-memory only). Sessions with
|
|
837
|
+
* non-zero conversation_count had messages that were either:
|
|
838
|
+
* 1. Already processed by L1 (l2_pending_l1_count > 0) → arm L2 timer
|
|
839
|
+
* 2. Never reached L1 (conversation_count > 0, messages lost) → arm L2
|
|
840
|
+
* as best-effort recovery
|
|
841
|
+
*
|
|
842
|
+
* We arm L2 timers (with delay) rather than enqueuing immediately,
|
|
843
|
+
* because the pipeline may be starting during management commands.
|
|
844
|
+
*/
|
|
845
|
+
recoverPendingSessions() {
|
|
846
|
+
for (const [sessionKey, state] of this.sessionStates) {
|
|
847
|
+
if (state.conversation_count === 0 && state.l2_pending_l1_count === 0)
|
|
848
|
+
continue;
|
|
849
|
+
this.logger?.debug?.(`${TAG} [${sessionKey}] Recovery: conversation_count=${state.conversation_count}, ` +
|
|
850
|
+
`l2_pending_l1_count=${state.l2_pending_l1_count}, arming L2 timer`);
|
|
851
|
+
// Reset conversation_count since we can't recover the messages
|
|
852
|
+
state.l2_pending_l1_count = Math.max(state.l2_pending_l1_count, state.conversation_count);
|
|
853
|
+
state.conversation_count = 0;
|
|
854
|
+
// Arm L2 timer with delay (gives the system time to fully start)
|
|
855
|
+
this.advanceL2Timer(sessionKey);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// ============================
|
|
859
|
+
// Public accessors (for testing / status)
|
|
860
|
+
// ============================
|
|
861
|
+
/** Get the pipeline session state for a session (read-only copy). */
|
|
862
|
+
getSessionState(sessionKey) {
|
|
863
|
+
const state = this.sessionStates.get(sessionKey);
|
|
864
|
+
return state ? { ...state } : undefined;
|
|
865
|
+
}
|
|
866
|
+
/** Get the buffered message count for a session. */
|
|
867
|
+
getBufferedMessageCount(sessionKey) {
|
|
868
|
+
return this.messageBuffers.get(sessionKey)?.length ?? 0;
|
|
869
|
+
}
|
|
870
|
+
/** Get all session keys being tracked. */
|
|
871
|
+
getSessionKeys() {
|
|
872
|
+
return Array.from(this.sessionStates.keys());
|
|
873
|
+
}
|
|
874
|
+
/** Whether the pipeline has been destroyed. */
|
|
875
|
+
get isDestroyed() {
|
|
876
|
+
return this.destroyed;
|
|
877
|
+
}
|
|
878
|
+
/** Queue sizes and running state for monitoring. */
|
|
879
|
+
getQueueSizes() {
|
|
880
|
+
return {
|
|
881
|
+
l1: this.l1Queue.size,
|
|
882
|
+
l2: this.l2Queue.size,
|
|
883
|
+
l3: this.l3Queue.size,
|
|
884
|
+
l1Pending: this.l1Queue.pending,
|
|
885
|
+
l2Pending: this.l2Queue.pending,
|
|
886
|
+
l3Pending: this.l3Queue.pending,
|
|
887
|
+
l1Idle: this.l1Queue.idle,
|
|
888
|
+
l2Idle: this.l2Queue.idle,
|
|
889
|
+
l3Idle: this.l3Queue.idle,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
}
|