instar 1.3.232 → 1.3.233

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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * CorrectionCaptureBacklog — a bounded, durable retry queue for captures the
3
+ * Correction & Preference Learning Sentinel could NOT distill at capture time
4
+ * because the LLM was rate-limited / capacity-throttled (spec §3.1 extension).
5
+ *
6
+ * THE PROBLEM IT FIXES: the capture→distill hop runs through a rate-limited
7
+ * LlmQueue behind the account-global LLM circuit breaker. Under sustained
8
+ * throttling EVERY distill throws (daily-cap / reserve-breach / aborted /
9
+ * breaker-open) and the old hot path dropped the capture silently — so on a
10
+ * throttled agent the correction ledger stayed permanently EMPTY even though
11
+ * Layer-0 was detecting corrections. This store PERSISTS the (already
12
+ * pre-scrubbed) capture instead of dropping it, so a later headroom window can
13
+ * distill it into the CorrectionLedger.
14
+ *
15
+ * PRIVACY POSTURE (critical — a bounded extension of the ephemeral-ring model):
16
+ * - It persists ONLY the ALREADY-PRE-SCRUBBED turns. The §3.3 deterministic
17
+ * scrub (scrubSecrets) runs in buildDistillPrompt BEFORE anything reaches
18
+ * this store — the caller hands us the post-scrub turn text, never raw user
19
+ * text. We re-scrub defensively on enqueue (belt-and-suspenders), so even a
20
+ * mis-wired caller cannot leak a raw secret to disk.
21
+ * - Bounded retention TWO ways: a max-entries cap (evict oldest on overflow)
22
+ * AND a TTL (pruneExpired discards stale rows). Entries are DELETED the
23
+ * instant they distill (markDistilled) or exhaust their retries (bumpAttempt).
24
+ * - No API route exposes backlog row content. The scrubbed turn text never
25
+ * crosses HTTP — only counts are observable.
26
+ *
27
+ * DISCIPLINE (mirrors CorrectionLedger): SQLite WAL, prune-in-transaction, a
28
+ * detected/age index, fail-open (every method swallows storage errors so a
29
+ * backlog fault can NEVER throw into the fire-and-forget capture seam).
30
+ */
31
+ import Database from 'better-sqlite3';
32
+ import { registerSqliteHandle } from '../core/SqliteRegistry.js';
33
+ import crypto from 'node:crypto';
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { NativeModuleHealer } from '../memory/NativeModuleHealer.js';
37
+ import { scrubSecrets } from './scrubSecrets.js';
38
+ const DEFAULT_MAX_ENTRIES = 200;
39
+ const DEFAULT_MAX_RETRIES = 3;
40
+ const DEFAULT_MIN_RETRY_GAP_MS = 60_000;
41
+ const SCHEMA = [
42
+ `CREATE TABLE IF NOT EXISTS correction_capture_backlog (
43
+ id TEXT PRIMARY KEY,
44
+ topic_id INTEGER NOT NULL,
45
+ scrubbed_turns_json TEXT NOT NULL,
46
+ dedupe_hash TEXT NOT NULL,
47
+ deterministic_weight INTEGER NOT NULL DEFAULT 0,
48
+ session_id TEXT,
49
+ captured_at INTEGER NOT NULL,
50
+ attempts INTEGER NOT NULL DEFAULT 0,
51
+ last_attempt_at INTEGER
52
+ )`,
53
+ // Oldest-first claim/evict ordering + TTL prune scan.
54
+ `CREATE INDEX IF NOT EXISTS idx_ccb_captured ON correction_capture_backlog(captured_at)`,
55
+ // Near-identical dedupe lookup.
56
+ `CREATE INDEX IF NOT EXISTS idx_ccb_dedupe ON correction_capture_backlog(dedupe_hash)`,
57
+ ];
58
+ /**
59
+ * Bounded durable backlog of pre-scrubbed captures awaiting an LLM headroom
60
+ * window. Every method is fail-open — a storage fault returns a safe default and
61
+ * never throws into the capture seam.
62
+ */
63
+ export class CorrectionCaptureBacklog {
64
+ db;
65
+ maxEntries;
66
+ maxRetries;
67
+ minRetryGapMs;
68
+ onError;
69
+ nowFn;
70
+ constructor(opts) {
71
+ this.maxEntries = opts.maxEntries && opts.maxEntries > 0 ? opts.maxEntries : DEFAULT_MAX_ENTRIES;
72
+ this.maxRetries =
73
+ typeof opts.maxRetries === 'number' && opts.maxRetries >= 0 ? opts.maxRetries : DEFAULT_MAX_RETRIES;
74
+ this.minRetryGapMs =
75
+ typeof opts.minRetryGapMs === 'number' && opts.minRetryGapMs >= 0
76
+ ? opts.minRetryGapMs
77
+ : DEFAULT_MIN_RETRY_GAP_MS;
78
+ this.nowFn = opts.now ?? (() => Date.now());
79
+ this.onError =
80
+ opts.onError ?? ((where, err) => console.error(`[CorrectionCaptureBacklog] ${where}:`, err));
81
+ if (opts.dbPath !== ':memory:') {
82
+ fs.mkdirSync(path.dirname(opts.dbPath), { recursive: true });
83
+ }
84
+ this.db = NativeModuleHealer.openWithHealSync('CorrectionCaptureBacklog', () => new Database(opts.dbPath));
85
+ this.db.pragma('journal_mode = WAL');
86
+ this.db.pragma('synchronous = NORMAL');
87
+ for (const ddl of SCHEMA)
88
+ this.db.exec(ddl);
89
+ registerSqliteHandle(() => { try {
90
+ this.db?.close();
91
+ }
92
+ catch { /* already closed */ } });
93
+ }
94
+ /**
95
+ * Re-scrub turns defensively (belt-and-suspenders over the §3.3 pre-scrub) and
96
+ * return ONLY {fromUser, text} — never the raw at-timestamp ring metadata.
97
+ */
98
+ sanitizeTurns(turns) {
99
+ return (Array.isArray(turns) ? turns : []).map((t) => ({
100
+ fromUser: !!t.fromUser,
101
+ text: scrubSecrets(String(t?.text ?? '')).slice(0, 1000),
102
+ }));
103
+ }
104
+ /** A stable hash over (topicId + scrubbed turn text) for near-identical dedupe. */
105
+ dedupeHashOf(topicId, turns) {
106
+ const canon = `${topicId}|` + turns.map((t) => `${t.fromUser ? 'u' : 'a'}:${t.text.trim()}`).join('\n');
107
+ return crypto.createHash('sha256').update(canon).digest('hex');
108
+ }
109
+ /**
110
+ * Persist a pre-scrubbed capture for a later distill. Dedupes against a
111
+ * near-identical existing entry (same topic + same scrubbed turns) — a repeat
112
+ * just bumps the existing row's captured_at forward rather than duplicating.
113
+ * Evicts the oldest entry when at the max-entries cap. Fail-open: returns the
114
+ * stored entry id, or null on any error (caller falls back to the old drop).
115
+ */
116
+ enqueue(input) {
117
+ try {
118
+ if (input.topicId == null)
119
+ return null;
120
+ const turns = this.sanitizeTurns(input.turns);
121
+ if (turns.length === 0)
122
+ return null;
123
+ const dedupeHash = this.dedupeHashOf(input.topicId, turns);
124
+ const capturedAt = input.capturedAt ?? this.nowFn();
125
+ const txn = this.db.transaction(() => {
126
+ // Near-identical dedupe: refresh the existing row's recency instead of
127
+ // inserting a duplicate (so a rapid restate doesn't fill the cap).
128
+ const existing = this.db
129
+ .prepare(`SELECT id FROM correction_capture_backlog WHERE dedupe_hash = ? LIMIT 1`)
130
+ .get(dedupeHash);
131
+ if (existing) {
132
+ this.db
133
+ .prepare(`UPDATE correction_capture_backlog SET captured_at = ? WHERE id = ?`)
134
+ .run(capturedAt, existing.id);
135
+ return existing.id;
136
+ }
137
+ // Evict oldest rows while at/over the cap (leave room for this insert).
138
+ const count = this.db
139
+ .prepare(`SELECT COUNT(*) c FROM correction_capture_backlog`)
140
+ .get().c;
141
+ const overflow = count - (this.maxEntries - 1);
142
+ if (overflow > 0) {
143
+ this.db
144
+ .prepare(`DELETE FROM correction_capture_backlog
145
+ WHERE id IN (
146
+ SELECT id FROM correction_capture_backlog
147
+ ORDER BY captured_at ASC, id ASC LIMIT @overflow)`)
148
+ .run({ overflow });
149
+ }
150
+ const id = `CCB-${crypto.randomBytes(6).toString('hex')}`;
151
+ this.db
152
+ .prepare(`INSERT INTO correction_capture_backlog
153
+ (id, topic_id, scrubbed_turns_json, dedupe_hash, deterministic_weight,
154
+ session_id, captured_at, attempts, last_attempt_at)
155
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL)`)
156
+ .run(id, input.topicId, JSON.stringify(turns), dedupeHash, Math.max(0, Math.round(input.deterministicWeight || 0)), input.sessionId ?? null, capturedAt);
157
+ return id;
158
+ });
159
+ return txn();
160
+ }
161
+ catch (err) {
162
+ this.onError('enqueue', err);
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Claim up to `n` oldest entries eligible for a drain attempt: not exhausted
168
+ * (attempts <= maxRetries) and not attempted within minRetryGapMs. Read-only —
169
+ * does NOT consume the row; the drainer markDistilled()s on success or
170
+ * bumpAttempt()s on failure. Fail-open: returns [] on error.
171
+ */
172
+ claimBatch(n) {
173
+ try {
174
+ const limit = Math.max(0, Math.min(Math.floor(n) || 0, 50));
175
+ if (limit === 0)
176
+ return [];
177
+ const now = this.nowFn();
178
+ const cutoff = now - this.minRetryGapMs;
179
+ const rows = this.db
180
+ .prepare(`SELECT * FROM correction_capture_backlog
181
+ WHERE attempts <= @maxRetries
182
+ AND (last_attempt_at IS NULL OR last_attempt_at <= @cutoff)
183
+ ORDER BY captured_at ASC, id ASC
184
+ LIMIT @limit`)
185
+ .all({ maxRetries: this.maxRetries, cutoff, limit });
186
+ return rows.map((r) => this.rowToEntry(r)).filter((e) => e !== null);
187
+ }
188
+ catch (err) {
189
+ this.onError('claimBatch', err);
190
+ return [];
191
+ }
192
+ }
193
+ /** Delete an entry once it has been distilled into the ledger. Fail-open. */
194
+ markDistilled(id) {
195
+ try {
196
+ this.db.prepare(`DELETE FROM correction_capture_backlog WHERE id = ?`).run(id);
197
+ }
198
+ catch (err) {
199
+ this.onError('markDistilled', err);
200
+ }
201
+ }
202
+ /**
203
+ * Record a failed drain attempt. Increments attempts + stamps last_attempt_at;
204
+ * DROPS the entry once attempts EXCEEDS maxRetries (give-up — bounded, no
205
+ * infinite retry). Returns true if the entry was dropped. Fail-open.
206
+ */
207
+ bumpAttempt(id) {
208
+ try {
209
+ const now = this.nowFn();
210
+ const res = this.db
211
+ .prepare(`UPDATE correction_capture_backlog
212
+ SET attempts = attempts + 1, last_attempt_at = @now
213
+ WHERE id = @id`)
214
+ .run({ id, now });
215
+ if (res.changes === 0)
216
+ return false;
217
+ const row = this.db
218
+ .prepare(`SELECT attempts FROM correction_capture_backlog WHERE id = ?`)
219
+ .get(id);
220
+ if (row && row.attempts > this.maxRetries) {
221
+ this.db.prepare(`DELETE FROM correction_capture_backlog WHERE id = ?`).run(id);
222
+ return true;
223
+ }
224
+ return false;
225
+ }
226
+ catch (err) {
227
+ this.onError('bumpAttempt', err);
228
+ return false;
229
+ }
230
+ }
231
+ /** Discard entries older than ttlMs (prune-in-transaction). Returns rows removed. Fail-open. */
232
+ pruneExpired(ttlMs) {
233
+ try {
234
+ if (!(ttlMs > 0))
235
+ return 0;
236
+ const cutoff = this.nowFn() - ttlMs;
237
+ const res = this.db
238
+ .prepare(`DELETE FROM correction_capture_backlog WHERE captured_at < ?`)
239
+ .run(cutoff);
240
+ return res.changes ?? 0;
241
+ }
242
+ catch (err) {
243
+ this.onError('pruneExpired', err);
244
+ return 0;
245
+ }
246
+ }
247
+ /** Current backlog depth (observability). Fail-open → 0. */
248
+ count() {
249
+ try {
250
+ const row = this.db
251
+ .prepare(`SELECT COUNT(*) c FROM correction_capture_backlog`)
252
+ .get();
253
+ return row?.c ?? 0;
254
+ }
255
+ catch (err) {
256
+ this.onError('count', err);
257
+ return 0;
258
+ }
259
+ }
260
+ close() {
261
+ try {
262
+ this.db.close();
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ rowToEntry(r) {
267
+ try {
268
+ const parsed = JSON.parse(String(r.scrubbed_turns_json ?? '[]'));
269
+ const turns = (Array.isArray(parsed) ? parsed : []).map((t) => ({
270
+ fromUser: !!t.fromUser,
271
+ text: String(t?.text ?? ''),
272
+ at: 0, // ring metadata is not persisted; synthesized for the prompt builder.
273
+ }));
274
+ return {
275
+ id: r.id,
276
+ topicId: r.topic_id,
277
+ turns,
278
+ deterministicWeight: r.deterministic_weight ?? 0,
279
+ sessionId: r.session_id ?? null,
280
+ capturedAt: r.captured_at,
281
+ attempts: r.attempts ?? 0,
282
+ lastAttemptAt: r.last_attempt_at ?? null,
283
+ };
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ }
290
+ //# sourceMappingURL=CorrectionCaptureBacklog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CorrectionCaptureBacklog.js","sourceRoot":"","sources":["../../src/monitoring/CorrectionCaptureBacklog.ts"],"names":[],"mappings":"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"}
@@ -20,10 +20,17 @@
20
20
  * `kind` is enum-validated (the LLM cannot widen it); llm_confidence is advisory.
21
21
  *
22
22
  * LlmQueue discipline (spec §3.1): every enqueue() is wrapped in try/catch for
23
- * ALL THREE throw paths (daily-cap, reserve-breach, LlmAbortedError) → DROP
24
- * silently, no retry, no backlog.
23
+ * ALL FOUR capacity throw paths (daily-cap, reserve-breach, LlmAbortedError,
24
+ * breaker-open). Previously these DROPPED the capture silently. Now, when a
25
+ * bounded durable backlog is wired (default-on when the feature is enabled), a
26
+ * capacity throw PERSISTS the already-pre-scrubbed capture to the backlog so a
27
+ * later headroom window can distill it ('distill-backlogged'). A non-capacity
28
+ * throw (e.g. a malformed provider error) still drops ('distill-dropped'), and
29
+ * if the backlog enqueue itself fails the capture falls back to the old drop —
30
+ * the fire-and-forget seam never throws regardless.
25
31
  */
26
32
  import type { CorrectionLedger, CorrectionKind } from './CorrectionLedger.js';
33
+ import type { CorrectionCaptureBacklog } from './CorrectionCaptureBacklog.js';
27
34
  export interface CaptureTurn {
28
35
  text: string;
29
36
  fromUser: boolean;
@@ -80,6 +87,21 @@ export declare function buildDistillPrompt(turns: CaptureTurn[]): string;
80
87
  * over the LLM's best-effort scrub).
81
88
  */
82
89
  export declare function parseDistillEnvelope(raw: string): DistillEnvelope | null;
90
+ /**
91
+ * Is this distill rejection a rate-limit / capacity / backpressure condition
92
+ * (i.e. "the LLM is unavailable RIGHT NOW, try later") rather than a genuine
93
+ * fault (malformed output, programmer error)? Only capacity throws are worth
94
+ * backlogging — a later headroom window can succeed where they failed. The four
95
+ * shapes the per-sentinel distill path can throw:
96
+ * - LlmQueue daily-cap: Error('LLM daily spend cap exceeded')
97
+ * - LlmQueue reserve breach: Error('...breach interactive reserve')
98
+ * - LlmQueue abort: LlmAbortedError (name === 'LlmAbortedError')
99
+ * - circuit breaker open: LlmCircuitOpenError (`circuitOpen === true`)
100
+ * Plus a defensive net for provider rate-limit strings (429 / quota / "rate
101
+ * limit"). Conservative: an unrecognized error is NOT treated as capacity (it
102
+ * drops, preserving the old behavior for genuine faults).
103
+ */
104
+ export declare function isCapacityThrow(err: unknown): boolean;
83
105
  export type DistillFn = (prompt: string) => Promise<string>;
84
106
  export interface CaptureAndDistillDeps {
85
107
  ring: CaptureRing;
@@ -94,6 +116,10 @@ export interface CaptureAndDistillDeps {
94
116
  maxPerWindow: number;
95
117
  windowMs: number;
96
118
  };
119
+ /** Durable retry backlog. When wired, a CAPACITY distill throw persists the
120
+ * pre-scrubbed capture here ('distill-backlogged') instead of dropping it, so
121
+ * a later headroom window can distill it. null/undefined → old drop behavior. */
122
+ backlog?: CorrectionCaptureBacklog | null;
97
123
  /** Audit sink — one structured line per capture decision. Never throws. */
98
124
  audit?: (event: {
99
125
  decision: string;
@@ -102,7 +128,7 @@ export interface CaptureAndDistillDeps {
102
128
  }) => void;
103
129
  now?: () => number;
104
130
  }
105
- export type CaptureDecision = 'recorded' | 'noise' | 'no-signal' | 'no-topic' | 'shed' | 'rate-limited' | 'distill-dropped' | 'distill-malformed' | 'error';
131
+ export type CaptureDecision = 'recorded' | 'noise' | 'no-signal' | 'no-topic' | 'shed' | 'rate-limited' | 'distill-dropped' | 'distill-backlogged' | 'distill-malformed' | 'error';
106
132
  /** Per-topic rate-limit state, owned by the loop wiring (persists across turns). */
107
133
  export interface CaptureRateState {
108
134
  attempts: Map<number, number[]>;
@@ -127,4 +153,48 @@ export declare function captureAndDistill(deps: CaptureAndDistillDeps, input: {
127
153
  deterministicWeight: number;
128
154
  isLearningSignal: boolean;
129
155
  }, rateState?: CaptureRateState): Promise<CaptureDecision>;
156
+ export interface DrainBacklogDeps {
157
+ backlog: CorrectionCaptureBacklog;
158
+ ledger: CorrectionLedger;
159
+ distill: DistillFn;
160
+ /** Pure, NON-mutating "is the LLM available right now?" gate (breaker closed +
161
+ * not load-shedding). The drainer NEVER runs while this is false — draining
162
+ * into an open breaker would just re-fail every entry. Wire to
163
+ * llmCircuitAvailable() (which does not consume a half-open probe slot). */
164
+ llmAvailable: () => boolean;
165
+ /** TTL (ms) — entries older than this are pruned before draining. */
166
+ ttlMs?: number;
167
+ /** Audit sink — one structured line per drain decision. Never throws. */
168
+ audit?: (event: {
169
+ decision: string;
170
+ topicId: number | null;
171
+ detail?: string;
172
+ }) => void;
173
+ }
174
+ export interface DrainResult {
175
+ /** Why the drain stopped without processing (when applicable). */
176
+ skipped?: 'breaker-open' | 'empty';
177
+ claimed: number;
178
+ distilled: number;
179
+ recorded: number;
180
+ failed: number;
181
+ dropped: number;
182
+ pruned: number;
183
+ }
184
+ /**
185
+ * Opportunistically distill up to `batchSize` backlogged captures into the
186
+ * CorrectionLedger. OFF-HOT-PATH (call via void/async, NEVER on the message
187
+ * seam). Bounded (batchSize), fail-open (a per-entry fault bumps that entry, a
188
+ * top-level fault returns a safe result), and SKIPPED when the LLM is
189
+ * unavailable (breaker open / shedding) — draining into an open breaker would
190
+ * only re-trip it and waste the entries' retry budget.
191
+ *
192
+ * Per entry: build the SAME distill prompt (turns are already scrubbed on
193
+ * persist; buildDistillPrompt re-scrubs harmlessly), parse the envelope, and —
194
+ * on a real, non-noise learning — record it to the ledger then DELETE the
195
+ * backlog row (markDistilled). A capacity throw OR a malformed/failed distill
196
+ * bumps the entry's attempt count (dropped once it exceeds maxRetries). A 'noise'
197
+ * verdict is terminal: the row is deleted (markDistilled) — it carried no lesson.
198
+ */
199
+ export declare function drainBacklog(deps: DrainBacklogDeps, batchSize: number): Promise<DrainResult>;
130
200
  //# sourceMappingURL=CorrectionCaptureLoop.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CorrectionCaptureLoop.d.ts","sourceRoot":"","sources":["../../src/monitoring/CorrectionCaptureLoop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CorrectionCaptureLoop.d.ts","sourceRoot":"","sources":["../../src/monitoring/CorrectionCaptureLoop.ts"],"names":[],"mappings":"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"}
@@ -145,6 +145,43 @@ export function parseDistillEnvelope(raw) {
145
145
  scrubbed_summary: scrubSecrets(summaryRaw || learningRaw).slice(0, 500),
146
146
  };
147
147
  }
148
+ // ── Capacity-throw classification ────────────────────────────────────────
149
+ /**
150
+ * Is this distill rejection a rate-limit / capacity / backpressure condition
151
+ * (i.e. "the LLM is unavailable RIGHT NOW, try later") rather than a genuine
152
+ * fault (malformed output, programmer error)? Only capacity throws are worth
153
+ * backlogging — a later headroom window can succeed where they failed. The four
154
+ * shapes the per-sentinel distill path can throw:
155
+ * - LlmQueue daily-cap: Error('LLM daily spend cap exceeded')
156
+ * - LlmQueue reserve breach: Error('...breach interactive reserve')
157
+ * - LlmQueue abort: LlmAbortedError (name === 'LlmAbortedError')
158
+ * - circuit breaker open: LlmCircuitOpenError (`circuitOpen === true`)
159
+ * Plus a defensive net for provider rate-limit strings (429 / quota / "rate
160
+ * limit"). Conservative: an unrecognized error is NOT treated as capacity (it
161
+ * drops, preserving the old behavior for genuine faults).
162
+ */
163
+ export function isCapacityThrow(err) {
164
+ if (!err)
165
+ return false;
166
+ const e = err;
167
+ if (e.circuitOpen === true)
168
+ return true;
169
+ if (e.isRateLimit === true)
170
+ return true;
171
+ if (e.name === 'LlmAbortedError' || e.name === 'LlmCircuitOpenError')
172
+ return true;
173
+ const msg = typeof e.message === 'string' ? e.message.toLowerCase() : '';
174
+ if (!msg)
175
+ return false;
176
+ return (msg.includes('daily spend cap') ||
177
+ msg.includes('interactive reserve') ||
178
+ msg.includes('rate limit') ||
179
+ msg.includes('rate-limit') ||
180
+ msg.includes('circuit breaker open') ||
181
+ msg.includes('too many requests') ||
182
+ /\b429\b/.test(msg) ||
183
+ msg.includes('quota'));
184
+ }
148
185
  export function makeCaptureRateState() {
149
186
  return { attempts: new Map() };
150
187
  }
@@ -207,8 +244,33 @@ export async function captureAndDistill(deps, input, rateState) {
207
244
  try {
208
245
  response = await deps.distill(prompt);
209
246
  }
210
- catch {
211
- // @silent-fallback-ok — cap / reserve / abort all mean "skip this capture".
247
+ catch (distillErr) {
248
+ // A CAPACITY throw (daily-cap / reserve / abort / breaker-open) means "the
249
+ // LLM is unavailable right now" — persist the pre-scrubbed capture to the
250
+ // durable backlog so a later headroom window distills it (instead of the
251
+ // old silent drop that left a throttled agent's ledger permanently empty).
252
+ // enqueue() re-scrubs every turn defensively, so ONLY pre-scrubbed text is
253
+ // ever persisted. Wrapped in try/catch → any backlog fault falls back to
254
+ // the old drop. A NON-capacity fault still drops.
255
+ if (deps.backlog && isCapacityThrow(distillErr)) {
256
+ try {
257
+ const id = deps.backlog.enqueue({
258
+ topicId: input.topicId,
259
+ turns: window,
260
+ deterministicWeight: input.deterministicWeight,
261
+ sessionId: input.sessionId ?? null,
262
+ capturedAt: now(),
263
+ });
264
+ if (id) {
265
+ deps.audit?.({ decision: 'distill-backlogged', topicId: input.topicId, detail: id });
266
+ return 'distill-backlogged';
267
+ }
268
+ }
269
+ catch {
270
+ // @silent-fallback-ok — backlog fault must never break capture; drop.
271
+ }
272
+ }
273
+ // @silent-fallback-ok — non-capacity fault, or backlog absent/failed.
212
274
  deps.audit?.({ decision: 'distill-dropped', topicId: input.topicId });
213
275
  return 'distill-dropped';
214
276
  }
@@ -246,4 +308,112 @@ export async function captureAndDistill(deps, input, rateState) {
246
308
  return 'error';
247
309
  }
248
310
  }
311
+ /**
312
+ * Opportunistically distill up to `batchSize` backlogged captures into the
313
+ * CorrectionLedger. OFF-HOT-PATH (call via void/async, NEVER on the message
314
+ * seam). Bounded (batchSize), fail-open (a per-entry fault bumps that entry, a
315
+ * top-level fault returns a safe result), and SKIPPED when the LLM is
316
+ * unavailable (breaker open / shedding) — draining into an open breaker would
317
+ * only re-trip it and waste the entries' retry budget.
318
+ *
319
+ * Per entry: build the SAME distill prompt (turns are already scrubbed on
320
+ * persist; buildDistillPrompt re-scrubs harmlessly), parse the envelope, and —
321
+ * on a real, non-noise learning — record it to the ledger then DELETE the
322
+ * backlog row (markDistilled). A capacity throw OR a malformed/failed distill
323
+ * bumps the entry's attempt count (dropped once it exceeds maxRetries). A 'noise'
324
+ * verdict is terminal: the row is deleted (markDistilled) — it carried no lesson.
325
+ */
326
+ export async function drainBacklog(deps, batchSize) {
327
+ const result = { claimed: 0, distilled: 0, recorded: 0, failed: 0, dropped: 0, pruned: 0 };
328
+ try {
329
+ // Prune stale entries first (bounded retention).
330
+ if (deps.ttlMs && deps.ttlMs > 0) {
331
+ result.pruned = deps.backlog.pruneExpired(deps.ttlMs);
332
+ }
333
+ // NEVER drain while the breaker is open / shedding.
334
+ if (!deps.llmAvailable()) {
335
+ result.skipped = 'breaker-open';
336
+ deps.audit?.({ decision: 'drain-skipped-breaker-open', topicId: null });
337
+ return result;
338
+ }
339
+ const batch = deps.backlog.claimBatch(batchSize);
340
+ result.claimed = batch.length;
341
+ if (batch.length === 0) {
342
+ result.skipped = 'empty';
343
+ return result;
344
+ }
345
+ for (const entry of batch) {
346
+ // Re-check availability between entries — a mid-drain trip stops the batch
347
+ // cleanly (remaining entries keep their attempt budget for next time).
348
+ if (!deps.llmAvailable()) {
349
+ deps.audit?.({ decision: 'drain-stopped-breaker-open', topicId: entry.topicId });
350
+ break;
351
+ }
352
+ try {
353
+ const prompt = buildDistillPrompt(entry.turns);
354
+ const response = await deps.distill(prompt);
355
+ result.distilled++;
356
+ const envelope = parseDistillEnvelope(response);
357
+ if (!envelope) {
358
+ // Malformed — count a failed attempt (a later retry may parse).
359
+ const dropped = deps.backlog.bumpAttempt(entry.id);
360
+ result.failed++;
361
+ if (dropped)
362
+ result.dropped++;
363
+ deps.audit?.({ decision: dropped ? 'drain-malformed-dropped' : 'drain-malformed', topicId: entry.topicId });
364
+ continue;
365
+ }
366
+ if (envelope.kind === 'noise') {
367
+ // Terminal — no lesson; delete the row.
368
+ deps.backlog.markDistilled(entry.id);
369
+ deps.audit?.({ decision: 'drain-noise', topicId: entry.topicId });
370
+ continue;
371
+ }
372
+ const rec = deps.ledger.record({
373
+ kind: envelope.kind,
374
+ learning: envelope.learning,
375
+ scrubbedSummary: envelope.scrubbed_summary,
376
+ deterministicWeight: entry.deterministicWeight,
377
+ llmConfidence: envelope.llm_confidence,
378
+ topicId: entry.topicId,
379
+ sessionId: entry.sessionId ?? null,
380
+ });
381
+ if (rec) {
382
+ deps.backlog.markDistilled(entry.id);
383
+ result.recorded++;
384
+ deps.audit?.({ decision: 'drain-recorded', topicId: entry.topicId, detail: `${envelope.kind} ${rec.dedupeKey.slice(0, 24)}` });
385
+ }
386
+ else {
387
+ // Ledger write failed — keep the entry, count an attempt.
388
+ const dropped = deps.backlog.bumpAttempt(entry.id);
389
+ result.failed++;
390
+ if (dropped)
391
+ result.dropped++;
392
+ deps.audit?.({ decision: dropped ? 'drain-ledger-failed-dropped' : 'drain-ledger-failed', topicId: entry.topicId });
393
+ }
394
+ }
395
+ catch (entryErr) {
396
+ // A capacity throw or any other per-entry fault: bump the attempt and
397
+ // move on (the seam never throws). If this was a fresh breaker trip the
398
+ // next-entry availability check stops the batch.
399
+ const dropped = deps.backlog.bumpAttempt(entry.id);
400
+ result.failed++;
401
+ if (dropped)
402
+ result.dropped++;
403
+ deps.audit?.({
404
+ decision: dropped ? 'drain-failed-dropped' : 'drain-failed',
405
+ topicId: entry.topicId,
406
+ detail: entryErr instanceof Error ? entryErr.message.slice(0, 80) : undefined,
407
+ });
408
+ if (isCapacityThrow(entryErr))
409
+ break; // breaker likely tripped — stop the batch.
410
+ }
411
+ }
412
+ }
413
+ catch (err) {
414
+ // Top-level fault — never propagates (off-hot-path, fail-open).
415
+ deps.audit?.({ decision: 'drain-error', topicId: null, detail: err instanceof Error ? err.message : String(err) });
416
+ }
417
+ return result;
418
+ }
249
419
  //# sourceMappingURL=CorrectionCaptureLoop.js.map