@remnic/core 9.3.728 → 9.3.729

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,175 @@
1
+ /**
2
+ * Extraction queue coordinator — extracted from the orchestrator (issue #1526).
3
+ *
4
+ * Owns the background serial queue that drains extraction tasks one at a
5
+ * time to avoid races between concurrent flush/heartbeat/bulk-import
6
+ * triggers:
7
+ * - queue state (`extractionQueue` + `queueProcessing`)
8
+ * - the scheduling trigger (start the drain when the first task lands and
9
+ * the processor is idle)
10
+ * - the serial drain loop itself (`processQueue`)
11
+ * - failure classification (`logExtractionQueueFailure`) — issue #549:
12
+ * AbortError from session transitions logs at debug, real failures at
13
+ * error
14
+ * - the idle wait (`waitForIdle`) used by bootstrap and tests
15
+ *
16
+ * Does NOT own the WHAT — `runExtraction`, the dedupe fingerprint check,
17
+ * the deadline timer, and the buffer-clear policy remain on the
18
+ * orchestrator (they need the full extraction dependency surface). The
19
+ * orchestrator builds each task closure and hands it to `enqueue`; the
20
+ * coordinator owns only the queue mechanics.
21
+ *
22
+ * Behavior-preserving move from orchestrator.ts. No logic changes — the
23
+ * orchestrator keeps a thin delegating `waitForExtractionIdle` and the
24
+ * `queueBufferedExtraction` builder delegates push+scheduling here.
25
+ */
26
+
27
+ import { isAbortError } from "../abort-error.js";
28
+ import { log } from "../logger.js";
29
+
30
+ /**
31
+ * Coordinates the background serial extraction queue. Owns the in-flight
32
+ * guard + queue array that previously lived as private orchestrator fields.
33
+ */
34
+ export class ExtractionQueueCoordinator {
35
+ /**
36
+ * Background serial queue for extractions (agent_end optimization).
37
+ * Queue stores tasks that resolve when extraction should run.
38
+ */
39
+ private readonly queue: Array<() => Promise<void>> = [];
40
+ /** Whether the serial drain loop is currently running. */
41
+ private processing = false;
42
+
43
+ /** Current queue depth (idle-wait + test seam). */
44
+ get length(): number {
45
+ return this.queue.length;
46
+ }
47
+
48
+ /** Whether the serial drain loop is currently running (idle-wait + test seam). */
49
+ get isProcessing(): boolean {
50
+ return this.processing;
51
+ }
52
+
53
+ /**
54
+ * Enqueue a task and start the serial processor if it is idle. This is
55
+ * the production entry point — the orchestrator's `queueBufferedExtraction`
56
+ * builds each task closure and hands it here.
57
+ */
58
+ enqueue(task: () => Promise<void>): void {
59
+ this.queue.push(task);
60
+ if (!this.processing) {
61
+ this.processing = true;
62
+ this.processQueue().catch((err) => {
63
+ this.logExtractionQueueFailure(err, "processor");
64
+ this.processing = false;
65
+ });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Background serial queue processor.
71
+ * Processes extractions one at a time to avoid race conditions.
72
+ * Called automatically when items are queued via `enqueue`; also exposed
73
+ * for the characterization tests that push raw tasks and assert drain
74
+ * semantics directly.
75
+ */
76
+ async processQueue(): Promise<void> {
77
+ while (this.queue.length > 0) {
78
+ const task = this.queue.shift();
79
+ if (task) {
80
+ try {
81
+ await task();
82
+ } catch (err) {
83
+ this.logExtractionQueueFailure(err, "task");
84
+ }
85
+ }
86
+ }
87
+
88
+ this.processing = false;
89
+ }
90
+
91
+ /**
92
+ * Classify + log a failure from either the per-task catch inside
93
+ * `processQueue()` or the outer `processQueue().catch(...)` in
94
+ * `enqueue()`. Issue #549: `throwIfRecallAborted`
95
+ * (used throughout `runExtraction`) raises an Error whose `name` is
96
+ * `"AbortError"`. That path fires when `before_reset` aborts a
97
+ * queued task to avoid duplicate extraction — it is intentional
98
+ * cancellation, not a failure. Downgrading the log to debug
99
+ * prevents spurious `error`-level lines that routinely appear
100
+ * right next to a successful `persisted: N facts, M entities` log
101
+ * and that confuse operators into thinking extraction is broken.
102
+ * Genuine extraction failures (network, parse, I/O) still log at
103
+ * `error`.
104
+ *
105
+ * Source differentiates the two call sites so the log message
106
+ * names the right layer (`task` vs `processor`).
107
+ */
108
+ logExtractionQueueFailure(
109
+ err: unknown,
110
+ source: "task" | "processor",
111
+ ): void {
112
+ const aborted =
113
+ source === "task"
114
+ ? "background extraction task aborted (session transition)"
115
+ : "background extraction queue processor aborted (session transition)";
116
+ const failed =
117
+ source === "task"
118
+ ? "background extraction task failed"
119
+ : "background extraction queue processor failed";
120
+ if (isAbortError(err)) {
121
+ log.debug(aborted);
122
+ } else {
123
+ log.error(failed, err);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Wait until the extraction queue is fully drained (no tasks queued, no
129
+ * drain in flight) or `timeoutMs` elapses. Used by bootstrap (after a
130
+ * dry-run import) and by tests that need to assert extraction settled.
131
+ * Returns false on timeout, true once idle.
132
+ */
133
+ async waitForIdle(timeoutMs: number = 60_000): Promise<boolean> {
134
+ const started = Date.now();
135
+ while (this.processing || this.queue.length > 0) {
136
+ if (Date.now() - started > timeoutMs) {
137
+ log.warn(`waitForExtractionIdle timed out after ${timeoutMs}ms`);
138
+ return false;
139
+ }
140
+ const { promise, resolve } = Promise.withResolvers<void>();
141
+ setTimeout(resolve, 50);
142
+ await promise;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ // ── Test seams ─────────────────────────────────────────────────────────
148
+ // Mirror the pre-extraction `orchestrator.extractionQueue` /
149
+ // `orchestrator.queueProcessing` direct-field access the characterization
150
+ // and flush deadline tests relied on. Each is a thin, well-named handle
151
+ // onto the coordinator's private state — no behavior change.
152
+
153
+ /**
154
+ * Push a raw task WITHOUT auto-starting the processor, matching the
155
+ * pre-extraction `this.extractionQueue.push(...)` used by the
156
+ * characterization tests that drive the drain manually via `processQueue`.
157
+ */
158
+ pushRaw(task: () => Promise<void>): void {
159
+ this.queue.push(task);
160
+ }
161
+
162
+ /** Remove + return the front task (test seam for the flush deadline tests). */
163
+ shift(): (() => Promise<void>) | undefined {
164
+ return this.queue.shift();
165
+ }
166
+
167
+ /**
168
+ * Force the processor-busy flag so `enqueue` does not auto-start the
169
+ * drain — used by the flush deadline test to simulate a busy queue and
170
+ * assert a queued task's deadline expires while it waits.
171
+ */
172
+ setProcessingForTest(value: boolean): void {
173
+ this.processing = value;
174
+ }
175
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
- import { Orchestrator } from "./orchestrator.js";
4
+ import { ExtractionQueueCoordinator } from "./orchestration/extraction-queue-coordinator.js";
5
5
  import { initLogger, type LoggerBackend } from "./logger.js";
6
6
  import { abortError } from "./abort-error.js";
7
7
 
@@ -31,17 +31,8 @@ function installCapturingLogger(): { entries: LogEntry[] } {
31
31
  return { entries };
32
32
  }
33
33
 
34
- type QueueOrchestrator = {
35
- extractionQueue: Array<() => Promise<void>>;
36
- queueProcessing: boolean;
37
- processQueue: () => Promise<void>;
38
- };
39
-
40
- function createQueueOrchestrator(): QueueOrchestrator {
41
- const orch = Object.create(Orchestrator.prototype) as QueueOrchestrator;
42
- orch.extractionQueue = [];
43
- orch.queueProcessing = true;
44
- return orch;
34
+ function createCoordinator(): ExtractionQueueCoordinator {
35
+ return new ExtractionQueueCoordinator();
45
36
  }
46
37
 
47
38
  // ── Issue #549 ─────────────────────────────────────────────────────────────
@@ -53,11 +44,11 @@ test("processQueue logs an AbortError task at debug, not error (#549)", async ()
53
44
  // That is intentional deduplication, not a failure. The queue
54
45
  // processor must log it at debug.
55
46
  const { entries } = installCapturingLogger();
56
- const orch = createQueueOrchestrator();
57
- orch.extractionQueue.push(async () => {
47
+ const coordinator = createCoordinator();
48
+ coordinator.pushRaw(async () => {
58
49
  throw abortError("extraction aborted (before_extract)");
59
50
  });
60
- await orch.processQueue();
51
+ await coordinator.processQueue();
61
52
 
62
53
  const errorEntries = entries.filter((e) => e.level === "error");
63
54
  const debugEntries = entries.filter(
@@ -80,11 +71,11 @@ test("processQueue still logs real task failures at error", async () => {
80
71
  // Guard the other half: non-abort errors (network, parse, I/O) must
81
72
  // continue to log at error level.
82
73
  const { entries } = installCapturingLogger();
83
- const orch = createQueueOrchestrator();
84
- orch.extractionQueue.push(async () => {
74
+ const coordinator = createCoordinator();
75
+ coordinator.pushRaw(async () => {
85
76
  throw new Error("upstream LLM 500");
86
77
  });
87
- await orch.processQueue();
78
+ await coordinator.processQueue();
88
79
 
89
80
  const errorEntries = entries.filter((e) => e.level === "error");
90
81
  assert.equal(errorEntries.length, 1);
@@ -96,14 +87,14 @@ test("processQueue still logs real task failures at error", async () => {
96
87
 
97
88
  test("processQueue handles a mixed run — abort goes to debug, failure to error", async () => {
98
89
  const { entries } = installCapturingLogger();
99
- const orch = createQueueOrchestrator();
100
- orch.extractionQueue.push(async () => {
90
+ const coordinator = createCoordinator();
91
+ coordinator.pushRaw(async () => {
101
92
  throw abortError("extraction aborted (before_clear_buffer)");
102
93
  });
103
- orch.extractionQueue.push(async () => {
94
+ coordinator.pushRaw(async () => {
104
95
  throw new Error("I/O failure");
105
96
  });
106
- await orch.processQueue();
97
+ await coordinator.processQueue();
107
98
 
108
99
  const errorCount = entries.filter((e) => e.level === "error").length;
109
100
  const abortDebugCount = entries.filter(
@@ -121,30 +112,27 @@ test("processQueue handles a mixed run — abort goes to debug, failure to error
121
112
 
122
113
  test("processQueue clears queueProcessing on exit regardless of task outcomes", async () => {
123
114
  installCapturingLogger();
124
- const orch = createQueueOrchestrator();
125
- orch.extractionQueue.push(async () => {
115
+ const coordinator = createCoordinator();
116
+ coordinator.setProcessingForTest(true);
117
+ coordinator.pushRaw(async () => {
126
118
  throw abortError("extraction aborted");
127
119
  });
128
- await orch.processQueue();
129
- assert.equal(orch.queueProcessing, false);
120
+ await coordinator.processQueue();
121
+ assert.equal(coordinator.isProcessing, false);
130
122
  });
131
123
 
132
124
  // ── Outer processQueue().catch() branch (Codex follow-up on #549) ──────────
133
125
 
134
- type QueueProcessorOrchestrator = QueueOrchestrator & {
135
- logExtractionQueueFailure: (err: unknown, source: "task" | "processor") => void;
136
- };
137
-
138
126
  test("logExtractionQueueFailure(processor) classifies AbortError as debug (#549)", async () => {
139
127
  // Covers the outer `processQueue().catch(...)` path in
140
- // `queueBufferedExtraction`. A processor-level AbortError can
128
+ // `enqueue`. A processor-level AbortError can
141
129
  // bubble from e.g. a processQueue rewrite that awaits an external
142
130
  // signal, so the handler must fail open to debug — otherwise a
143
131
  // session-transition-triggered processor abort would get logged
144
132
  // at error next to the successful extraction it just produced.
145
133
  const { entries } = installCapturingLogger();
146
- const orch = createQueueOrchestrator() as QueueProcessorOrchestrator;
147
- orch.logExtractionQueueFailure(
134
+ const coordinator = createCoordinator();
135
+ coordinator.logExtractionQueueFailure(
148
136
  abortError("queue processor aborted"),
149
137
  "processor",
150
138
  );
@@ -160,8 +148,8 @@ test("logExtractionQueueFailure(processor) classifies AbortError as debug (#549)
160
148
 
161
149
  test("logExtractionQueueFailure(processor) preserves error-level for real failures", async () => {
162
150
  const { entries } = installCapturingLogger();
163
- const orch = createQueueOrchestrator() as QueueProcessorOrchestrator;
164
- orch.logExtractionQueueFailure(
151
+ const coordinator = createCoordinator();
152
+ coordinator.logExtractionQueueFailure(
165
153
  new Error("unexpected processor crash"),
166
154
  "processor",
167
155
  );
@@ -180,9 +168,9 @@ test("logExtractionQueueFailure names the right layer (task vs processor)", asyn
180
168
  // tells them whether a specific extraction aborted or the queue
181
169
  // processor itself did.
182
170
  const { entries } = installCapturingLogger();
183
- const orch = createQueueOrchestrator() as QueueProcessorOrchestrator;
184
- orch.logExtractionQueueFailure(abortError("a"), "task");
185
- orch.logExtractionQueueFailure(abortError("b"), "processor");
171
+ const coordinator = createCoordinator();
172
+ coordinator.logExtractionQueueFailure(abortError("a"), "task");
173
+ coordinator.logExtractionQueueFailure(abortError("b"), "processor");
186
174
  const debugMessages = entries
187
175
  .filter((e) => e.level === "debug")
188
176
  .map((e) => e.message);
@@ -7,6 +7,7 @@ import {
7
7
  BulkImportBatchPartialFailureError,
8
8
  Orchestrator,
9
9
  } from "./orchestrator.js";
10
+ import { ExtractionQueueCoordinator } from "./orchestration/extraction-queue-coordinator.js";
10
11
  import { parseConfig } from "./config.js";
11
12
  import { stableHash } from "./coding/git-context.js";
12
13
  import type { BufferTurn } from "./types.js";
@@ -117,8 +118,7 @@ test("flushSession waits for queued extraction task completion", async () => {
117
118
  return [makeTurn("thread-a", "remember alpha")];
118
119
  },
119
120
  };
120
- orchestrator.extractionQueue = [];
121
- orchestrator.queueProcessing = false;
121
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
122
122
  orchestrator.runExtraction = async () => {
123
123
  extractionStarted = true;
124
124
  await new Promise<void>((resolve) => {
@@ -147,8 +147,8 @@ test("flushSession waits for queued extraction task completion", async () => {
147
147
  test("ingestBulkImportBatch rejects when the extraction deadline expires in the queue", async () => {
148
148
  const orchestrator = Object.create(Orchestrator.prototype) as any;
149
149
  orchestrator.config = parseConfig({});
150
- orchestrator.extractionQueue = [];
151
- orchestrator.queueProcessing = true;
150
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
151
+ orchestrator.extractionQueueCoordinator.setProcessingForTest(true);
152
152
  let runExtractionCalls = 0;
153
153
  orchestrator.runExtraction = async () => {
154
154
  runExtractionCalls += 1;
@@ -185,7 +185,7 @@ test("ingestBulkImportBatch rejects when the extraction deadline expires in the
185
185
  );
186
186
  assert.equal(runExtractionCalls, 0);
187
187
 
188
- const queuedTask = orchestrator.extractionQueue.shift();
188
+ const queuedTask = orchestrator.extractionQueueCoordinator.shift();
189
189
  assert.ok(queuedTask);
190
190
  await queuedTask();
191
191
  assert.equal(
@@ -198,8 +198,7 @@ test("ingestBulkImportBatch rejects when the extraction deadline expires in the
198
198
  test("ingestBulkImportBatch does not report queue wait timeout after extraction starts", async () => {
199
199
  const orchestrator = Object.create(Orchestrator.prototype) as any;
200
200
  orchestrator.config = parseConfig({});
201
- orchestrator.extractionQueue = [];
202
- orchestrator.queueProcessing = false;
201
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
203
202
  let runExtractionCalls = 0;
204
203
  orchestrator.runExtraction = async () => {
205
204
  runExtractionCalls += 1;
@@ -234,8 +233,7 @@ test("ingestBulkImportBatch does not report queue wait timeout after extraction
234
233
  test("ingestBulkImportBatch reports post-persist metadata failures separately", async () => {
235
234
  const orchestrator = Object.create(Orchestrator.prototype) as any;
236
235
  orchestrator.config = parseConfig({});
237
- orchestrator.extractionQueue = [];
238
- orchestrator.queueProcessing = false;
236
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
239
237
  orchestrator.runExtraction = async () => ({
240
238
  status: "completed",
241
239
  persistedCount: 1,
@@ -267,8 +265,7 @@ test("ingestBulkImportBatch reports post-persist metadata failures separately",
267
265
  test("ingestBulkImportBatch can disable source-valid-at replay context", async () => {
268
266
  const orchestrator = Object.create(Orchestrator.prototype) as any;
269
267
  orchestrator.config = parseConfig({});
270
- orchestrator.extractionQueue = [];
271
- orchestrator.queueProcessing = false;
268
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
272
269
  const capturedSlices: BufferTurn[][] = [];
273
270
  orchestrator.runExtraction = async (turns: BufferTurn[]) => {
274
271
  capturedSlices.push(turns);
@@ -321,8 +318,7 @@ test("ingestBulkImportBatch can disable source-valid-at replay context", async (
321
318
  test("ingestBulkImportBatch preserves partial metadata failure before a later slice rejects", async () => {
322
319
  const orchestrator = Object.create(Orchestrator.prototype) as any;
323
320
  orchestrator.config = parseConfig({});
324
- orchestrator.extractionQueue = [];
325
- orchestrator.queueProcessing = false;
321
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
326
322
  let runExtractionCalls = 0;
327
323
  orchestrator.runExtraction = async () => {
328
324
  runExtractionCalls += 1;
@@ -384,8 +380,7 @@ test("ingestBulkImportBatch preserves partial metadata failure before a later sl
384
380
  test("ingestBulkImportBatch stops after the first failed source-valid-at slice", async () => {
385
381
  const orchestrator = Object.create(Orchestrator.prototype) as any;
386
382
  orchestrator.config = parseConfig({});
387
- orchestrator.extractionQueue = [];
388
- orchestrator.queueProcessing = false;
383
+ orchestrator.extractionQueueCoordinator = new ExtractionQueueCoordinator();
389
384
  let runExtractionCalls = 0;
390
385
  orchestrator.runExtraction = async () => {
391
386
  runExtractionCalls += 1;
@@ -99,6 +99,7 @@ import {
99
99
  } from "./fallback-llm.js";
100
100
  import { MaintenanceScheduler } from "./orchestration/maintenance.js";
101
101
  import { TierMigrationCoordinator } from "./orchestration/tier-migration-coordinator.js";
102
+ import { ExtractionQueueCoordinator } from "./orchestration/extraction-queue-coordinator.js";
102
103
  import {
103
104
  runLiveConnectorsOnce,
104
105
  type LiveConnectorsRunSummary,
@@ -171,7 +172,6 @@ import {
171
172
  import { SessionObserverState } from "./session-observer-state.js";
172
173
  import {
173
174
  abortError as sharedAbortError,
174
- isAbortError,
175
175
  throwIfAborted as sharedThrowIfAborted,
176
176
  } from "./abort-error.js";
177
177
  import { CODEX_THREAD_KEY_PREFIX } from "./thread-key.js";
@@ -2021,10 +2021,14 @@ export class Orchestrator {
2021
2021
  suppressedReasonCounts: Record<string, number>;
2022
2022
  } = { detected: 0, queued: 0, autoApplied: 0, suppressedReasonCounts: {} };
2023
2023
 
2024
- // Background serial queue for extractions (agent_end optimization)
2025
- // Queue stores promises that resolve when extraction should run
2026
- private extractionQueue: Array<() => Promise<void>> = [];
2027
- private queueProcessing = false;
2024
+ /**
2025
+ * Background serial extraction queue coordinator (issue #1526 moved
2026
+ * from inline `extractionQueue`/`queueProcessing` fields). Owns queue
2027
+ * state + scheduling + the serial drain + failure classification; the
2028
+ * orchestrator builds each task closure in `queueBufferedExtraction` and
2029
+ * hands it to `enqueue`.
2030
+ */
2031
+ readonly extractionQueueCoordinator: ExtractionQueueCoordinator;
2028
2032
  private heartbeatObserverChains = new Map<string, Promise<void>>();
2029
2033
  private recentExtractionFingerprints = new Map<string, number>();
2030
2034
  private readonly consolidationObservers = new Set<
@@ -2991,6 +2995,8 @@ export class Orchestrator {
2991
2995
  namespaceSearchRouter: this.namespaceSearchRouter,
2992
2996
  namespaceCatalog: this.namespaceCatalog,
2993
2997
  });
2998
+ // Issue #1526: background extraction queue lives on its own coordinator.
2999
+ this.extractionQueueCoordinator = new ExtractionQueueCoordinator();
2994
3000
  const conversationIndexRuntime = createConversationIndexRuntime(config, {
2995
3001
  getQmd: () => this.conversationQmd,
2996
3002
  getFaiss: () => this.conversationFaiss,
@@ -4720,15 +4726,8 @@ export class Orchestrator {
4720
4726
  }
4721
4727
 
4722
4728
  async waitForExtractionIdle(timeoutMs: number = 60_000): Promise<boolean> {
4723
- const started = Date.now();
4724
- while (this.queueProcessing || this.extractionQueue.length > 0) {
4725
- if (Date.now() - started > timeoutMs) {
4726
- log.warn(`waitForExtractionIdle timed out after ${timeoutMs}ms`);
4727
- return false;
4728
- }
4729
- await new Promise((resolve) => setTimeout(resolve, 50));
4730
- }
4731
- return true;
4729
+ // Issue #1526: queue state + idle wait moved to ExtractionQueueCoordinator.
4730
+ return this.extractionQueueCoordinator.waitForIdle(timeoutMs);
4732
4731
  }
4733
4732
 
4734
4733
  async waitForConsolidationIdle(timeoutMs: number = 60_000): Promise<boolean> {
@@ -13165,7 +13164,7 @@ export class Orchestrator {
13165
13164
  }, remainingMs);
13166
13165
  }
13167
13166
 
13168
- this.extractionQueue.push(async () => {
13167
+ this.extractionQueueCoordinator.enqueue(async () => {
13169
13168
  if (settled) return;
13170
13169
  if (
13171
13170
  typeof extractionDeadlineMs === "number" &&
@@ -13196,13 +13195,6 @@ export class Orchestrator {
13196
13195
  }
13197
13196
  });
13198
13197
 
13199
- if (!this.queueProcessing) {
13200
- this.queueProcessing = true;
13201
- this.processQueue().catch((err) => {
13202
- this.logExtractionQueueFailure(err, "processor");
13203
- this.queueProcessing = false;
13204
- });
13205
- }
13206
13198
  log.debug(`queued extraction from ${reason}`);
13207
13199
  }
13208
13200
 
@@ -13270,62 +13262,6 @@ export class Orchestrator {
13270
13262
  return true;
13271
13263
  }
13272
13264
 
13273
- /**
13274
- * Background serial queue processor.
13275
- * Processes extractions one at a time to avoid race conditions.
13276
- * Called automatically when items are queued.
13277
- */
13278
- private async processQueue(): Promise<void> {
13279
- while (this.extractionQueue.length > 0) {
13280
- const task = this.extractionQueue.shift();
13281
- if (task) {
13282
- try {
13283
- await task();
13284
- } catch (err) {
13285
- this.logExtractionQueueFailure(err, "task");
13286
- }
13287
- }
13288
- }
13289
-
13290
- this.queueProcessing = false;
13291
- }
13292
-
13293
- /**
13294
- * Classify + log a failure from either the per-task catch inside
13295
- * `processQueue()` or the outer `processQueue().catch(...)` in
13296
- * `queueBufferedExtraction()`. Issue #549: `throwIfRecallAborted`
13297
- * (used throughout `runExtraction`) raises an Error whose `name` is
13298
- * `"AbortError"`. That path fires when `before_reset` aborts a
13299
- * queued task to avoid duplicate extraction — it is intentional
13300
- * cancellation, not a failure. Downgrading the log to debug
13301
- * prevents spurious `error`-level lines that routinely appear
13302
- * right next to a successful `persisted: N facts, M entities` log
13303
- * and that confuse operators into thinking extraction is broken.
13304
- * Genuine extraction failures (network, parse, I/O) still log at
13305
- * `error`.
13306
- *
13307
- * Source differentiates the two call sites so the log message
13308
- * names the right layer (`task` vs `processor`).
13309
- */
13310
- private logExtractionQueueFailure(
13311
- err: unknown,
13312
- source: "task" | "processor",
13313
- ): void {
13314
- const aborted =
13315
- source === "task"
13316
- ? "background extraction task aborted (session transition)"
13317
- : "background extraction queue processor aborted (session transition)";
13318
- const failed =
13319
- source === "task"
13320
- ? "background extraction task failed"
13321
- : "background extraction queue processor failed";
13322
- if (isAbortError(err)) {
13323
- log.debug(aborted);
13324
- } else {
13325
- log.error(failed, err);
13326
- }
13327
- }
13328
-
13329
13265
  /**
13330
13266
  * Passive correction capture (issue #1581) — detects corrections expressed
13331
13267
  * passively in conversation turns and routes them to the Correction Contract