@remnic/core 10.0.66 → 10.0.67

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.
Files changed (239) hide show
  1. package/dist/access-admin-ops-surface.js +16 -16
  2. package/dist/access-authorization-probe.js +17 -17
  3. package/dist/access-boundary.js +16 -16
  4. package/dist/access-cli.js +60 -60
  5. package/dist/access-extraction-force-flush.js +16 -16
  6. package/dist/access-http-briefing.js +17 -17
  7. package/dist/access-http-query.js +18 -18
  8. package/dist/access-http.js +33 -33
  9. package/dist/access-identity-continuity-surface.js +16 -16
  10. package/dist/access-lcm-surface.js +16 -16
  11. package/dist/access-mcp.js +26 -26
  12. package/dist/access-namespace-preflight.js +16 -16
  13. package/dist/access-observe-write-surface.js +16 -16
  14. package/dist/access-operations-batch.js +17 -17
  15. package/dist/access-operations.js +24 -24
  16. package/dist/access-recall-concurrency.js +16 -16
  17. package/dist/access-recall-diagnostics.js +2 -2
  18. package/dist/access-recall-response.js +16 -16
  19. package/dist/access-recall-surface.js +16 -16
  20. package/dist/access-service.js +16 -16
  21. package/dist/access-surface-catalog.js +18 -18
  22. package/dist/active-recall.js +3 -3
  23. package/dist/briefing.js +3 -3
  24. package/dist/buffer.js +2 -2
  25. package/dist/causal-consolidation.js +4 -4
  26. package/dist/{chunk-KISOMGY2.js → chunk-3RHU5MD7.js} +2 -2
  27. package/dist/{chunk-3H25IRJJ.js → chunk-4MPAKYZU.js} +2 -2
  28. package/dist/{chunk-IPCSZLLF.js → chunk-4NV7O76Q.js} +2 -2
  29. package/dist/{chunk-BH7NVSEC.js → chunk-4SOWMA55.js} +3 -3
  30. package/dist/{chunk-GROHOWR5.js → chunk-5MGKGEL7.js} +2 -2
  31. package/dist/{chunk-ZPLQ27MY.js → chunk-5UM4H64E.js} +2 -2
  32. package/dist/{chunk-2DNFRLLL.js → chunk-5Y6VA6X2.js} +2 -2
  33. package/dist/{chunk-RAU25Q6J.js → chunk-6WKWL53V.js} +13 -13
  34. package/dist/{chunk-KFPJIKUW.js → chunk-75DCOS6I.js} +23 -23
  35. package/dist/{chunk-25QK3LR2.js → chunk-7O2MC42R.js} +2 -2
  36. package/dist/{chunk-7ETWIBTO.js → chunk-ADYP7HXE.js} +3 -3
  37. package/dist/{chunk-FUDKGJDN.js → chunk-AVGDYYMA.js} +2 -2
  38. package/dist/{chunk-ECF627KC.js → chunk-AYJQ644F.js} +2 -2
  39. package/dist/{chunk-T763ADEK.js → chunk-B3MWELZ3.js} +2 -2
  40. package/dist/{chunk-DRTSPRWL.js → chunk-B4DXREWC.js} +2 -2
  41. package/dist/{chunk-5UEGL5BF.js → chunk-CI3MAAIY.js} +2 -2
  42. package/dist/{chunk-JYXFIILI.js → chunk-CTH2CQCB.js} +2 -2
  43. package/dist/{chunk-UVHXLVFJ.js → chunk-EURPSF4Y.js} +2 -2
  44. package/dist/{chunk-VPEA63FC.js → chunk-EWJZZDRG.js} +2 -2
  45. package/dist/{chunk-BOZQAUAJ.js → chunk-EXQJMFRF.js} +2 -2
  46. package/dist/{chunk-VC65GNUS.js → chunk-GGD2YDS6.js} +2 -2
  47. package/dist/{chunk-5JABRT5G.js → chunk-GHHQLSSY.js} +3 -3
  48. package/dist/{chunk-FMYAU5MP.js → chunk-GJ4HU6LK.js} +2 -2
  49. package/dist/{chunk-3NJJQAL3.js → chunk-H5DIRCZP.js} +12 -12
  50. package/dist/{chunk-434E4BTO.js → chunk-HH73M65H.js} +3 -3
  51. package/dist/{chunk-LIGZO7XN.js → chunk-HPB5TF55.js} +2 -2
  52. package/dist/{chunk-E4QIX2OB.js → chunk-IAGVM7EY.js} +2 -2
  53. package/dist/{chunk-VC7GKR2V.js → chunk-IG3EZ3TF.js} +2 -2
  54. package/dist/{chunk-QAN5ZDRS.js → chunk-IVUVQPTQ.js} +2 -2
  55. package/dist/{chunk-XOFIJGB2.js → chunk-IZBIDPMY.js} +2 -2
  56. package/dist/{chunk-HFEGRMFG.js → chunk-JLGBJ4AX.js} +7 -7
  57. package/dist/{chunk-BBHKN2CN.js → chunk-JLMOH5JA.js} +2 -2
  58. package/dist/{chunk-TPCRDPUY.js → chunk-KZSGBWOJ.js} +2 -2
  59. package/dist/{chunk-SHHZWSSE.js → chunk-KZZ5EZ7O.js} +2 -2
  60. package/dist/{chunk-XMBDI725.js → chunk-LFYEQK45.js} +674 -518
  61. package/dist/chunk-LFYEQK45.js.map +1 -0
  62. package/dist/{chunk-A5FOXI3B.js → chunk-LN4X2NDA.js} +2 -2
  63. package/dist/{chunk-5ZHFPUBC.js → chunk-MOIJWWNA.js} +8 -8
  64. package/dist/{chunk-KO224R36.js → chunk-NCZGGGWI.js} +2 -2
  65. package/dist/{chunk-XVQVF2TK.js → chunk-NHWDTOBN.js} +269 -230
  66. package/dist/chunk-NHWDTOBN.js.map +1 -0
  67. package/dist/{chunk-7ZTGMEXF.js → chunk-O5ZINWW3.js} +2 -2
  68. package/dist/{chunk-WWH7GJ42.js → chunk-OCK4D5PC.js} +2 -2
  69. package/dist/{chunk-G6L3REL3.js → chunk-OPFVXM2D.js} +2 -2
  70. package/dist/{chunk-FR7PK7ED.js → chunk-P4TV3TQ2.js} +2 -2
  71. package/dist/{chunk-ZGHPELZ6.js → chunk-PMDOGXA2.js} +2 -2
  72. package/dist/{chunk-NM2II57L.js → chunk-PRKNAIGZ.js} +2 -1
  73. package/dist/chunk-PRKNAIGZ.js.map +1 -0
  74. package/dist/{chunk-F32JRMX2.js → chunk-QGFR73SN.js} +2 -2
  75. package/dist/{chunk-QNZPYH36.js → chunk-R2BIVU2Z.js} +352 -290
  76. package/dist/chunk-R2BIVU2Z.js.map +1 -0
  77. package/dist/{chunk-HG3CG557.js → chunk-RLVI2M5Q.js} +2 -2
  78. package/dist/{chunk-SH6ZYUTX.js → chunk-SXTVZ6JL.js} +2 -2
  79. package/dist/{chunk-CLSKGTAT.js → chunk-TECK5IVY.js} +2 -2
  80. package/dist/{chunk-SNS3DWG6.js → chunk-TVE7W67N.js} +2 -2
  81. package/dist/{chunk-KYSSUY66.js → chunk-U2CAGMUD.js} +2 -2
  82. package/dist/{chunk-XLCEFUAU.js → chunk-UVVFPCPX.js} +2 -2
  83. package/dist/{chunk-PL75KQGF.js → chunk-UW6LBFYN.js} +5 -5
  84. package/dist/{chunk-SEA56G6S.js → chunk-V4XE263I.js} +1 -1
  85. package/dist/chunk-V4XE263I.js.map +1 -0
  86. package/dist/{chunk-VSZNPAGL.js → chunk-V6SBA5O7.js} +3 -3
  87. package/dist/{chunk-B7VD36TI.js → chunk-WHROVQ5Q.js} +3 -3
  88. package/dist/{chunk-LDHR465K.js → chunk-Y3XPSGNF.js} +2 -2
  89. package/dist/{chunk-E6VQJ75T.js → chunk-ZORS5BG5.js} +4 -4
  90. package/dist/{chunk-KXY4HW2Y.js → chunk-ZXF3O7P4.js} +2 -2
  91. package/dist/cli.js +46 -46
  92. package/dist/coding/export-okf-codegraph.js +2 -2
  93. package/dist/compounding/engine.js +4 -4
  94. package/dist/config.d.ts.map +1 -1
  95. package/dist/config.js +3 -3
  96. package/dist/connectors/codex-materialize-runner.js +3 -3
  97. package/dist/connectors/index.js +3 -3
  98. package/dist/convergence-refresh.js +5 -5
  99. package/dist/corpus-watermark.js +5 -5
  100. package/dist/entity-retrieval.js +3 -3
  101. package/dist/external-wiki-access.js +17 -17
  102. package/dist/extraction.js +6 -6
  103. package/dist/harmonic-retrieval.js +3 -3
  104. package/dist/index.js +60 -60
  105. package/dist/location/index.js +5 -5
  106. package/dist/maintenance/memory-governance.js +3 -3
  107. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  108. package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
  109. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  110. package/dist/memory-browse-operations.js +17 -17
  111. package/dist/namespaces/migrate.js +4 -4
  112. package/dist/namespaces/storage.js +3 -3
  113. package/dist/operator-doctor-corpus.js +6 -6
  114. package/dist/operator-toolkit.js +12 -12
  115. package/dist/orchestration/buffer-drain-runner.js +60 -60
  116. package/dist/orchestration/consolidation-run.d.ts.map +1 -1
  117. package/dist/orchestration/extraction-drain.d.ts +4 -4
  118. package/dist/orchestration/extraction-drain.d.ts.map +1 -1
  119. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  120. package/dist/orchestration/extraction-queue-coordinator.d.ts +67 -33
  121. package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
  122. package/dist/orchestration/extraction-run.d.ts +1 -8
  123. package/dist/orchestration/extraction-run.d.ts.map +1 -1
  124. package/dist/orchestration/fact-dedup-window.d.ts +49 -0
  125. package/dist/orchestration/fact-dedup-window.d.ts.map +1 -0
  126. package/dist/orchestration/maintenance.js +13 -13
  127. package/dist/orchestration/provider-breaker.d.ts +58 -0
  128. package/dist/orchestration/provider-breaker.d.ts.map +1 -0
  129. package/dist/orchestrator.js +60 -60
  130. package/dist/recall-explain-renderer.js +3 -3
  131. package/dist/recall-navigate-access.js +2 -2
  132. package/dist/recall-navigate-disclosure.js +2 -2
  133. package/dist/recall-navigate.js +2 -2
  134. package/dist/recall-navigation-operations.js +17 -17
  135. package/dist/recall-navigation.js +16 -16
  136. package/dist/recall-xray-cli.js +4 -4
  137. package/dist/recall-xray-renderer.js +3 -3
  138. package/dist/recall-xray.js +2 -2
  139. package/dist/resume-bundles.js +4 -4
  140. package/dist/semantic-consolidation.js +4 -4
  141. package/dist/semantic-rule-promotion.js +3 -3
  142. package/dist/semantic-rule-verifier.js +3 -3
  143. package/dist/source-agent-qualifier.js +5 -5
  144. package/dist/standup/build.js +3 -3
  145. package/dist/storage/profile-mutation.d.ts +25 -0
  146. package/dist/storage/profile-mutation.d.ts.map +1 -0
  147. package/dist/storage.d.ts +4 -6
  148. package/dist/storage.d.ts.map +1 -1
  149. package/dist/storage.js +2 -2
  150. package/dist/support-passport/index.js +19 -19
  151. package/dist/transfer/export-okf.js +3 -3
  152. package/dist/types.d.ts +2 -0
  153. package/dist/types.d.ts.map +1 -1
  154. package/dist/types.js +1 -1
  155. package/dist/verified-recall.js +3 -3
  156. package/dist/who-knows-cli.js +4 -4
  157. package/dist/who-knows.js +3 -3
  158. package/package.json +2 -2
  159. package/src/buffer-drain-handover.test.ts +3 -3
  160. package/src/buffer-drop-extraction-chunk.test.ts +0 -1
  161. package/src/config.test.ts +12 -0
  162. package/src/config.ts +2 -0
  163. package/src/fact-dedup-window.test.ts +116 -0
  164. package/src/orchestration/consolidation-profile-race.test.ts +178 -0
  165. package/src/orchestration/consolidation-run.ts +19 -7
  166. package/src/orchestration/extraction-drain.ts +10 -3
  167. package/src/orchestration/extraction-persist.ts +141 -138
  168. package/src/orchestration/extraction-queue-coordinator.ts +167 -56
  169. package/src/orchestration/extraction-run.ts +50 -93
  170. package/src/orchestration/fact-dedup-window.ts +122 -0
  171. package/src/orchestration/provider-breaker.test.ts +89 -0
  172. package/src/orchestration/provider-breaker.ts +158 -0
  173. package/src/orchestration/turn-ingestion.ts +5 -5
  174. package/src/orchestrator-extraction-queue.test.ts +179 -8
  175. package/src/orchestrator.ts +1 -1
  176. package/src/storage/profile-mutation.ts +119 -0
  177. package/src/storage-append-profile.test.ts +114 -0
  178. package/src/storage.ts +17 -48
  179. package/src/types.ts +2 -0
  180. package/dist/chunk-NM2II57L.js.map +0 -1
  181. package/dist/chunk-QNZPYH36.js.map +0 -1
  182. package/dist/chunk-SEA56G6S.js.map +0 -1
  183. package/dist/chunk-XMBDI725.js.map +0 -1
  184. package/dist/chunk-XVQVF2TK.js.map +0 -1
  185. /package/dist/{chunk-KISOMGY2.js.map → chunk-3RHU5MD7.js.map} +0 -0
  186. /package/dist/{chunk-3H25IRJJ.js.map → chunk-4MPAKYZU.js.map} +0 -0
  187. /package/dist/{chunk-IPCSZLLF.js.map → chunk-4NV7O76Q.js.map} +0 -0
  188. /package/dist/{chunk-BH7NVSEC.js.map → chunk-4SOWMA55.js.map} +0 -0
  189. /package/dist/{chunk-GROHOWR5.js.map → chunk-5MGKGEL7.js.map} +0 -0
  190. /package/dist/{chunk-ZPLQ27MY.js.map → chunk-5UM4H64E.js.map} +0 -0
  191. /package/dist/{chunk-2DNFRLLL.js.map → chunk-5Y6VA6X2.js.map} +0 -0
  192. /package/dist/{chunk-RAU25Q6J.js.map → chunk-6WKWL53V.js.map} +0 -0
  193. /package/dist/{chunk-KFPJIKUW.js.map → chunk-75DCOS6I.js.map} +0 -0
  194. /package/dist/{chunk-25QK3LR2.js.map → chunk-7O2MC42R.js.map} +0 -0
  195. /package/dist/{chunk-7ETWIBTO.js.map → chunk-ADYP7HXE.js.map} +0 -0
  196. /package/dist/{chunk-FUDKGJDN.js.map → chunk-AVGDYYMA.js.map} +0 -0
  197. /package/dist/{chunk-ECF627KC.js.map → chunk-AYJQ644F.js.map} +0 -0
  198. /package/dist/{chunk-T763ADEK.js.map → chunk-B3MWELZ3.js.map} +0 -0
  199. /package/dist/{chunk-DRTSPRWL.js.map → chunk-B4DXREWC.js.map} +0 -0
  200. /package/dist/{chunk-5UEGL5BF.js.map → chunk-CI3MAAIY.js.map} +0 -0
  201. /package/dist/{chunk-JYXFIILI.js.map → chunk-CTH2CQCB.js.map} +0 -0
  202. /package/dist/{chunk-UVHXLVFJ.js.map → chunk-EURPSF4Y.js.map} +0 -0
  203. /package/dist/{chunk-VPEA63FC.js.map → chunk-EWJZZDRG.js.map} +0 -0
  204. /package/dist/{chunk-BOZQAUAJ.js.map → chunk-EXQJMFRF.js.map} +0 -0
  205. /package/dist/{chunk-VC65GNUS.js.map → chunk-GGD2YDS6.js.map} +0 -0
  206. /package/dist/{chunk-5JABRT5G.js.map → chunk-GHHQLSSY.js.map} +0 -0
  207. /package/dist/{chunk-FMYAU5MP.js.map → chunk-GJ4HU6LK.js.map} +0 -0
  208. /package/dist/{chunk-3NJJQAL3.js.map → chunk-H5DIRCZP.js.map} +0 -0
  209. /package/dist/{chunk-434E4BTO.js.map → chunk-HH73M65H.js.map} +0 -0
  210. /package/dist/{chunk-LIGZO7XN.js.map → chunk-HPB5TF55.js.map} +0 -0
  211. /package/dist/{chunk-E4QIX2OB.js.map → chunk-IAGVM7EY.js.map} +0 -0
  212. /package/dist/{chunk-VC7GKR2V.js.map → chunk-IG3EZ3TF.js.map} +0 -0
  213. /package/dist/{chunk-QAN5ZDRS.js.map → chunk-IVUVQPTQ.js.map} +0 -0
  214. /package/dist/{chunk-XOFIJGB2.js.map → chunk-IZBIDPMY.js.map} +0 -0
  215. /package/dist/{chunk-HFEGRMFG.js.map → chunk-JLGBJ4AX.js.map} +0 -0
  216. /package/dist/{chunk-BBHKN2CN.js.map → chunk-JLMOH5JA.js.map} +0 -0
  217. /package/dist/{chunk-TPCRDPUY.js.map → chunk-KZSGBWOJ.js.map} +0 -0
  218. /package/dist/{chunk-SHHZWSSE.js.map → chunk-KZZ5EZ7O.js.map} +0 -0
  219. /package/dist/{chunk-A5FOXI3B.js.map → chunk-LN4X2NDA.js.map} +0 -0
  220. /package/dist/{chunk-5ZHFPUBC.js.map → chunk-MOIJWWNA.js.map} +0 -0
  221. /package/dist/{chunk-KO224R36.js.map → chunk-NCZGGGWI.js.map} +0 -0
  222. /package/dist/{chunk-7ZTGMEXF.js.map → chunk-O5ZINWW3.js.map} +0 -0
  223. /package/dist/{chunk-WWH7GJ42.js.map → chunk-OCK4D5PC.js.map} +0 -0
  224. /package/dist/{chunk-G6L3REL3.js.map → chunk-OPFVXM2D.js.map} +0 -0
  225. /package/dist/{chunk-FR7PK7ED.js.map → chunk-P4TV3TQ2.js.map} +0 -0
  226. /package/dist/{chunk-ZGHPELZ6.js.map → chunk-PMDOGXA2.js.map} +0 -0
  227. /package/dist/{chunk-F32JRMX2.js.map → chunk-QGFR73SN.js.map} +0 -0
  228. /package/dist/{chunk-HG3CG557.js.map → chunk-RLVI2M5Q.js.map} +0 -0
  229. /package/dist/{chunk-SH6ZYUTX.js.map → chunk-SXTVZ6JL.js.map} +0 -0
  230. /package/dist/{chunk-CLSKGTAT.js.map → chunk-TECK5IVY.js.map} +0 -0
  231. /package/dist/{chunk-SNS3DWG6.js.map → chunk-TVE7W67N.js.map} +0 -0
  232. /package/dist/{chunk-KYSSUY66.js.map → chunk-U2CAGMUD.js.map} +0 -0
  233. /package/dist/{chunk-XLCEFUAU.js.map → chunk-UVVFPCPX.js.map} +0 -0
  234. /package/dist/{chunk-PL75KQGF.js.map → chunk-UW6LBFYN.js.map} +0 -0
  235. /package/dist/{chunk-VSZNPAGL.js.map → chunk-V6SBA5O7.js.map} +0 -0
  236. /package/dist/{chunk-B7VD36TI.js.map → chunk-WHROVQ5Q.js.map} +0 -0
  237. /package/dist/{chunk-LDHR465K.js.map → chunk-Y3XPSGNF.js.map} +0 -0
  238. /package/dist/{chunk-E6VQJ75T.js.map → chunk-ZORS5BG5.js.map} +0 -0
  239. /package/dist/{chunk-KXY4HW2Y.js.map → chunk-ZXF3O7P4.js.map} +0 -0
