@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.
- package/dist/access-admin-ops-surface.js +16 -16
- package/dist/access-authorization-probe.js +17 -17
- package/dist/access-boundary.js +16 -16
- package/dist/access-cli.js +60 -60
- package/dist/access-extraction-force-flush.js +16 -16
- package/dist/access-http-briefing.js +17 -17
- package/dist/access-http-query.js +18 -18
- package/dist/access-http.js +33 -33
- package/dist/access-identity-continuity-surface.js +16 -16
- package/dist/access-lcm-surface.js +16 -16
- package/dist/access-mcp.js +26 -26
- package/dist/access-namespace-preflight.js +16 -16
- package/dist/access-observe-write-surface.js +16 -16
- package/dist/access-operations-batch.js +17 -17
- package/dist/access-operations.js +24 -24
- package/dist/access-recall-concurrency.js +16 -16
- package/dist/access-recall-diagnostics.js +2 -2
- package/dist/access-recall-response.js +16 -16
- package/dist/access-recall-surface.js +16 -16
- package/dist/access-service.js +16 -16
- package/dist/access-surface-catalog.js +18 -18
- package/dist/active-recall.js +3 -3
- package/dist/briefing.js +3 -3
- package/dist/buffer.js +2 -2
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-KISOMGY2.js → chunk-3RHU5MD7.js} +2 -2
- package/dist/{chunk-3H25IRJJ.js → chunk-4MPAKYZU.js} +2 -2
- package/dist/{chunk-IPCSZLLF.js → chunk-4NV7O76Q.js} +2 -2
- package/dist/{chunk-BH7NVSEC.js → chunk-4SOWMA55.js} +3 -3
- package/dist/{chunk-GROHOWR5.js → chunk-5MGKGEL7.js} +2 -2
- package/dist/{chunk-ZPLQ27MY.js → chunk-5UM4H64E.js} +2 -2
- package/dist/{chunk-2DNFRLLL.js → chunk-5Y6VA6X2.js} +2 -2
- package/dist/{chunk-RAU25Q6J.js → chunk-6WKWL53V.js} +13 -13
- package/dist/{chunk-KFPJIKUW.js → chunk-75DCOS6I.js} +23 -23
- package/dist/{chunk-25QK3LR2.js → chunk-7O2MC42R.js} +2 -2
- package/dist/{chunk-7ETWIBTO.js → chunk-ADYP7HXE.js} +3 -3
- package/dist/{chunk-FUDKGJDN.js → chunk-AVGDYYMA.js} +2 -2
- package/dist/{chunk-ECF627KC.js → chunk-AYJQ644F.js} +2 -2
- package/dist/{chunk-T763ADEK.js → chunk-B3MWELZ3.js} +2 -2
- package/dist/{chunk-DRTSPRWL.js → chunk-B4DXREWC.js} +2 -2
- package/dist/{chunk-5UEGL5BF.js → chunk-CI3MAAIY.js} +2 -2
- package/dist/{chunk-JYXFIILI.js → chunk-CTH2CQCB.js} +2 -2
- package/dist/{chunk-UVHXLVFJ.js → chunk-EURPSF4Y.js} +2 -2
- package/dist/{chunk-VPEA63FC.js → chunk-EWJZZDRG.js} +2 -2
- package/dist/{chunk-BOZQAUAJ.js → chunk-EXQJMFRF.js} +2 -2
- package/dist/{chunk-VC65GNUS.js → chunk-GGD2YDS6.js} +2 -2
- package/dist/{chunk-5JABRT5G.js → chunk-GHHQLSSY.js} +3 -3
- package/dist/{chunk-FMYAU5MP.js → chunk-GJ4HU6LK.js} +2 -2
- package/dist/{chunk-3NJJQAL3.js → chunk-H5DIRCZP.js} +12 -12
- package/dist/{chunk-434E4BTO.js → chunk-HH73M65H.js} +3 -3
- package/dist/{chunk-LIGZO7XN.js → chunk-HPB5TF55.js} +2 -2
- package/dist/{chunk-E4QIX2OB.js → chunk-IAGVM7EY.js} +2 -2
- package/dist/{chunk-VC7GKR2V.js → chunk-IG3EZ3TF.js} +2 -2
- package/dist/{chunk-QAN5ZDRS.js → chunk-IVUVQPTQ.js} +2 -2
- package/dist/{chunk-XOFIJGB2.js → chunk-IZBIDPMY.js} +2 -2
- package/dist/{chunk-HFEGRMFG.js → chunk-JLGBJ4AX.js} +7 -7
- package/dist/{chunk-BBHKN2CN.js → chunk-JLMOH5JA.js} +2 -2
- package/dist/{chunk-TPCRDPUY.js → chunk-KZSGBWOJ.js} +2 -2
- package/dist/{chunk-SHHZWSSE.js → chunk-KZZ5EZ7O.js} +2 -2
- package/dist/{chunk-XMBDI725.js → chunk-LFYEQK45.js} +674 -518
- package/dist/chunk-LFYEQK45.js.map +1 -0
- package/dist/{chunk-A5FOXI3B.js → chunk-LN4X2NDA.js} +2 -2
- package/dist/{chunk-5ZHFPUBC.js → chunk-MOIJWWNA.js} +8 -8
- package/dist/{chunk-KO224R36.js → chunk-NCZGGGWI.js} +2 -2
- package/dist/{chunk-XVQVF2TK.js → chunk-NHWDTOBN.js} +269 -230
- package/dist/chunk-NHWDTOBN.js.map +1 -0
- package/dist/{chunk-7ZTGMEXF.js → chunk-O5ZINWW3.js} +2 -2
- package/dist/{chunk-WWH7GJ42.js → chunk-OCK4D5PC.js} +2 -2
- package/dist/{chunk-G6L3REL3.js → chunk-OPFVXM2D.js} +2 -2
- package/dist/{chunk-FR7PK7ED.js → chunk-P4TV3TQ2.js} +2 -2
- package/dist/{chunk-ZGHPELZ6.js → chunk-PMDOGXA2.js} +2 -2
- package/dist/{chunk-NM2II57L.js → chunk-PRKNAIGZ.js} +2 -1
- package/dist/chunk-PRKNAIGZ.js.map +1 -0
- package/dist/{chunk-F32JRMX2.js → chunk-QGFR73SN.js} +2 -2
- package/dist/{chunk-QNZPYH36.js → chunk-R2BIVU2Z.js} +352 -290
- package/dist/chunk-R2BIVU2Z.js.map +1 -0
- package/dist/{chunk-HG3CG557.js → chunk-RLVI2M5Q.js} +2 -2
- package/dist/{chunk-SH6ZYUTX.js → chunk-SXTVZ6JL.js} +2 -2
- package/dist/{chunk-CLSKGTAT.js → chunk-TECK5IVY.js} +2 -2
- package/dist/{chunk-SNS3DWG6.js → chunk-TVE7W67N.js} +2 -2
- package/dist/{chunk-KYSSUY66.js → chunk-U2CAGMUD.js} +2 -2
- package/dist/{chunk-XLCEFUAU.js → chunk-UVVFPCPX.js} +2 -2
- package/dist/{chunk-PL75KQGF.js → chunk-UW6LBFYN.js} +5 -5
- package/dist/{chunk-SEA56G6S.js → chunk-V4XE263I.js} +1 -1
- package/dist/chunk-V4XE263I.js.map +1 -0
- package/dist/{chunk-VSZNPAGL.js → chunk-V6SBA5O7.js} +3 -3
- package/dist/{chunk-B7VD36TI.js → chunk-WHROVQ5Q.js} +3 -3
- package/dist/{chunk-LDHR465K.js → chunk-Y3XPSGNF.js} +2 -2
- package/dist/{chunk-E6VQJ75T.js → chunk-ZORS5BG5.js} +4 -4
- package/dist/{chunk-KXY4HW2Y.js → chunk-ZXF3O7P4.js} +2 -2
- package/dist/cli.js +46 -46
- package/dist/coding/export-okf-codegraph.js +2 -2
- package/dist/compounding/engine.js +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -3
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/index.js +3 -3
- package/dist/convergence-refresh.js +5 -5
- package/dist/corpus-watermark.js +5 -5
- package/dist/entity-retrieval.js +3 -3
- package/dist/external-wiki-access.js +17 -17
- package/dist/extraction.js +6 -6
- package/dist/harmonic-retrieval.js +3 -3
- package/dist/index.js +60 -60
- package/dist/location/index.js +5 -5
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/memory-browse-operations.js +17 -17
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/storage.js +3 -3
- package/dist/operator-doctor-corpus.js +6 -6
- package/dist/operator-toolkit.js +12 -12
- package/dist/orchestration/buffer-drain-runner.js +60 -60
- package/dist/orchestration/consolidation-run.d.ts.map +1 -1
- package/dist/orchestration/extraction-drain.d.ts +4 -4
- package/dist/orchestration/extraction-drain.d.ts.map +1 -1
- package/dist/orchestration/extraction-persist.d.ts.map +1 -1
- package/dist/orchestration/extraction-queue-coordinator.d.ts +67 -33
- package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
- package/dist/orchestration/extraction-run.d.ts +1 -8
- package/dist/orchestration/extraction-run.d.ts.map +1 -1
- package/dist/orchestration/fact-dedup-window.d.ts +49 -0
- package/dist/orchestration/fact-dedup-window.d.ts.map +1 -0
- package/dist/orchestration/maintenance.js +13 -13
- package/dist/orchestration/provider-breaker.d.ts +58 -0
- package/dist/orchestration/provider-breaker.d.ts.map +1 -0
- package/dist/orchestrator.js +60 -60
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-navigate-access.js +2 -2
- package/dist/recall-navigate-disclosure.js +2 -2
- package/dist/recall-navigate.js +2 -2
- package/dist/recall-navigation-operations.js +17 -17
- package/dist/recall-navigation.js +16 -16
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.js +2 -2
- package/dist/resume-bundles.js +4 -4
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/source-agent-qualifier.js +5 -5
- package/dist/standup/build.js +3 -3
- package/dist/storage/profile-mutation.d.ts +25 -0
- package/dist/storage/profile-mutation.d.ts.map +1 -0
- package/dist/storage.d.ts +4 -6
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2 -2
- package/dist/support-passport/index.js +19 -19
- package/dist/transfer/export-okf.js +3 -3
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/verified-recall.js +3 -3
- package/dist/who-knows-cli.js +4 -4
- package/dist/who-knows.js +3 -3
- package/package.json +2 -2
- package/src/buffer-drain-handover.test.ts +3 -3
- package/src/buffer-drop-extraction-chunk.test.ts +0 -1
- package/src/config.test.ts +12 -0
- package/src/config.ts +2 -0
- package/src/fact-dedup-window.test.ts +116 -0
- package/src/orchestration/consolidation-profile-race.test.ts +178 -0
- package/src/orchestration/consolidation-run.ts +19 -7
- package/src/orchestration/extraction-drain.ts +10 -3
- package/src/orchestration/extraction-persist.ts +141 -138
- package/src/orchestration/extraction-queue-coordinator.ts +167 -56
- package/src/orchestration/extraction-run.ts +50 -93
- package/src/orchestration/fact-dedup-window.ts +122 -0
- package/src/orchestration/provider-breaker.test.ts +89 -0
- package/src/orchestration/provider-breaker.ts +158 -0
- package/src/orchestration/turn-ingestion.ts +5 -5
- package/src/orchestrator-extraction-queue.test.ts +179 -8
- package/src/orchestrator.ts +1 -1
- package/src/storage/profile-mutation.ts +119 -0
- package/src/storage-append-profile.test.ts +114 -0
- package/src/storage.ts +17 -48
- package/src/types.ts +2 -0
- package/dist/chunk-NM2II57L.js.map +0 -1
- package/dist/chunk-QNZPYH36.js.map +0 -1
- package/dist/chunk-SEA56G6S.js.map +0 -1
- package/dist/chunk-XMBDI725.js.map +0 -1
- package/dist/chunk-XVQVF2TK.js.map +0 -1
- /package/dist/{chunk-KISOMGY2.js.map → chunk-3RHU5MD7.js.map} +0 -0
- /package/dist/{chunk-3H25IRJJ.js.map → chunk-4MPAKYZU.js.map} +0 -0
- /package/dist/{chunk-IPCSZLLF.js.map → chunk-4NV7O76Q.js.map} +0 -0
- /package/dist/{chunk-BH7NVSEC.js.map → chunk-4SOWMA55.js.map} +0 -0
- /package/dist/{chunk-GROHOWR5.js.map → chunk-5MGKGEL7.js.map} +0 -0
- /package/dist/{chunk-ZPLQ27MY.js.map → chunk-5UM4H64E.js.map} +0 -0
- /package/dist/{chunk-2DNFRLLL.js.map → chunk-5Y6VA6X2.js.map} +0 -0
- /package/dist/{chunk-RAU25Q6J.js.map → chunk-6WKWL53V.js.map} +0 -0
- /package/dist/{chunk-KFPJIKUW.js.map → chunk-75DCOS6I.js.map} +0 -0
- /package/dist/{chunk-25QK3LR2.js.map → chunk-7O2MC42R.js.map} +0 -0
- /package/dist/{chunk-7ETWIBTO.js.map → chunk-ADYP7HXE.js.map} +0 -0
- /package/dist/{chunk-FUDKGJDN.js.map → chunk-AVGDYYMA.js.map} +0 -0
- /package/dist/{chunk-ECF627KC.js.map → chunk-AYJQ644F.js.map} +0 -0
- /package/dist/{chunk-T763ADEK.js.map → chunk-B3MWELZ3.js.map} +0 -0
- /package/dist/{chunk-DRTSPRWL.js.map → chunk-B4DXREWC.js.map} +0 -0
- /package/dist/{chunk-5UEGL5BF.js.map → chunk-CI3MAAIY.js.map} +0 -0
- /package/dist/{chunk-JYXFIILI.js.map → chunk-CTH2CQCB.js.map} +0 -0
- /package/dist/{chunk-UVHXLVFJ.js.map → chunk-EURPSF4Y.js.map} +0 -0
- /package/dist/{chunk-VPEA63FC.js.map → chunk-EWJZZDRG.js.map} +0 -0
- /package/dist/{chunk-BOZQAUAJ.js.map → chunk-EXQJMFRF.js.map} +0 -0
- /package/dist/{chunk-VC65GNUS.js.map → chunk-GGD2YDS6.js.map} +0 -0
- /package/dist/{chunk-5JABRT5G.js.map → chunk-GHHQLSSY.js.map} +0 -0
- /package/dist/{chunk-FMYAU5MP.js.map → chunk-GJ4HU6LK.js.map} +0 -0
- /package/dist/{chunk-3NJJQAL3.js.map → chunk-H5DIRCZP.js.map} +0 -0
- /package/dist/{chunk-434E4BTO.js.map → chunk-HH73M65H.js.map} +0 -0
- /package/dist/{chunk-LIGZO7XN.js.map → chunk-HPB5TF55.js.map} +0 -0
- /package/dist/{chunk-E4QIX2OB.js.map → chunk-IAGVM7EY.js.map} +0 -0
- /package/dist/{chunk-VC7GKR2V.js.map → chunk-IG3EZ3TF.js.map} +0 -0
- /package/dist/{chunk-QAN5ZDRS.js.map → chunk-IVUVQPTQ.js.map} +0 -0
- /package/dist/{chunk-XOFIJGB2.js.map → chunk-IZBIDPMY.js.map} +0 -0
- /package/dist/{chunk-HFEGRMFG.js.map → chunk-JLGBJ4AX.js.map} +0 -0
- /package/dist/{chunk-BBHKN2CN.js.map → chunk-JLMOH5JA.js.map} +0 -0
- /package/dist/{chunk-TPCRDPUY.js.map → chunk-KZSGBWOJ.js.map} +0 -0
- /package/dist/{chunk-SHHZWSSE.js.map → chunk-KZZ5EZ7O.js.map} +0 -0
- /package/dist/{chunk-A5FOXI3B.js.map → chunk-LN4X2NDA.js.map} +0 -0
- /package/dist/{chunk-5ZHFPUBC.js.map → chunk-MOIJWWNA.js.map} +0 -0
- /package/dist/{chunk-KO224R36.js.map → chunk-NCZGGGWI.js.map} +0 -0
- /package/dist/{chunk-7ZTGMEXF.js.map → chunk-O5ZINWW3.js.map} +0 -0
- /package/dist/{chunk-WWH7GJ42.js.map → chunk-OCK4D5PC.js.map} +0 -0
- /package/dist/{chunk-G6L3REL3.js.map → chunk-OPFVXM2D.js.map} +0 -0
- /package/dist/{chunk-FR7PK7ED.js.map → chunk-P4TV3TQ2.js.map} +0 -0
- /package/dist/{chunk-ZGHPELZ6.js.map → chunk-PMDOGXA2.js.map} +0 -0
- /package/dist/{chunk-F32JRMX2.js.map → chunk-QGFR73SN.js.map} +0 -0
- /package/dist/{chunk-HG3CG557.js.map → chunk-RLVI2M5Q.js.map} +0 -0
- /package/dist/{chunk-SH6ZYUTX.js.map → chunk-SXTVZ6JL.js.map} +0 -0
- /package/dist/{chunk-CLSKGTAT.js.map → chunk-TECK5IVY.js.map} +0 -0
- /package/dist/{chunk-SNS3DWG6.js.map → chunk-TVE7W67N.js.map} +0 -0
- /package/dist/{chunk-KYSSUY66.js.map → chunk-U2CAGMUD.js.map} +0 -0
- /package/dist/{chunk-XLCEFUAU.js.map → chunk-UVVFPCPX.js.map} +0 -0
- /package/dist/{chunk-PL75KQGF.js.map → chunk-UW6LBFYN.js.map} +0 -0
- /package/dist/{chunk-VSZNPAGL.js.map → chunk-V6SBA5O7.js.map} +0 -0
- /package/dist/{chunk-B7VD36TI.js.map → chunk-WHROVQ5Q.js.map} +0 -0
- /package/dist/{chunk-LDHR465K.js.map → chunk-Y3XPSGNF.js.map} +0 -0
- /package/dist/{chunk-E6VQJ75T.js.map → chunk-ZORS5BG5.js.map} +0 -0
- /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.
|
|
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 (
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
"extraction aborted (during_extract)",
|
|
827
|
+
const existingEntities = await runDeadlineAware(
|
|
828
|
+
() => storage.listEntityNames(),
|
|
829
|
+
"during_list_entity_names",
|
|
888
830
|
);
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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;
|