@@ -125,6 +125,7 @@ import {
125
125
  } from "./extraction-run-helpers.js";
126
126
  import type { ExtractionResilienceStatus, ExtractionRetryStateEntry } from "./extraction-run-helpers.js";
127
127
  import type { ExtractionPersistResult } from "./extraction-persist.js";
128
+ import { ProviderCircuitBreaker, type ProbeOwnerToken } from "./provider-breaker.js";
128
129
 
129
130
  export {
130
131
  capExtractionRetryStateEntries,
@@ -150,12 +151,7 @@ export class ExtractionRunCoordinator {
150
151
  // backoff still throttles, so a restart can't resurrect the hot loop.
151
152
  private readonly extractionRetryState = new Map<string, Map<string, ExtractionRetryStateEntry>>();
152
153
  private readonly hydratedRetryNamespaces = new Set<string>();
153
- private providerBreaker: {
154
- consecutiveFailures: number;
155
- openUntilMs: number;
156
- state: "closed" | "open" | "half_open";
157
- lastReason: string;
158
- } = { consecutiveFailures: 0, openUntilMs: 0, state: "closed", lastReason: "" };
154
+ private readonly providerBreaker = new ProviderCircuitBreaker();
159
155
 
160
156
  constructor(private readonly deps: ExtractionRunCoordinatorDeps) {}
161
157
 
@@ -282,65 +278,6 @@ export class ExtractionRunCoordinator {
282
278
  this.extractionRetryState.set(namespace, nsMap);
283
279
  }
284
280
 
285
- private logBreakerTransition(state: "closed" | "open" | "half_open"): void {
286
- // Once per state change (not per attempt) — AGENTS.md observability rule.
287
- log.warn("extraction: provider circuit breaker transition", {
288
- state,
289
- consecutiveFailures: this.providerBreaker.consecutiveFailures,
290
- lastReason: this.providerBreaker.lastReason,
291
- openUntilMs: this.providerBreaker.openUntilMs,
292
- });
293
- }
294
-
295
- /** True while the breaker suppresses non-forced attempts. Flips an expired
296
- * `open` breaker to `half_open`, which allows exactly one probe (the next
297
- * non-forced attempt) because the extraction queue is a serial drain. */
298
- private isProviderBreakerOpen(now: number): boolean {
299
- if (now < this.providerBreaker.openUntilMs) return true;
300
- if (this.providerBreaker.state === "open") {
301
- this.providerBreaker.state = "half_open";
302
- this.logBreakerTransition("half_open");
303
- }
304
- return false;
305
- }
306
-
307
- private onProviderFailure(cls: ExtractionFailureClass, now: number): void {
308
- this.providerBreaker.consecutiveFailures += 1;
309
- const trip =
310
- cls === "auth_config" ||
311
- // A failed half-open probe re-opens immediately (standard breaker
312
- // semantics). Without this, an auth_config-opened breaker (which trips
313
- // below the threshold) would get stuck half_open after a transient
314
- // probe failure and stop suppressing (cursor review).
315
- this.providerBreaker.state === "half_open" ||
316
- this.providerBreaker.consecutiveFailures >= this.config.extractionBreakerFailureThreshold;
317
- if (!trip) return;
318
- const cooldown =
319
- cls === "auth_config"
320
- ? this.config.extractionBreakerAuthCooldownMs
321
- : this.config.extractionBreakerCooldownMs;
322
- this.providerBreaker.openUntilMs = now + Math.max(0, cooldown);
323
- this.providerBreaker.lastReason = cls;
324
- if (this.providerBreaker.state !== "open") {
325
- this.providerBreaker.state = "open";
326
- this.logBreakerTransition("open");
327
- }
328
- }
329
-
330
- private resetProviderBreakerOnSuccess(): void {
331
- const wasDisturbed =
332
- this.providerBreaker.state !== "closed" ||
333
- this.providerBreaker.consecutiveFailures > 0 ||
334
- this.providerBreaker.openUntilMs > 0;
335
- if (!wasDisturbed) return;
336
- const wasOpen = this.providerBreaker.state !== "closed";
337
- this.providerBreaker.consecutiveFailures = 0;
338
- this.providerBreaker.openUntilMs = 0;
339
- this.providerBreaker.lastReason = "";
340
- this.providerBreaker.state = "closed";
341
- if (wasOpen) this.logBreakerTransition("closed");
342
- }
343
-
344
281
  /** Rebuild `meta.extractionRetryState` from the in-memory namespace mirror,
345
282
  * bounded to the newest entries, and prune the mirror to match. */
346
283
  private persistRetryStateToMeta(namespace: string, meta: MetaState): void {
@@ -370,10 +307,11 @@ export class ExtractionRunCoordinator {
370
307
  cls: ExtractionFailureClass,
371
308
  meta: MetaState,
372
309
  now: number,
310
+ probeToken?: ProbeOwnerToken,
373
311
  ): Promise<void> {
374
312
  try {
375
313
  this.hydrateRetryStateFromMeta(namespace, meta);
376
- this.applyExtractionFailureToMemory(namespace, fingerprint, cls, now);
314
+ this.applyExtractionFailureToMemory(namespace, fingerprint, cls, now, probeToken);
377
315
  this.persistRetryStateToMeta(namespace, meta);
378
316
  // The caller's storage.updateMeta persists this — saving here would write the same file twice.
379
317
  } catch (err) {
@@ -389,6 +327,7 @@ export class ExtractionRunCoordinator {
389
327
  fingerprint: string,
390
328
  cls: ExtractionFailureClass,
391
329
  now: number,
330
+ probeToken?: ProbeOwnerToken,
392
331
  ): void {
393
332
  const nsMap = this.extractionRetryState.get(namespace) ?? new Map<string, ExtractionRetryStateEntry>();
394
333
  const prev = nsMap.get(fingerprint);
@@ -409,7 +348,11 @@ export class ExtractionRunCoordinator {
409
348
  }
410
349
  nsMap.set(fingerprint, { attempts, nextEligibleAtMs, firstFailedAtMs, lastFailureClass: cls });
411
350
  this.extractionRetryState.set(namespace, nsMap);
412
- this.onProviderFailure(cls, now);
351
+ this.providerBreaker.failure(cls, now, {
352
+ failureThreshold: this.config.extractionBreakerFailureThreshold,
353
+ cooldownMs: this.config.extractionBreakerCooldownMs,
354
+ authCooldownMs: this.config.extractionBreakerAuthCooldownMs,
355
+ }, probeToken);
413
356
  }
414
357
 
415
358
  /** Delete a fingerprint's retry entry from the mirror + meta. Returns whether
@@ -430,12 +373,7 @@ export class ExtractionRunCoordinator {
430
373
  backoffFingerprintCount += nsMap.size;
431
374
  }
432
375
  return {
433
- breaker: {
434
- state: this.providerBreaker.state,
435
- openUntilMs: this.providerBreaker.openUntilMs,
436
- consecutiveFailures: this.providerBreaker.consecutiveFailures,
437
- lastReason: this.providerBreaker.lastReason,
438
- },
376
+ breaker: this.providerBreaker.snapshot(),
439
377
  backoffFingerprintCount,
440
378
  };
441
379
  }
@@ -834,13 +772,18 @@ export class ExtractionRunCoordinator {
834
772
  // Resolve the gate through the shared lifecycle capability plan (issue
835
773
  // #1523) rather than reading the raw config flag at each call site.
836
774
  const extractionRetryEnabled = resolveMemoryLifecycleCapabilities(this.config).extractionRetry;
775
+ let extractionProbeOwned = false;
776
+ let extractionProbeToken: ProbeOwnerToken | undefined;
837
777
  if (extractionFingerprint && !forcedExtractionAttempt && extractionRetryEnabled) {
838
778
  try {
839
779
  meta ??= await runDeadlineAware(() => storage.loadMeta(), "during_retry_gate");
840
780
  this.hydrateRetryStateFromMeta(selfNamespace, meta);
841
781
  const nowMs = Date.now();
782
+ const probeAttempt = this.providerBreaker.acquire(nowMs);
783
+ extractionProbeOwned = probeAttempt.probe;
784
+ extractionProbeToken = probeAttempt.token;
842
785
  let suppressReason: "provider_circuit_open" | "extraction_backoff" | null = null;
843
- if (this.isProviderBreakerOpen(nowMs)) {
786
+ if (probeAttempt.suppressed) {
844
787
  suppressReason = "provider_circuit_open";
845
788
  } else {
846
789
  const st = this.extractionRetryState.get(selfNamespace)?.get(extractionFingerprint);
@@ -865,6 +808,10 @@ export class ExtractionRunCoordinator {
865
808
  log.warn("runExtraction: passive correction capture failed on suppressed attempt (non-fatal)", captureErr);
866
809
  }
867
810
  clearExtractionDeadlineTimer();
811
+ // Backoff suppression exits inside the gate, before the wrapper
812
+ // finally: release an acquired probe here (codex follow-up) so a
813
+ // cooldown-expired fingerprint cannot pin the breaker half_open.
814
+ if (extractionProbeOwned) this.providerBreaker.releaseProbe(extractionProbeToken);
868
815
  return { status: "skipped", reason: suppressReason, persistedCount: 0, durableOutputCount: 0 };
869
816
  }
870
817
  } catch (err) {
@@ -873,26 +820,28 @@ export class ExtractionRunCoordinator {
873
820
  log.warn("runExtraction: extraction retry gate check failed; proceeding (fail-open)", err);
874
821
  }
875
822
  }
876
-
877
823
  // Pass existing entity names so the LLM can reuse them instead of inventing variants.
878
- const existingEntities = await runDeadlineAware(
879
- () => storage.listEntityNames(),
880
- "during_list_entity_names",
881
- );
882
- let result: ExtractionResult;
824
+ // The probe owner must release on ANY pre-persistence exit too (codex P1):
825
+ // the big try below only covers from the extract call onward.
883
826
  try {
884
- result = await raceRecallAbort(
885
- this.deps.getExtraction().extract(normalizedTurns, existingEntities, extractionAbortSignal),
886
- extractionAbortSignal,
887
- "extraction aborted (during_extract)",
827
+ const existingEntities = await runDeadlineAware(
828
+ () => storage.listEntityNames(),
829
+ "during_list_entity_names",
888
830
  );
889
- } catch (error) {
890
- clearExtractionDeadlineTimer();
891
- if (typeof deadlineMs === "number" && Date.now() >= deadlineMs) {
892
- throw new ExtractionDeadlineError("during_extract");
831
+ let result: ExtractionResult;
832
+ try {
833
+ result = await raceRecallAbort(
834
+ this.deps.getExtraction().extract(normalizedTurns, existingEntities, extractionAbortSignal),
835
+ extractionAbortSignal,
836
+ "extraction aborted (during_extract)",
837
+ );
838
+ } catch (error) {
839
+ clearExtractionDeadlineTimer();
840
+ if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
841
+ throw new ExtractionDeadlineError("during_extract");
842
+ }
843
+ throw error;
893
844
  }
894
- throw error;
895
- }
896
845
  try {
897
846
  throwIfDeadlineExceeded("before_persist");
898
847
  throwIfAborted("before_persist");
@@ -965,7 +914,7 @@ export class ExtractionRunCoordinator {
965
914
  await runDeadlineAware(
966
915
  () =>
967
916
  storage.updateMeta((freshMeta) =>
968
- this.recordExtractionFailure(selfNamespace, extractionFingerprint, failureClass, freshMeta, Date.now()),
917
+ this.recordExtractionFailure(selfNamespace, extractionFingerprint, failureClass, freshMeta, Date.now(), extractionProbeToken),
969
918
  ),
970
919
  "during_retry_bookkeeping_save",
971
920
  false,
@@ -976,14 +925,16 @@ export class ExtractionRunCoordinator {
976
925
  // Fail-open, but retain the turns (a cleared buffer with no retry state drops them). Only a
977
926
  // LOCK skip reaches here without the mutator having run — a later save must not double-count.
978
927
  if (err instanceof Error && err.message.includes("meta.json.lock not acquired"))
979
- this.applyExtractionFailureToMemory(selfNamespace, extractionFingerprint, failureClass, Date.now());
928
+ this.applyExtractionFailureToMemory(selfNamespace, extractionFingerprint, failureClass, Date.now(), extractionProbeToken);
980
929
  recordedRetryFailure = true;
981
930
  log.warn("runExtraction: retry state kept in memory; meta persist will catch up", err);
982
931
  }
983
932
  } else if (result.extractionSkippedReason === undefined) {
984
933
  // Provider responded without failure → breaker heals; clear any
985
934
  // parked backoff for this fingerprint so it proceeds normally.
986
- this.resetProviderBreakerOnSuccess();
935
+ // PRRT_kwDORJXyws6zxG6u: pass the run's probe token so the settlement
936
+ // only applies to a probe this run owns.
937
+ this.providerBreaker.success(extractionProbeToken);
987
938
  try {
988
939
  await runDeadlineAware(
989
940
  () =>
@@ -1196,5 +1147,11 @@ export class ExtractionRunCoordinator {
1196
1147
  } finally {
1197
1148
  clearExtractionDeadlineTimer();
1198
1149
  }
1150
+ } finally {
1151
+ // The half-open probe is OWNED, not timed: the WRAPPER finally releases
1152
+ // it on every exit path, including pre-persistence ones (codex P1) that
1153
+ // never reach the inner try above.
1154
+ if (extractionProbeOwned) this.providerBreaker.releaseProbe(extractionProbeToken);
1155
+ }
1199
1156
  }
1200
1157
  }
@@ -0,0 +1,122 @@
1
+ import path from "node:path";
2
+ import { serializeMutations, withHeldFileLock } from "../utils/serialize-mutations.js";
3
+ import { ContentHashIndex, type StorageManager } from "../index.js";
4
+ import type { MemoryFile } from "../types.js";
5
+ import { normalizeStoredHashSource } from "./extraction-persist-citation.js";
6
+ import { stripAttributesSuffix } from "../structured-attributes.js";
7
+ import { log } from "../logger.js";
8
+
9
+ /**
10
+ * Fact-dedup commit window (codex P2, #1526): with extractionQueueConcurrency
11
+ * > 1 (or daemon + external drain workers), two sessions can both pass the
12
+ * content-hash check at persist time and both write, producing duplicate
13
+ * active copies — the persist-time checks run well before the write, and
14
+ * writeMemory mints a fresh id per write. This module serializes the short
15
+ * "final recheck → write" window per storage dir (in-process chain +
16
+ * fail-closed cross-process fact-dedup.lock, mirroring the profile/meta lock
17
+ * shape: bounded, no LLM or corpus-scale work planned inside, throw BEFORE
18
+ * any read/write when the lock is not acquired). The persist-time check stays
19
+ * OUTSIDE the window exactly like the consolidation LLM call stays outside
20
+ * commitConsolidatedProfile.
21
+ */
22
+ export interface FactDedupProbe {
23
+ hasContentHashDedup(storage: StorageManager, key: string): Promise<boolean>;
24
+ }
25
+
26
+ interface FactDedupMatchOptions {
27
+ readonly category: string;
28
+ readonly contentHashDedupKey: string;
29
+ readonly connector?: string;
30
+ readonly citationEnabled: boolean;
31
+ readonly citationTemplate: string;
32
+ }
33
+
34
+ function matchesActiveConnectorDuplicate(memory: MemoryFile, opts: FactDedupMatchOptions): boolean {
35
+ if (memory.frontmatter.category !== opts.category) return false;
36
+ if ((memory.frontmatter.status ?? "active") !== "active") return false;
37
+ // Thread 5 (QPDE5): for procedures the hash is keyed on the full
38
+ // body (title + steps via buildProcedurePersistBody), so compare
39
+ // against contentHashDedupKey — not the canonical title-only form.
40
+ // Thread 3 (QO42V): facts with structuredAttributes get an appended
41
+ // [Attributes: ...] suffix in the stored body that the hash key lacks;
42
+ // strip it before comparing so same-connector enriched facts dedup
43
+ // correctly.
44
+ if (normalizeStoredHashSource(stripAttributesSuffix(memory.content ?? ""), opts.citationEnabled, opts.citationTemplate) !==
45
+ ContentHashIndex.normalizeContent(opts.contentHashDedupKey)) return false;
46
+ return (memory.frontmatter.sourceConnector?.trim() || undefined) === opts.connector;
47
+ }
48
+
49
+ /**
50
+ * Connector-aware duplicate confirm: same final semantics as the persist-time
51
+ * check — an authoritative index miss is a clean NOT-duplicate, a hash hit or
52
+ * a non-authoritative/failed lookup must be confirmed against the hot+cold
53
+ * corpus (same category, active, same normalized content, same connector),
54
+ * and a corpus-scan failure fails OPEN so an unverifiable state cannot
55
+ * silently drop content.
56
+ */
57
+ export async function confirmActiveFactDuplicate(
58
+ probe: FactDedupProbe,
59
+ storage: StorageManager,
60
+ opts: FactDedupMatchOptions,
61
+ ): Promise<boolean> {
62
+ let needsCorpusConfirm: boolean;
63
+ try {
64
+ if (!(await probe.hasContentHashDedup(storage, opts.contentHashDedupKey))) {
65
+ if (await storage.isFactContentHashAuthoritative()) return false;
66
+ needsCorpusConfirm = true;
67
+ } else {
68
+ needsCorpusConfirm = true;
69
+ }
70
+ } catch {
71
+ // Dedup lookup failed (e.g. the authoritative rebuild could not run). Do
72
+ // NOT fail-open into a possible duplicate — confirm against the corpus.
73
+ needsCorpusConfirm = true;
74
+ }
75
+ try {
76
+ // #2016 cold-tier finding: the authoritative content-hash rebuild unions
77
+ // the HOT and COLD tiers, so a cold-only active copy must still confirm
78
+ // the duplicate and suppress the redundant hot write.
79
+ const [hotMems, coldMems] = await Promise.all([
80
+ storage.readAllMemories(),
81
+ storage.readAllColdMemories(),
82
+ ]);
83
+ return hotMems.some((m) => matchesActiveConnectorDuplicate(m, opts)) ||
84
+ coldMems.some((m) => matchesActiveConnectorDuplicate(m, opts));
85
+ } catch (err) {
86
+ log.warn(
87
+ `connector-aware dedup scan failed for storage ${storage.dir}; writing fail-open: ${err instanceof Error ? err.message : String(err)}`,
88
+ );
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export type FactDedupWindowOutcome<T> = { suppressed: true } | { suppressed: false; result: T };
94
+
95
+ /**
96
+ * Run `write` inside the per-dir fact-dedup window: recheck `recheck` (the
97
+ * SAME confirm used at persist time, now under the lock) and suppress as a
98
+ * duplicate when a concurrent session committed the same content first;
99
+ * otherwise write. Lock contention throws (fail closed) — never an unlocked
100
+ * write; the extraction retry path retains the accepted turns.
101
+ */
102
+ export async function persistUnderFactDedupWindow<T>(
103
+ storageDir: string,
104
+ recheck: () => Promise<boolean>,
105
+ write: () => Promise<T>,
106
+ ): Promise<FactDedupWindowOutcome<T>> {
107
+ return serializeMutations(`fact-dedup:${storageDir}`, () =>
108
+ withHeldFileLock(
109
+ path.join(storageDir, "fact-dedup.lock"),
110
+ // Bounded: the window holds one corpus confirm + one memory write, not a
111
+ // scan-length lock (catalog round 5 adoption note).
112
+ { staleMs: 5_000, maxWaitMs: 2_000, pollMs: 50 },
113
+ async (acquired) => {
114
+ if (!acquired) {
115
+ throw new Error(`fact write skipped: fact-dedup.lock not acquired (${storageDir})`);
116
+ }
117
+ if (await recheck()) return { suppressed: true };
118
+ return { suppressed: false, result: await write() };
119
+ },
120
+ ),
121
+ );
122
+ }
@@ -0,0 +1,89 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ProviderCircuitBreaker } from "./provider-breaker.js";
4
+
5
+ const CONFIG = { failureThreshold: 3, cooldownMs: 5_000, authCooldownMs: 60_000 };
6
+
7
+ test("half-open probe ownership is single-flight under parallel callers", () => {
8
+ const breaker = new ProviderCircuitBreaker();
9
+ breaker.failure("provider_retryable", 1_000, CONFIG);
10
+ breaker.failure("provider_retryable", 1_100, CONFIG);
11
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
12
+ assert.deepEqual(breaker.acquire(1_300).suppressed, true, "open suppresses during cooldown");
13
+
14
+ // Cooldown expires: the FIRST caller takes the probe…
15
+ const probe = breaker.acquire(6_200);
16
+ assert.equal(probe.probe, true, "expired breaker hands one probe out");
17
+ // …siblings stay suppressed for as long as the probe is held — no wall-clock
18
+ // expiry, however long the provider attempt runs (timeouts + retries).
19
+ assert.deepEqual(breaker.acquire(600_000).suppressed, true, "a live probe keeps suppressing");
20
+ assert.deepEqual(breaker.acquire(3_600_000).suppressed, true, "no timer expiry while held");
21
+
22
+ // Probe attempt dies WITHOUT a terminal outcome (deadline/abort): explicit
23
+ // release lets the next attempt re-probe.
24
+ breaker.releaseProbe(probe.token);
25
+ const reprobe = breaker.acquire(3_600_001);
26
+ assert.equal(reprobe.probe, true, "released probe is re-acquired");
27
+ assert.deepEqual(breaker.acquire(3_600_002).suppressed, true, "re-probe is also single-flight");
28
+
29
+ // Terminal outcomes release ownership: the OWNER's failed probe re-opens
30
+ // immediately (cooldown to 6_500 + 5_000).
31
+ breaker.failure("provider_retryable", 6_500, CONFIG, reprobe.token);
32
+ assert.deepEqual(breaker.acquire(7_000).suppressed, true, "failed probe re-opens at once");
33
+ assert.deepEqual(breaker.acquire(12_000).suppressed, false, "cooldown expiry admits a fresh probe");
34
+ breaker.success();
35
+ assert.deepEqual(breaker.acquire(12_001).probe, false, "successful probe closes the breaker");
36
+ });
37
+
38
+ test("a forced attempt's failure never releases another attempt's live probe", () => {
39
+ const breaker = new ProviderCircuitBreaker();
40
+ breaker.failure("provider_retryable", 1_000, CONFIG);
41
+ breaker.failure("provider_retryable", 1_100, CONFIG);
42
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
43
+ const probe = breaker.acquire(6_200);
44
+ assert.equal(probe.probe, true, "ordinary attempt owns the probe");
45
+
46
+ // A FORCED attempt bypassed acquire, fails while the probe is in flight:
47
+ // it must not release the owner or masquerade as the probe result.
48
+ breaker.failure("provider_retryable", 6_300, CONFIG /* no token */);
49
+ assert.deepEqual(breaker.acquire(6_400), { suppressed: true, probe: false }, "probe stays single-flight");
50
+
51
+ // Only the owner's failure settles it: re-open at once.
52
+ breaker.failure("provider_retryable", 6_500, CONFIG, probe.token);
53
+ assert.deepEqual(breaker.acquire(6_600), { suppressed: true, probe: false }, "failed probe re-opens immediately");
54
+
55
+ // A released probe is re-acquired only via its own token path.
56
+ const fresh = breaker.acquire(12_000);
57
+ assert.equal(fresh.probe, true);
58
+ breaker.releaseProbe(fresh.token);
59
+ const reprobe = breaker.acquire(12_001);
60
+ assert.equal(reprobe.probe, true, "released probe is re-acquired by the next attempt");
61
+ // A non-owner release with a foreign token is a no-op.
62
+ breaker.releaseProbe({ __probeOwner: true });
63
+ assert.deepEqual(breaker.acquire(12_002), { suppressed: true, probe: false });
64
+ });
65
+
66
+ test("an unowned forced outcome neither trips the half-open threshold nor settles a held probe", () => {
67
+ const breaker = new ProviderCircuitBreaker();
68
+ breaker.failure("provider_retryable", 1_000, CONFIG);
69
+ breaker.failure("provider_retryable", 1_100, CONFIG);
70
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
71
+ const probe = breaker.acquire(6_200);
72
+ assert.equal(probe.probe, true, "ordinary attempt owns the probe");
73
+
74
+ // Forced failures bypass acquire; even crossing the threshold they must
75
+ // not reopen over a held probe (PRRT_kwDORJXyws6zxG6V).
76
+ breaker.failure("provider_retryable", 6_250, CONFIG /* no token */);
77
+ breaker.failure("provider_retryable", 6_300, CONFIG /* no token */);
78
+ breaker.failure("provider_retryable", 6_350, CONFIG /* no token */);
79
+ assert.deepEqual(breaker.acquire(6_400), { suppressed: true, probe: false }, "probe stays single-flight");
80
+
81
+ // A forced success without the owner token must not settle the probe or
82
+ // close the breaker mid-probe (PRRT_kwDORJXyws6zxG6u).
83
+ breaker.success();
84
+ assert.deepEqual(breaker.acquire(6_450), { suppressed: true, probe: false }, "probe survives an unowned success");
85
+
86
+ // The owner's success settles it for real.
87
+ breaker.success(probe.token);
88
+ assert.deepEqual(breaker.acquire(6_500).suppressed, false, "owner success closes the breaker");
89
+ });
@@ -0,0 +1,158 @@
1
+ import { log } from "../logger.js";
2
+ import type { ExtractionFailureClass } from "../types.js";
3
+
4
+ export interface ProviderBreakerConfig {
5
+ readonly failureThreshold: number;
6
+ readonly cooldownMs: number;
7
+ readonly authCooldownMs: number;
8
+ }
9
+
10
+ export interface ProbeOwnerToken {
11
+ readonly __probeOwner: true;
12
+ }
13
+
14
+ export interface ProviderBreakerSnapshot {
15
+ readonly state: "closed" | "open" | "half_open";
16
+ readonly openUntilMs: number;
17
+ readonly consecutiveFailures: number;
18
+ readonly lastReason: string;
19
+ }
20
+
21
+ export interface ProbeAcquisition {
22
+ /** True when the attempt is suppressed (breaker open, or the half-open
23
+ * probe is owned by another attempt). */
24
+ readonly suppressed: boolean;
25
+ /** True when THIS attempt owns the single half-open probe and must
26
+ * terminate it via {@link failure}/{@link success} (with its token), or
27
+ * release it via {@link releaseProbe} on any early exit (deadline, abort,
28
+ * throw). */
29
+ readonly probe: boolean;
30
+ /** Opaque ownership token; only the matching token settles or releases the
31
+ * probe, so attempts that bypass the gate (forced extractions) can never
32
+ * release another attempt's in-flight probe. */
33
+ readonly token?: ProbeOwnerToken;
34
+ }
35
+
36
+ /**
37
+ * Provider circuit breaker with single-flight half-open probe OWNERSHIP
38
+ * (codex P2 on #3311): under extractionQueueConcurrency > 1 several sessions
39
+ * can pass an expired `open` breaker in the same window, and a wall-clock
40
+ * reservation either expires under a live long provider attempt (600s
41
+ * timeouts) or pins the breaker when an attempt dies without settling. So the
42
+ * probe is OWNED, not timed: exactly one attempt holds it from the
43
+ * open→half_open flip until its terminal `failure()`/`success()`, and any
44
+ * early exit releases it explicitly. A released-but-unfinished probe lets the
45
+ * next attempt re-probe; a retry backoff still throttles that namespace.
46
+ */
47
+ export class ProviderCircuitBreaker {
48
+ private consecutiveFailures = 0;
49
+ private openUntilMs = 0;
50
+ private state: ProviderBreakerSnapshot["state"] = "closed";
51
+ private lastReason = "";
52
+ private probeHeld = false;
53
+ private probeToken: ProbeOwnerToken | null = null;
54
+
55
+ acquire(now: number): ProbeAcquisition {
56
+ if (now < this.openUntilMs) return { suppressed: true, probe: false };
57
+ if (this.state === "open") {
58
+ this.state = "half_open";
59
+ this.probeHeld = true;
60
+ const token: ProbeOwnerToken = { __probeOwner: true };
61
+ this.probeToken = token;
62
+ this.logTransition();
63
+ return { suppressed: false, probe: true, token }; // THIS attempt is the probe.
64
+ }
65
+ if (this.state === "half_open") {
66
+ if (this.probeHeld) return { suppressed: true, probe: false };
67
+ this.probeHeld = true; // previous owner released without settling; re-probe.
68
+ const token: ProbeOwnerToken = { __probeOwner: true };
69
+ this.probeToken = token;
70
+ return { suppressed: false, probe: true, token };
71
+ }
72
+ return { suppressed: false, probe: false };
73
+ }
74
+
75
+ /** Release an owned probe on an attempt that exits without a terminal
76
+ * outcome (deadline, abort, throw). Only the matching owner releases;
77
+ * idempotent; state stays half_open so the next attempt re-probes. */
78
+ releaseProbe(token?: ProbeOwnerToken): void {
79
+ if (token === undefined || this.probeToken !== token) return;
80
+ this.probeToken = null;
81
+ this.probeHeld = false;
82
+ }
83
+
84
+ failure(cls: ExtractionFailureClass, now: number, config: ProviderBreakerConfig, probeToken?: ProbeOwnerToken): void {
85
+ // Only the probe OWNER's failure settles the half-open probe: a failure
86
+ // from an attempt that bypassed acquire (forced extraction) must not
87
+ // release another attempt's in-flight probe or masquerade as its result.
88
+ const owned = probeToken !== undefined && this.probeToken === probeToken;
89
+ if (owned) {
90
+ this.probeToken = null;
91
+ this.probeHeld = false;
92
+ }
93
+ this.consecutiveFailures += 1;
94
+ const trip =
95
+ // An unowned auth_config failure (forced extraction) must not reopen
96
+ // over a held half-open probe — same ownership rule as the threshold
97
+ // term; the auth cooldown still applies once the probe settles.
98
+ (cls === "auth_config" && (this.state !== "half_open" || owned)) ||
99
+ // A failed half-open probe re-opens immediately (standard breaker
100
+ // semantics). Without this, an auth_config-opened breaker (which trips
101
+ // below the threshold) would get stuck half_open after a transient
102
+ // probe failure and stop suppressing (cursor review).
103
+ (owned && this.state === "half_open") ||
104
+ // PRRT_kwDORJXyws6zxG6V: an unowned (forced-extraction) failure must
105
+ // not cross the threshold while another attempt owns the half-open
106
+ // probe — reopening here would strand the owner's token and let the
107
+ // next cooldown mint a second concurrent probe. The failure count and
108
+ // retry bookkeeping still accumulate for when the state leaves
109
+ // half_open.
110
+ (this.state !== "half_open" && this.consecutiveFailures >= config.failureThreshold);
111
+ if (!trip) return;
112
+ const cooldown = cls === "auth_config" ? config.authCooldownMs : config.cooldownMs;
113
+ this.openUntilMs = now + Math.max(0, cooldown);
114
+ this.lastReason = cls;
115
+ if (this.state !== "open") {
116
+ this.state = "open";
117
+ this.logTransition();
118
+ }
119
+ }
120
+
121
+ success(probeToken?: ProbeOwnerToken): void {
122
+ const wasDisturbed =
123
+ this.state !== "closed" || this.consecutiveFailures > 0 || this.openUntilMs > 0;
124
+ if (!wasDisturbed) return;
125
+ // PRRT_kwDORJXyws6zxG6u: while a half-open probe is held, only its OWNER
126
+ // may settle it. An unowned (forced-extraction) success must not clear
127
+ // another run's probe or close the breaker mid-probe — the next ordinary
128
+ // acquisition could then start a second concurrent probe.
129
+ if (this.probeHeld && !(probeToken !== undefined && this.probeToken === probeToken)) return;
130
+ const wasOpen = this.state !== "closed";
131
+ this.probeToken = null;
132
+ this.probeHeld = false;
133
+ this.consecutiveFailures = 0;
134
+ this.openUntilMs = 0;
135
+ this.lastReason = "";
136
+ this.state = "closed";
137
+ if (wasOpen) this.logTransition();
138
+ }
139
+
140
+ snapshot(): ProviderBreakerSnapshot {
141
+ return {
142
+ state: this.state,
143
+ openUntilMs: this.openUntilMs,
144
+ consecutiveFailures: this.consecutiveFailures,
145
+ lastReason: this.lastReason,
146
+ };
147
+ }
148
+
149
+ private logTransition(): void {
150
+ // Once per state change (not per attempt) — AGENTS.md observability rule.
151
+ log.warn("extraction: provider circuit breaker transition", {
152
+ state: this.state,
153
+ consecutiveFailures: this.consecutiveFailures,
154
+ lastReason: this.lastReason,
155
+ openUntilMs: this.openUntilMs,
156
+ });
157
+ }
158
+ }
@@ -932,8 +932,8 @@ export class TurnIngestionCoordinator {
932
932
  }
933
933
  if (settled) return;
934
934
  const preemptable = options.preemptable === true && options.preemptRunning !== true;
935
- const accepted = this.deps.extractionQueueCoordinator.enqueue(async () => {
936
- await this.deps.extractionQueueCoordinator.runPreemptable(preemptable, options.abortSignal, async (abortSignal) => {
935
+ const accepted = this.deps.extractionQueueCoordinator.enqueue(async (preemptHandle) => {
936
+ await this.deps.extractionQueueCoordinator.runPreemptable(preemptHandle, preemptable, options.abortSignal, async (abortSignal) => {
937
937
  if (settled) return;
938
938
  if (
939
939
  typeof extractionDeadlineMs === "number" &&
@@ -1082,14 +1082,14 @@ export class TurnIngestionCoordinator {
1082
1082
  deadlineMs: extractionDeadlineMs,
1083
1083
  bufferKey,
1084
1084
  onDurableCommit: options.onDurableCommit,
1085
- onBeforePersist: () => this.deps.extractionQueueCoordinator.endPreemptable(),
1085
+ onBeforePersist: () => preemptHandle?.disarm(),
1086
1086
  abortSignal,
1087
1087
  failOnExtractionFailure: options.failOnExtractionFailure === true,
1088
1088
  writeNamespaceOverride: options.writeNamespaceOverride,
1089
1089
  principalOverride: options.principalOverride,
1090
1090
  scopeProfileWritePlan: effectiveScopePlan,
1091
1091
  forceExtractionAttempt: options.forceExtractionAttempt === true,
1092
- }, preemptable);
1092
+ }, preemptable, preemptHandle);
1093
1093
  settleTask(undefined, result);
1094
1094
  } catch (err) {
1095
1095
  const reason = abortSignal.reason;
@@ -1099,7 +1099,7 @@ export class TurnIngestionCoordinator {
1099
1099
  }
1100
1100
  }
1101
1101
  });
1102
- }, { front: options.preemptRunning === true });
1102
+ }, { front: options.preemptRunning === true, key: bufferKey });
1103
1103
  if (!accepted) {
1104
1104
  settleTask(new Error("extraction queue is stopped"));
1105
1105
  return;