@remnic/core 10.0.70 → 10.0.71
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 +14 -14
- package/dist/access-authorization-probe.js +15 -15
- package/dist/access-boundary.js +14 -14
- package/dist/access-cli.js +58 -58
- package/dist/access-extraction-force-flush.js +14 -14
- package/dist/access-http-briefing.js +15 -15
- package/dist/access-http-query.js +16 -16
- package/dist/access-http.js +31 -31
- package/dist/access-identity-continuity-surface.js +14 -14
- package/dist/access-lcm-surface.js +14 -14
- package/dist/access-mcp.js +24 -24
- package/dist/access-namespace-preflight.js +14 -14
- package/dist/access-observe-write-surface.js +14 -14
- package/dist/access-operations-batch.js +16 -16
- package/dist/access-operations.js +23 -23
- package/dist/access-recall-concurrency.js +14 -14
- package/dist/access-recall-response.js +14 -14
- package/dist/access-recall-surface.js +14 -14
- package/dist/access-service.js +14 -14
- package/dist/access-surface-catalog.js +16 -16
- package/dist/active-recall.js +3 -3
- package/dist/briefing.js +4 -4
- package/dist/buffer-turn-helpers.d.ts +10 -1
- package/dist/buffer-turn-helpers.d.ts.map +1 -1
- package/dist/buffer-turn-helpers.js +3 -1
- package/dist/buffer.d.ts +4 -0
- package/dist/buffer.d.ts.map +1 -1
- package/dist/buffer.js +3 -3
- package/dist/causal-consolidation.js +5 -5
- package/dist/{chunk-B7YAIHAV.js → chunk-23BA3USG.js} +10 -10
- package/dist/{chunk-3F7N4I5I.js → chunk-2R44BBPK.js} +2 -2
- package/dist/{chunk-BW33YTF3.js → chunk-2WPWEVAH.js} +2 -2
- package/dist/{chunk-EF3HNVL4.js → chunk-4PMFSYH7.js} +2 -2
- package/dist/{chunk-J5TVHCDG.js → chunk-5ORJHH43.js} +2 -2
- package/dist/{chunk-MK7HXGM6.js → chunk-6KBCUIZP.js} +2 -2
- package/dist/{chunk-OX6GY22Q.js → chunk-7P5VBRBS.js} +2 -2
- package/dist/{chunk-3AASGFR4.js → chunk-7WFY2L5J.js} +3 -3
- package/dist/{chunk-YLMTFRZR.js → chunk-AGRMXXVA.js} +2 -2
- package/dist/{chunk-WIW4LOZL.js → chunk-AZNE2H6D.js} +2 -2
- package/dist/{chunk-GT4G2S2Y.js → chunk-BCBY5GYZ.js} +2 -2
- package/dist/{chunk-EIZ4MK33.js → chunk-BTU77RB5.js} +2 -2
- package/dist/{chunk-ZS5LREUZ.js → chunk-DC3DWF2M.js} +2 -2
- package/dist/{chunk-LTST4R7U.js → chunk-DE4U5PTV.js} +49 -45
- package/dist/chunk-DE4U5PTV.js.map +1 -0
- package/dist/{chunk-OAQO5AZJ.js → chunk-DSSTOV5M.js} +440 -267
- package/dist/chunk-DSSTOV5M.js.map +1 -0
- package/dist/{chunk-VDYRXI3E.js → chunk-EGKDONTC.js} +9 -3
- package/dist/chunk-EGKDONTC.js.map +1 -0
- package/dist/{chunk-FM6UKZAE.js → chunk-EQ6EMJRG.js} +2 -2
- package/dist/{chunk-TSZP7GGJ.js → chunk-FGGLVCHI.js} +2 -2
- package/dist/{chunk-K7LLGCPH.js → chunk-GXAZ4Q2S.js} +2 -2
- package/dist/{chunk-2GV24KFD.js → chunk-H2X32F2K.js} +2 -2
- package/dist/{chunk-2YPIFJHL.js → chunk-ICBUTKLL.js} +2 -2
- package/dist/{chunk-3W37NITP.js → chunk-IQXQ3KWR.js} +2 -2
- package/dist/{chunk-HNF7IMYQ.js → chunk-J72ZY7NU.js} +2 -2
- package/dist/{chunk-6QS5NQJH.js → chunk-JGLZ46JS.js} +2 -2
- package/dist/{chunk-MRN2MKB7.js → chunk-JY4LXMZR.js} +2 -2
- package/dist/{chunk-UJ2FP2RV.js → chunk-KY3KJUNN.js} +2 -2
- package/dist/{chunk-64U42JL5.js → chunk-LHMCZ6VW.js} +10 -10
- package/dist/{chunk-YRMSHA7H.js → chunk-LN7CWLAU.js} +2 -2
- package/dist/{chunk-XR4QR3JT.js → chunk-LYOCAUI6.js} +3 -3
- package/dist/{chunk-VA3GNAWQ.js → chunk-LZIOY7ZU.js} +4 -5
- package/dist/chunk-LZIOY7ZU.js.map +1 -0
- package/dist/{chunk-GMSRLDVR.js → chunk-N2Y6COEM.js} +2 -2
- package/dist/{chunk-XDUP6SWG.js → chunk-OGQGDOYK.js} +2 -2
- package/dist/{chunk-U7MIMS2Y.js → chunk-OUBEJWVI.js} +17 -1
- package/dist/chunk-OUBEJWVI.js.map +1 -0
- package/dist/{chunk-5FMGPV34.js → chunk-OWZXDBV4.js} +2 -2
- package/dist/{chunk-SQO5SEKA.js → chunk-PJAMFIDE.js} +3 -3
- package/dist/{chunk-I6HRHJRA.js → chunk-QIOQI7QZ.js} +2 -2
- package/dist/{chunk-OAKOAOEG.js → chunk-QUPTDKSX.js} +22 -22
- package/dist/{chunk-MCFI6G7U.js → chunk-QVZZE6VH.js} +3 -3
- package/dist/{chunk-LYTKNK5O.js → chunk-S37O4S6E.js} +2 -2
- package/dist/{chunk-ILD7N3XC.js → chunk-S73ZVU37.js} +2 -2
- package/dist/{chunk-5VQSKBNF.js → chunk-U6IL4724.js} +2 -2
- package/dist/{chunk-D3AGBN6G.js → chunk-UAOJVQXC.js} +2 -2
- package/dist/{chunk-MKMIY3E7.js → chunk-UE5JYPU6.js} +7 -7
- package/dist/{chunk-K47G2CBP.js → chunk-USX5YIKB.js} +5 -5
- package/dist/{chunk-YIG4HPY4.js → chunk-VK5HKVQ7.js} +2 -2
- package/dist/{chunk-ZOBYTRBU.js → chunk-VNTQU6KB.js} +47 -2
- package/dist/chunk-VNTQU6KB.js.map +1 -0
- package/dist/{chunk-THFSCUFC.js → chunk-VSJ3ARFS.js} +2 -2
- package/dist/{chunk-4BOBW6H4.js → chunk-W4V7P5HP.js} +2 -2
- package/dist/{chunk-DM3UZUCN.js → chunk-WNQ3B3Q5.js} +2 -2
- package/dist/{chunk-MFKBYXBY.js → chunk-WYOHQJKC.js} +6 -6
- package/dist/{chunk-66PEACZP.js → chunk-X4DHAXFO.js} +3 -3
- package/dist/{chunk-ZYEWTVNQ.js → chunk-XPZM7GP6.js} +7 -7
- package/dist/{chunk-2BBMCMJS.js → chunk-YPYSC3TX.js} +2 -2
- package/dist/{chunk-JGQEF33K.js → chunk-ZBET3W26.js} +2 -2
- package/dist/chunk-ZODKV27J.js +443 -0
- package/dist/chunk-ZODKV27J.js.map +1 -0
- package/dist/cli.js +43 -43
- package/dist/coding/export-okf-codegraph.js +3 -3
- package/dist/compounding/engine.js +5 -5
- package/dist/config.js +3 -3
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/index.js +4 -4
- package/dist/convergence-refresh.js +6 -6
- package/dist/corpus-watermark.js +6 -6
- package/dist/entity-retrieval.js +4 -4
- package/dist/external-wiki-access.js +15 -15
- package/dist/extraction-window-drain.d.ts.map +1 -1
- package/dist/extraction-window-drain.js +1 -1
- package/dist/extraction.js +7 -7
- package/dist/harmonic-retrieval.js +4 -4
- package/dist/index.js +58 -58
- package/dist/location/index.js +6 -6
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection-runner.js +6 -6
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/memory-browse-operations.js +15 -15
- package/dist/namespaces/migrate.js +5 -5
- package/dist/namespaces/storage.js +4 -4
- package/dist/operator-doctor-corpus.js +7 -7
- package/dist/operator-toolkit.js +12 -12
- package/dist/orchestration/backlog-scheduler.d.ts +151 -0
- package/dist/orchestration/backlog-scheduler.d.ts.map +1 -0
- package/dist/orchestration/backlog-scheduler.js +891 -0
- package/dist/orchestration/backlog-scheduler.js.map +1 -0
- package/dist/orchestration/buffer-drain-runner.d.ts +31 -27
- package/dist/orchestration/buffer-drain-runner.d.ts.map +1 -1
- package/dist/orchestration/buffer-drain-runner.js +64 -269
- package/dist/orchestration/buffer-drain-runner.js.map +1 -1
- package/dist/orchestration/extraction-drain.d.ts +50 -17
- package/dist/orchestration/extraction-drain.d.ts.map +1 -1
- package/dist/orchestration/extraction-queue-coordinator.d.ts +19 -1
- package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
- package/dist/orchestration/extraction-run.d.ts +8 -0
- package/dist/orchestration/extraction-run.d.ts.map +1 -1
- package/dist/orchestration/extraction-task-queueing.d.ts +44 -0
- package/dist/orchestration/extraction-task-queueing.d.ts.map +1 -0
- package/dist/orchestration/maintenance.js +14 -14
- package/dist/orchestration/turn-ingestion.d.ts +11 -5
- package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
- package/dist/orchestrator.d.ts +13 -0
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +59 -59
- package/dist/recall-navigation-operations.js +15 -15
- package/dist/recall-navigation.js +14 -14
- package/dist/resume-bundles.js +3 -3
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/source-agent-qualifier.js +6 -6
- package/dist/standup/build.js +4 -4
- package/dist/storage/buffer-drain-claims.d.ts +15 -0
- package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
- package/dist/storage/buffer-drain-claims.js +2 -2
- package/dist/storage/buffer-session-files.js +2 -2
- package/dist/storage.js +3 -3
- package/dist/support-passport/index.js +17 -17
- package/dist/transfer/export-okf.js +4 -4
- package/dist/verified-recall.js +4 -4
- package/dist/who-knows-cli.js +5 -5
- package/dist/who-knows.js +4 -4
- package/package.json +12 -2
- package/src/backlog-scheduler.test.ts +806 -0
- package/src/buffer-drain-handover.test.ts +363 -18
- package/src/buffer-drain-inprocess.test.ts +355 -0
- package/src/buffer-drain-model-boundary.test.ts +1062 -0
- package/src/buffer-drop-extraction-chunk.test.ts +237 -22
- package/src/buffer-turn-helpers.ts +26 -1
- package/src/buffer.ts +8 -0
- package/src/extraction-window-drain.ts +7 -7
- package/src/orchestration/backlog-scheduler.ts +481 -0
- package/src/orchestration/buffer-drain-runner.ts +545 -180
- package/src/orchestration/extraction-drain.ts +287 -108
- package/src/orchestration/extraction-queue-coordinator.ts +51 -3
- package/src/orchestration/extraction-run.test.ts +41 -0
- package/src/orchestration/extraction-run.ts +24 -5
- package/src/orchestration/extraction-task-queueing.ts +87 -0
- package/src/orchestration/turn-ingestion.ts +46 -59
- package/src/orchestrator-extraction-queue.test.ts +46 -2
- package/src/orchestrator-flush.test.ts +4 -1
- package/src/orchestrator.ts +47 -21
- package/src/storage/buffer-drain-claims.ts +55 -0
- package/dist/chunk-LTST4R7U.js.map +0 -1
- package/dist/chunk-OAQO5AZJ.js.map +0 -1
- package/dist/chunk-U7MIMS2Y.js.map +0 -1
- package/dist/chunk-VA3GNAWQ.js.map +0 -1
- package/dist/chunk-VDYRXI3E.js.map +0 -1
- package/dist/chunk-ZOBYTRBU.js.map +0 -1
- /package/dist/{chunk-B7YAIHAV.js.map → chunk-23BA3USG.js.map} +0 -0
- /package/dist/{chunk-3F7N4I5I.js.map → chunk-2R44BBPK.js.map} +0 -0
- /package/dist/{chunk-BW33YTF3.js.map → chunk-2WPWEVAH.js.map} +0 -0
- /package/dist/{chunk-EF3HNVL4.js.map → chunk-4PMFSYH7.js.map} +0 -0
- /package/dist/{chunk-J5TVHCDG.js.map → chunk-5ORJHH43.js.map} +0 -0
- /package/dist/{chunk-MK7HXGM6.js.map → chunk-6KBCUIZP.js.map} +0 -0
- /package/dist/{chunk-OX6GY22Q.js.map → chunk-7P5VBRBS.js.map} +0 -0
- /package/dist/{chunk-3AASGFR4.js.map → chunk-7WFY2L5J.js.map} +0 -0
- /package/dist/{chunk-YLMTFRZR.js.map → chunk-AGRMXXVA.js.map} +0 -0
- /package/dist/{chunk-WIW4LOZL.js.map → chunk-AZNE2H6D.js.map} +0 -0
- /package/dist/{chunk-GT4G2S2Y.js.map → chunk-BCBY5GYZ.js.map} +0 -0
- /package/dist/{chunk-EIZ4MK33.js.map → chunk-BTU77RB5.js.map} +0 -0
- /package/dist/{chunk-ZS5LREUZ.js.map → chunk-DC3DWF2M.js.map} +0 -0
- /package/dist/{chunk-FM6UKZAE.js.map → chunk-EQ6EMJRG.js.map} +0 -0
- /package/dist/{chunk-TSZP7GGJ.js.map → chunk-FGGLVCHI.js.map} +0 -0
- /package/dist/{chunk-K7LLGCPH.js.map → chunk-GXAZ4Q2S.js.map} +0 -0
- /package/dist/{chunk-2GV24KFD.js.map → chunk-H2X32F2K.js.map} +0 -0
- /package/dist/{chunk-2YPIFJHL.js.map → chunk-ICBUTKLL.js.map} +0 -0
- /package/dist/{chunk-3W37NITP.js.map → chunk-IQXQ3KWR.js.map} +0 -0
- /package/dist/{chunk-HNF7IMYQ.js.map → chunk-J72ZY7NU.js.map} +0 -0
- /package/dist/{chunk-6QS5NQJH.js.map → chunk-JGLZ46JS.js.map} +0 -0
- /package/dist/{chunk-MRN2MKB7.js.map → chunk-JY4LXMZR.js.map} +0 -0
- /package/dist/{chunk-UJ2FP2RV.js.map → chunk-KY3KJUNN.js.map} +0 -0
- /package/dist/{chunk-64U42JL5.js.map → chunk-LHMCZ6VW.js.map} +0 -0
- /package/dist/{chunk-YRMSHA7H.js.map → chunk-LN7CWLAU.js.map} +0 -0
- /package/dist/{chunk-XR4QR3JT.js.map → chunk-LYOCAUI6.js.map} +0 -0
- /package/dist/{chunk-GMSRLDVR.js.map → chunk-N2Y6COEM.js.map} +0 -0
- /package/dist/{chunk-XDUP6SWG.js.map → chunk-OGQGDOYK.js.map} +0 -0
- /package/dist/{chunk-5FMGPV34.js.map → chunk-OWZXDBV4.js.map} +0 -0
- /package/dist/{chunk-SQO5SEKA.js.map → chunk-PJAMFIDE.js.map} +0 -0
- /package/dist/{chunk-I6HRHJRA.js.map → chunk-QIOQI7QZ.js.map} +0 -0
- /package/dist/{chunk-OAKOAOEG.js.map → chunk-QUPTDKSX.js.map} +0 -0
- /package/dist/{chunk-MCFI6G7U.js.map → chunk-QVZZE6VH.js.map} +0 -0
- /package/dist/{chunk-LYTKNK5O.js.map → chunk-S37O4S6E.js.map} +0 -0
- /package/dist/{chunk-ILD7N3XC.js.map → chunk-S73ZVU37.js.map} +0 -0
- /package/dist/{chunk-5VQSKBNF.js.map → chunk-U6IL4724.js.map} +0 -0
- /package/dist/{chunk-D3AGBN6G.js.map → chunk-UAOJVQXC.js.map} +0 -0
- /package/dist/{chunk-MKMIY3E7.js.map → chunk-UE5JYPU6.js.map} +0 -0
- /package/dist/{chunk-K47G2CBP.js.map → chunk-USX5YIKB.js.map} +0 -0
- /package/dist/{chunk-YIG4HPY4.js.map → chunk-VK5HKVQ7.js.map} +0 -0
- /package/dist/{chunk-THFSCUFC.js.map → chunk-VSJ3ARFS.js.map} +0 -0
- /package/dist/{chunk-4BOBW6H4.js.map → chunk-W4V7P5HP.js.map} +0 -0
- /package/dist/{chunk-DM3UZUCN.js.map → chunk-WNQ3B3Q5.js.map} +0 -0
- /package/dist/{chunk-MFKBYXBY.js.map → chunk-WYOHQJKC.js.map} +0 -0
- /package/dist/{chunk-66PEACZP.js.map → chunk-X4DHAXFO.js.map} +0 -0
- /package/dist/{chunk-ZYEWTVNQ.js.map → chunk-XPZM7GP6.js.map} +0 -0
- /package/dist/{chunk-2BBMCMJS.js.map → chunk-YPYSC3TX.js.map} +0 -0
- /package/dist/{chunk-JGQEF33K.js.map → chunk-ZBET3W26.js.map} +0 -0
|
@@ -1,35 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Out-of-process extraction-buffer drain (`remnic buffer drain`).
|
|
3
|
-
*
|
|
4
|
-
* The runner owns ONE claimed session at a time and drives it through the
|
|
5
|
-
* SAME extraction pipeline the daemon uses — the same chunking
|
|
6
|
-
* (`chunkExtractionWindow` + the model's input budget), the same retry /
|
|
7
|
-
* provider-circuit semantics, the same dropped-chunk side-file recovery. It
|
|
8
|
-
* adds no extraction logic of its own.
|
|
9
|
-
*
|
|
10
|
-
* The difference is ownership: the claimed session's turns live in a drain
|
|
11
|
-
* claim (a durable file), not in a live buffer. So the runner wires a
|
|
12
|
-
* claim-backed `SmartBuffer` into the extraction queue instead of the live
|
|
13
|
-
* one. Every chunk that extracts successfully is removed from the claim
|
|
14
|
-
* immediately, which makes the drain resumable: a kill at any instant
|
|
15
|
-
* leaves the un-extracted turns in the claim, and the next run continues
|
|
16
|
-
* from there.
|
|
17
|
-
*
|
|
18
|
-
* Concurrency lives in the CLI (worker processes, one session each at a
|
|
19
|
-
* time): the in-process extraction queue is serial by design, so a single
|
|
20
|
-
* runner never runs two sessions' extractions at once.
|
|
21
|
-
*/
|
|
1
|
+
/** Claim-backed extraction-buffer drain (`remnic buffer drain` worker AND the */
|
|
22
2
|
|
|
23
|
-
import type { BufferStorage } from "../buffer-persistence.js";
|
|
24
|
-
import { BufferDrainClaims, type DrainLease } from "../storage/buffer-drain-claims.js";
|
|
25
|
-
import type { BufferEntryState, BufferState } from "../types.js";
|
|
26
3
|
import { hostname } from "node:os";
|
|
27
|
-
import {
|
|
4
|
+
import type { BufferStorage } from "../buffer-persistence.js";
|
|
5
|
+
import { type ExtractionScopeGroup, describeError, groupTurnsByExtractionScope } from "../buffer-turn-helpers.js";
|
|
6
|
+
import { SmartBuffer } from "../buffer.js";
|
|
7
|
+
import { resolveMemoryLifecycleCapabilities } from "../capabilities.js";
|
|
8
|
+
import { shouldSkipImplicitExtraction } from "../explicit-capture.js";
|
|
28
9
|
import { log } from "../logger.js";
|
|
10
|
+
import type { ResolvedScopeProfilePlan } from "../namespaces/scope-profiles.js";
|
|
11
|
+
import type { Orchestrator } from "../orchestrator.js";
|
|
12
|
+
import { headerConnector, renderExtractionConversation, resolveSourceConnector } from "../source-agent-qualifier.js";
|
|
13
|
+
import type { BufferDrainClaims, DrainLease } from "../storage/buffer-drain-claims.js";
|
|
14
|
+
import type { BufferEntryState, BufferState, BufferTurn, PluginConfig } from "../types.js";
|
|
15
|
+
import { ExtractionDrain } from "./extraction-drain.js";
|
|
16
|
+
import type { ExtractionRunResult } from "./extraction-run.js";
|
|
29
17
|
import { selfDeps } from "./self-deps.js";
|
|
30
18
|
import { TurnIngestionCoordinator, type TurnIngestionDeps } from "./turn-ingestion.js";
|
|
31
|
-
import type { ExtractionRunResult } from "./extraction-run.js";
|
|
32
|
-
import type { Orchestrator } from "../orchestrator.js";
|
|
33
19
|
|
|
34
20
|
export type DrainSessionStatus = "drained" | "deferred" | "failed" | "skipped";
|
|
35
21
|
|
|
@@ -46,8 +32,24 @@ export interface DrainSessionOutcome {
|
|
|
46
32
|
durableOutputCount: number;
|
|
47
33
|
/** Why the session was skipped or deferred (machine-readable). */
|
|
48
34
|
reason?: string;
|
|
35
|
+
/** Current-policy denials: scope groups whose authorization no longer holds. */
|
|
36
|
+
denied?: { reason: string; turns: number }[];
|
|
49
37
|
}
|
|
50
38
|
|
|
39
|
+
/** Extraction results that mean "current policy refuses this scope": the
|
|
40
|
+
* turns are valid backlog whose captured scope is no longer writable. */
|
|
41
|
+
const DENIED_SCOPE_REASONS: ReadonlySet<string> = new Set([
|
|
42
|
+
"pinned_namespace_unavailable",
|
|
43
|
+
"namespace_write_revoked",
|
|
44
|
+
"scope_profile_revoked",
|
|
45
|
+
// The resolved profile has no writable layer: the same current-policy
|
|
46
|
+
// refusal family, surfaced from inside the shared runExtraction gate.
|
|
47
|
+
"scope_profile_no_writable_layer",
|
|
48
|
+
// captureMode=explicit: ordinary buffered conversation turns never create
|
|
49
|
+
// memories; only deliberately accepted captures drain.
|
|
50
|
+
"capture_mode_explicit",
|
|
51
|
+
]);
|
|
52
|
+
|
|
51
53
|
export interface BufferDrainRunnerOptions {
|
|
52
54
|
/** Identity of this drainer; owns the lease it takes. */
|
|
53
55
|
ownerId: string;
|
|
@@ -73,12 +75,12 @@ const DEFAULT_LEASE_MS = 120_000;
|
|
|
73
75
|
* Writes are the drain's progress (what is left to extract), so every
|
|
74
76
|
* successfully extracted chunk is durably removed before the next one runs.
|
|
75
77
|
*/
|
|
76
|
-
class ClaimBufferStorage implements BufferStorage {
|
|
78
|
+
export class ClaimBufferStorage implements BufferStorage {
|
|
77
79
|
constructor(
|
|
78
80
|
private readonly claims: BufferDrainClaims,
|
|
79
81
|
private readonly key: string,
|
|
80
82
|
private readonly ownerId: string,
|
|
81
|
-
private readonly entry: BufferEntryState
|
|
83
|
+
private readonly entry: BufferEntryState
|
|
82
84
|
) {}
|
|
83
85
|
|
|
84
86
|
async loadBuffer(): Promise<BufferState> {
|
|
@@ -100,7 +102,12 @@ class ClaimBufferStorage implements BufferStorage {
|
|
|
100
102
|
async saveBuffer(state: BufferState): Promise<void> {
|
|
101
103
|
// Whole-state fallback (the claim covers one session): persist the entry.
|
|
102
104
|
const entry = state.entries?.[this.key];
|
|
103
|
-
await this.claims.writeEntry(
|
|
105
|
+
await this.claims.writeEntry(
|
|
106
|
+
this.key,
|
|
107
|
+
this.ownerId,
|
|
108
|
+
entry ?? { turns: [], lastExtractionAt: null, extractionCount: 0 },
|
|
109
|
+
this.entry
|
|
110
|
+
);
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
async saveBufferSession(key: string, entry: BufferEntryState): Promise<void> {
|
|
@@ -113,6 +120,471 @@ class ClaimBufferStorage implements BufferStorage {
|
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
/** Per-invocation isolated execution context for one claim drain. */
|
|
124
|
+
interface ClaimDrainExecution {
|
|
125
|
+
claimBuffer: SmartBuffer;
|
|
126
|
+
coordinator: TurnIngestionCoordinator;
|
|
127
|
+
executionDrain: ExtractionDrain;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The scopeProfileWritePlan option a scope group forwards to the queue. */
|
|
131
|
+
interface GroupPlanOption {
|
|
132
|
+
scopeProfileWritePlan?: ResolvedScopeProfilePlan | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Durable counters a drain pass contributed. */
|
|
136
|
+
interface GroupCounts {
|
|
137
|
+
persistedCount: number;
|
|
138
|
+
durableOutputCount: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Pending turns across canonical + retained copies (the honest backlog count). */
|
|
142
|
+
function pendingTurnCount(entry: BufferEntryState | undefined): number {
|
|
143
|
+
if (!entry) return 0;
|
|
144
|
+
return entry.turns.length + (entry.retainedTurns?.length ?? 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Load one claimed session. An unclaimed key is nothing to drain. */
|
|
148
|
+
async function loadClaimedSession(
|
|
149
|
+
claims: BufferDrainClaims,
|
|
150
|
+
key: string
|
|
151
|
+
): Promise<
|
|
152
|
+
| { kind: "notClaimed"; notClaimed: DrainSessionOutcome }
|
|
153
|
+
| { kind: "claimed"; claim: { entry: BufferEntryState }; turnsAtStart: number }
|
|
154
|
+
> {
|
|
155
|
+
const claim = await claims.load(key);
|
|
156
|
+
if (!claim) {
|
|
157
|
+
return {
|
|
158
|
+
kind: "notClaimed",
|
|
159
|
+
notClaimed: {
|
|
160
|
+
key,
|
|
161
|
+
status: "skipped",
|
|
162
|
+
turnsAtStart: 0,
|
|
163
|
+
turnsRemaining: 0,
|
|
164
|
+
persistedCount: 0,
|
|
165
|
+
durableOutputCount: 0,
|
|
166
|
+
reason: "not_claimed",
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { kind: "claimed", claim, turnsAtStart: pendingTurnCount(claim.entry) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function busyLeaseOutcome(key: string, turnsAtStart: number): DrainSessionOutcome {
|
|
174
|
+
return {
|
|
175
|
+
key,
|
|
176
|
+
status: "skipped",
|
|
177
|
+
turnsAtStart,
|
|
178
|
+
turnsRemaining: turnsAtStart,
|
|
179
|
+
persistedCount: 0,
|
|
180
|
+
durableOutputCount: 0,
|
|
181
|
+
reason: "claimed_by_another_drain",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Take the lease unless this drainer already holds it. A busy lease means
|
|
186
|
+
* another drainer owns the claim — skip without touching content. */
|
|
187
|
+
async function adoptLease(
|
|
188
|
+
claims: BufferDrainClaims,
|
|
189
|
+
key: string,
|
|
190
|
+
ownerId: string,
|
|
191
|
+
leaseMs: number
|
|
192
|
+
): Promise<DrainSessionOutcome | null> {
|
|
193
|
+
const held = await claims.adopt(key, {
|
|
194
|
+
ownerId,
|
|
195
|
+
pid: process.pid,
|
|
196
|
+
host: hostname(),
|
|
197
|
+
acquiredAt: new Date().toISOString(),
|
|
198
|
+
heartbeatAt: new Date().toISOString(),
|
|
199
|
+
leaseMs,
|
|
200
|
+
});
|
|
201
|
+
return held === "busy" ? busyLeaseOutcome(key, pendingTurnCount((await claims.load(key))?.entry)) : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function assembleOutcome(
|
|
205
|
+
key: string,
|
|
206
|
+
turnsAtStart: number,
|
|
207
|
+
verdict: { status: DrainSessionStatus; reason?: string; turnsRemaining: number },
|
|
208
|
+
counts: { persistedCount: number; durableOutputCount: number },
|
|
209
|
+
denied: Map<string, number>
|
|
210
|
+
): DrainSessionOutcome {
|
|
211
|
+
const outcome: DrainSessionOutcome = {
|
|
212
|
+
key,
|
|
213
|
+
status: verdict.status,
|
|
214
|
+
turnsAtStart,
|
|
215
|
+
turnsRemaining: verdict.turnsRemaining,
|
|
216
|
+
persistedCount: counts.persistedCount,
|
|
217
|
+
durableOutputCount: counts.durableOutputCount,
|
|
218
|
+
};
|
|
219
|
+
if (denied.size > 0) outcome.denied = [...denied].map(([deniedReason, turns]) => ({ reason: deniedReason, turns }));
|
|
220
|
+
if (verdict.reason !== undefined) outcome.reason = verdict.reason;
|
|
221
|
+
return outcome;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Ack an EMPTY claim. The ack verdict is the truth: live ingress can merge
|
|
225
|
+
* between the empty read and the ack — a not_empty ack means pending work
|
|
226
|
+
* exists; reload it, release OUR lease, report deferred, never a false
|
|
227
|
+
* drained. */
|
|
228
|
+
async function ackEmptyClaim(
|
|
229
|
+
claims: BufferDrainClaims,
|
|
230
|
+
key: string,
|
|
231
|
+
ownerId: string,
|
|
232
|
+
turnsAtStart: number
|
|
233
|
+
): Promise<DrainSessionOutcome> {
|
|
234
|
+
const ackOutcome = await claims.ack(key, ownerId);
|
|
235
|
+
if (ackOutcome !== "acked") {
|
|
236
|
+
const reloaded = await claims.load(key);
|
|
237
|
+
const turnsRemaining = pendingTurnCount(reloaded?.entry);
|
|
238
|
+
await claims.releaseLease(key, ownerId);
|
|
239
|
+
return {
|
|
240
|
+
key,
|
|
241
|
+
status: "deferred",
|
|
242
|
+
turnsAtStart,
|
|
243
|
+
turnsRemaining,
|
|
244
|
+
persistedCount: 0,
|
|
245
|
+
durableOutputCount: 0,
|
|
246
|
+
reason: ackOutcome === "not_empty" ? "ack_not_empty" : "ack_not_owner",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { key, status: "drained", turnsAtStart, turnsRemaining: 0, persistedCount: 0, durableOutputCount: 0 };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** another drainer took the claim, so this run must stop instead of writing */
|
|
253
|
+
function startLeaseHeartbeat(
|
|
254
|
+
claims: BufferDrainClaims,
|
|
255
|
+
key: string,
|
|
256
|
+
ownerId: string,
|
|
257
|
+
leaseMs: number,
|
|
258
|
+
abort: AbortController
|
|
259
|
+
): NodeJS.Timeout {
|
|
260
|
+
const heartbeat = setInterval(
|
|
261
|
+
() => {
|
|
262
|
+
void claims
|
|
263
|
+
.heartbeat(key, ownerId, leaseMs)
|
|
264
|
+
.then((state) => {
|
|
265
|
+
if (state === "lost") abort.abort(new Error(`buffer drain: lease lost for session ${key}`));
|
|
266
|
+
})
|
|
267
|
+
.catch((err: Error) => {
|
|
268
|
+
// A heartbeat that cannot be renewed is a lease that cannot be held:
|
|
269
|
+
// stop this run cleanly at the next chunk boundary (the next run
|
|
270
|
+
// resumes it).
|
|
271
|
+
abort.abort(new Error(`buffer drain: lease heartbeat failed for session ${key}: ${describeError(err)}`));
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
Math.max(1_000, Math.floor(leaseMs / 3))
|
|
275
|
+
);
|
|
276
|
+
heartbeat.unref?.();
|
|
277
|
+
return heartbeat;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** coordinator bound to it, and the no-drop chunked drain. Built per */
|
|
281
|
+
function buildClaimExecution(
|
|
282
|
+
orchestrator: Orchestrator,
|
|
283
|
+
claims: BufferDrainClaims,
|
|
284
|
+
key: string,
|
|
285
|
+
ownerId: string,
|
|
286
|
+
claim: { entry: BufferEntryState }
|
|
287
|
+
): ClaimDrainExecution {
|
|
288
|
+
const claimBuffer = new SmartBuffer(orchestrator.config, new ClaimBufferStorage(claims, key, ownerId, claim.entry));
|
|
289
|
+
const isolatedRun = orchestrator.extractionRunCoordinatorForBuffer(claimBuffer);
|
|
290
|
+
const coordinator = new TurnIngestionCoordinator(selfDeps<TurnIngestionDeps>(orchestrator));
|
|
291
|
+
const config = orchestrator.config;
|
|
292
|
+
const ingestionDeps = selfDeps<TurnIngestionDeps>(orchestrator);
|
|
293
|
+
// No-drop: a repeatedly failing chunk stays in the claim as durable
|
|
294
|
+
// retryable backlog — never side-filed, never counted as processed.
|
|
295
|
+
const executionDrain = new ExtractionDrain(
|
|
296
|
+
{
|
|
297
|
+
config: {
|
|
298
|
+
extractionMinUserTurns: config.extractionMinUserTurns,
|
|
299
|
+
extractionMinChars: config.extractionMinChars,
|
|
300
|
+
extractionMaxTurnChars: config.extractionMaxTurnChars,
|
|
301
|
+
extractionChunkDropAfterFailures: config.extractionChunkDropAfterFailures,
|
|
302
|
+
},
|
|
303
|
+
extractionWindowBudgetChars: () => ingestionDeps.extractionWindowBudgetChars?.() ?? Number.POSITIVE_INFINITY,
|
|
304
|
+
// Render-exact chunk sizing: the model clamp bounds the CONVERSATION
|
|
305
|
+
// (truncateLocalExtractionConversation), so measure exactly that —
|
|
306
|
+
// with the SAME source-classification gate extract() applies when it
|
|
307
|
+
// prepends the connector header (header margin included when the
|
|
308
|
+
// engine would render it).
|
|
309
|
+
measureRenderedTurns: (turns) => {
|
|
310
|
+
const sourceConnector = resolveMemoryLifecycleCapabilities(config).extractionScopeClassification
|
|
311
|
+
? resolveSourceConnector(turns)
|
|
312
|
+
: undefined;
|
|
313
|
+
return renderExtractionConversation(
|
|
314
|
+
turns.map((turn) => ({ ...turn, content: turn.content.trim().slice(0, config.extractionMaxTurnChars) })),
|
|
315
|
+
headerConnector(sourceConnector)
|
|
316
|
+
).conversation.length;
|
|
317
|
+
},
|
|
318
|
+
runExtraction: (turns, runOptions) => isolatedRun.runExtraction(turns, runOptions),
|
|
319
|
+
getStorage: () => ingestionDeps.getStorage(),
|
|
320
|
+
buffer: claimBuffer,
|
|
321
|
+
},
|
|
322
|
+
{ dropFailedChunks: false }
|
|
323
|
+
);
|
|
324
|
+
return { claimBuffer, coordinator, executionDrain };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Plan semantics for one scope group: a pinned plan forwards as-is; a
|
|
328
|
+
* planless ACCEPTED group pins null (existing flush behavior); unpinned
|
|
329
|
+
* legacy groups pass NO option so runExtraction resolves the CURRENT
|
|
330
|
+
* profile. */
|
|
331
|
+
function planOptionForGroup(group: ExtractionScopeGroup): GroupPlanOption {
|
|
332
|
+
if (group.scopeProfileWritePlan !== undefined) return { scopeProfileWritePlan: group.scopeProfileWritePlan };
|
|
333
|
+
if (group.turns.some((turn) => turn.acceptedCapture === true)) return { scopeProfileWritePlan: null };
|
|
334
|
+
return {};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** captureMode=explicit: ordinary buffered conversation turns never create
|
|
338
|
+
* memories — only deliberately accepted captures drain. */
|
|
339
|
+
function explicitModeDenied(group: ExtractionScopeGroup, config: PluginConfig): boolean {
|
|
340
|
+
return shouldSkipImplicitExtraction(config) && !group.turns.some((turn) => turn.acceptedCapture === true);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface GroupOutcome {
|
|
344
|
+
turns: number;
|
|
345
|
+
result?: ExtractionRunResult;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface ScopeDrainContext {
|
|
349
|
+
config: PluginConfig;
|
|
350
|
+
key: string;
|
|
351
|
+
signal: AbortSignal;
|
|
352
|
+
claimBuffer: SmartBuffer;
|
|
353
|
+
coordinator: TurnIngestionCoordinator;
|
|
354
|
+
executionDrain: ExtractionDrain;
|
|
355
|
+
turns: BufferTurn[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Drain a claimed session's turns ONE captured-scope group at a time: each
|
|
359
|
+
* group keeps its own namespace/principal/plan pins, and the shared
|
|
360
|
+
* extraction boundary re-applies CURRENT authorization from the turn's
|
|
361
|
+
* ORIGINAL coding binding before any write. Groups run sequentially (FIFO
|
|
362
|
+
* per key — the queue's same-key exclusion would serialize them anyway); a
|
|
363
|
+
* denied group never blocks or clears the other groups. */
|
|
364
|
+
async function drainScopeGroups(
|
|
365
|
+
ctx: ScopeDrainContext
|
|
366
|
+
): Promise<{ groupOutcomes: GroupOutcome[]; groupError?: DrainExtractionError }> {
|
|
367
|
+
const groupOutcomes: GroupOutcome[] = [];
|
|
368
|
+
let groupError: DrainExtractionError | undefined;
|
|
369
|
+
for (const group of groupTurnsByExtractionScope(ctx.turns)) {
|
|
370
|
+
if (ctx.signal.aborted) break;
|
|
371
|
+
if (explicitModeDenied(group, ctx.config)) {
|
|
372
|
+
log.warn(
|
|
373
|
+
`buffer drain: session ${ctx.key} denied capture_mode_explicit (${group.turns.length} ordinary turn(s) retained)`
|
|
374
|
+
);
|
|
375
|
+
groupOutcomes.push({
|
|
376
|
+
turns: group.turns.length,
|
|
377
|
+
result: { status: "skipped", reason: "capture_mode_explicit", persistedCount: 0, durableOutputCount: 0 },
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const settleGate = Promise.withResolvers<{ cause?: unknown; result?: ExtractionRunResult }>();
|
|
382
|
+
let done = false;
|
|
383
|
+
const onSettle = (cause: unknown, result?: ExtractionRunResult): void => {
|
|
384
|
+
if (done) return;
|
|
385
|
+
done = true;
|
|
386
|
+
settleGate.resolve({ cause, result });
|
|
387
|
+
};
|
|
388
|
+
// Settlement and callback EXIT are tracked separately: the lease is
|
|
389
|
+
// released only after BOTH the queued closure settled and fully exited
|
|
390
|
+
// (an abort settles through its listener while the closure may still be
|
|
391
|
+
// persisting).
|
|
392
|
+
const exited = Promise.withResolvers<void>();
|
|
393
|
+
void ctx.coordinator
|
|
394
|
+
.queueBufferedExtraction(group.turns, "trigger_mode", {
|
|
395
|
+
bufferKey: ctx.key,
|
|
396
|
+
clearBufferAfterExtraction: true,
|
|
397
|
+
clearMatchingTurns: true,
|
|
398
|
+
skipDedupeCheck: true,
|
|
399
|
+
skipCharThreshold: true,
|
|
400
|
+
skipUserTurnThreshold: true,
|
|
401
|
+
// Automatic background work yields to fail-closed foreground flushes
|
|
402
|
+
// (preemptRunning), same as the live trigger path.
|
|
403
|
+
preemptable: true,
|
|
404
|
+
abortSignal: ctx.signal,
|
|
405
|
+
writeNamespaceOverride: group.writeNamespaceOverride,
|
|
406
|
+
principalOverride: group.principalOverride,
|
|
407
|
+
...planOptionForGroup(group),
|
|
408
|
+
execution: { buffer: ctx.claimBuffer, drain: ctx.executionDrain },
|
|
409
|
+
onTaskSettled: (error, result) => settleGate.resolve({ cause: error, result }),
|
|
410
|
+
onTaskExit: () => exited.resolve(),
|
|
411
|
+
})
|
|
412
|
+
.catch((cause: DrainExtractionError) => onSettle(cause));
|
|
413
|
+
const [verdict] = await Promise.all([settleGate.promise, exited.promise]);
|
|
414
|
+
const settled = verdict;
|
|
415
|
+
if (settled.cause !== undefined) {
|
|
416
|
+
// SAFETY: settle captures whatever the queued closure rejected with;
|
|
417
|
+
// every drain rejection site throws Error values, and a rejection is
|
|
418
|
+
// always a DrainExtractionError in this path.
|
|
419
|
+
groupError = settled.cause as Error;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
groupOutcomes.push({ turns: group.turns.length, result: settled.result });
|
|
423
|
+
}
|
|
424
|
+
return { groupOutcomes, groupError };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Denied scope groups: policy refusals leave their turns pending (the group
|
|
428
|
+
* result's turns were NOT cleared), aggregated per reason so a drain is
|
|
429
|
+
* never reported complete while denied backlog remains. */
|
|
430
|
+
function deniedByReason(groupOutcomes: readonly GroupOutcome[]): Map<string, number> {
|
|
431
|
+
const denied = new Map<string, number>();
|
|
432
|
+
for (const entry of groupOutcomes) {
|
|
433
|
+
if (entry.result?.status !== "skipped" || !DENIED_SCOPE_REASONS.has(entry.result.reason ?? "")) continue;
|
|
434
|
+
denied.set(entry.result.reason!, (denied.get(entry.result.reason!) ?? 0) + entry.turns);
|
|
435
|
+
}
|
|
436
|
+
return denied;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function lastGroupReason(groupOutcomes: readonly GroupOutcome[]): string | undefined {
|
|
440
|
+
for (let index = groupOutcomes.length - 1; index >= 0; index -= 1) {
|
|
441
|
+
const reason = groupOutcomes[index]?.result?.reason;
|
|
442
|
+
if (reason !== undefined) return reason;
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** the session file is gone for good — but the ack verdict is the truth (live */
|
|
448
|
+
async function settleDrainVerdict(
|
|
449
|
+
claims: BufferDrainClaims,
|
|
450
|
+
key: string,
|
|
451
|
+
ownerId: string,
|
|
452
|
+
groupOutcomes: readonly GroupOutcome[],
|
|
453
|
+
groupError: DrainExtractionError | undefined,
|
|
454
|
+
turnsRemaining: number
|
|
455
|
+
): Promise<{ status: DrainSessionStatus; reason?: string; turnsRemaining: number }> {
|
|
456
|
+
if (turnsRemaining === 0) return await ackOrReload(claims, key, ownerId);
|
|
457
|
+
if (groupError) {
|
|
458
|
+
await claims.releaseLease(key, ownerId);
|
|
459
|
+
return { status: "failed", reason: describeError(groupError), turnsRemaining };
|
|
460
|
+
}
|
|
461
|
+
await claims.releaseLease(key, ownerId);
|
|
462
|
+
const reason =
|
|
463
|
+
groupOutcomes.find((entry) => entry.result?.reason !== undefined && DENIED_SCOPE_REASONS.has(entry.result.reason!))
|
|
464
|
+
?.result?.reason ??
|
|
465
|
+
lastGroupReason(groupOutcomes) ??
|
|
466
|
+
"deferred";
|
|
467
|
+
return { status: "deferred", reason, turnsRemaining };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Ack an emptied claim; a not-acked verdict means live ingress merged (or
|
|
471
|
+
* the lease moved) — reload the honest pending count and release OUR lease,
|
|
472
|
+
* keeping the source. */
|
|
473
|
+
async function ackOrReload(
|
|
474
|
+
claims: BufferDrainClaims,
|
|
475
|
+
key: string,
|
|
476
|
+
ownerId: string
|
|
477
|
+
): Promise<{ status: DrainSessionStatus; reason?: string; turnsRemaining: number }> {
|
|
478
|
+
const ackOutcome = await claims.ack(key, ownerId);
|
|
479
|
+
if (ackOutcome === "acked") return { status: "drained", turnsRemaining: 0 };
|
|
480
|
+
const reloaded = await claims.load(key);
|
|
481
|
+
const turnsRemaining = pendingTurnCount(reloaded?.entry);
|
|
482
|
+
await claims.releaseLease(key, ownerId);
|
|
483
|
+
return { status: "deferred", reason: ackOutcome === "not_empty" ? "ack_not_empty" : "ack_not_owner", turnsRemaining };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function groupCounts(groupOutcomes: readonly GroupOutcome[]): GroupCounts {
|
|
487
|
+
return {
|
|
488
|
+
persistedCount: groupOutcomes.reduce((sum, entry) => sum + (entry.result?.persistedCount ?? 0), 0),
|
|
489
|
+
durableOutputCount: groupOutcomes.reduce((sum, entry) => sum + (entry.result?.durableOutputCount ?? 0), 0),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface DrainScanEntry {
|
|
494
|
+
key: string;
|
|
495
|
+
turns: number;
|
|
496
|
+
claimed: boolean;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Session-buffer listings: a claimed session reports its CLAIM's pending
|
|
500
|
+
* count (the live buffer copy may lag the claim), an unclaimed one its
|
|
501
|
+
* buffer entry. */
|
|
502
|
+
async function bufferSessionListings(
|
|
503
|
+
claims: BufferDrainClaims,
|
|
504
|
+
entries: Record<string, BufferEntryState>
|
|
505
|
+
): Promise<{ listings: DrainScanEntry[]; seen: Set<string> }> {
|
|
506
|
+
const listings: DrainScanEntry[] = [];
|
|
507
|
+
const seen = new Set<string>();
|
|
508
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
509
|
+
seen.add(key);
|
|
510
|
+
const claim = claims.isClaimed(key) ? await claims.load(key) : null;
|
|
511
|
+
listings.push({
|
|
512
|
+
key,
|
|
513
|
+
turns: claim ? pendingTurnCount(claim.entry) : pendingTurnCount(entry),
|
|
514
|
+
claimed: claim !== null,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return { listings, seen };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Claims the buffer state no longer lists (drained by an earlier run whose
|
|
521
|
+
* claim file outlived its session file). */
|
|
522
|
+
async function orphanClaimListings(claims: BufferDrainClaims, seen: Set<string>): Promise<DrainScanEntry[]> {
|
|
523
|
+
const out: DrainScanEntry[] = [];
|
|
524
|
+
for (const listed of await claims.list()) {
|
|
525
|
+
if (seen.has(listed.key)) continue;
|
|
526
|
+
out.push({ key: listed.key, turns: listed.turnCount, claimed: true });
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The drain runs on the caller's abort signal combined with this run's own
|
|
532
|
+
* lease-loss controller. */
|
|
533
|
+
function drainAbortSignal(callerSignal: AbortSignal | undefined, abort: AbortController): AbortSignal {
|
|
534
|
+
return callerSignal ? AbortSignal.any([callerSignal, abort.signal]) : abort.signal;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** One heartbeat-protected claim drain: run every scope group, settle the
|
|
538
|
+
* verdict against the claim store, and assemble the honest outcome. The
|
|
539
|
+
* heartbeat is cleared by the caller on every exit path. */
|
|
540
|
+
async function runClaimDrain(
|
|
541
|
+
ctx: {
|
|
542
|
+
orchestrator: Orchestrator;
|
|
543
|
+
claims: BufferDrainClaims;
|
|
544
|
+
key: string;
|
|
545
|
+
ownerId: string;
|
|
546
|
+
claimBuffer: SmartBuffer;
|
|
547
|
+
coordinator: TurnIngestionCoordinator;
|
|
548
|
+
executionDrain: ExtractionDrain;
|
|
549
|
+
turns: BufferTurn[];
|
|
550
|
+
turnsAtStart: number;
|
|
551
|
+
config: PluginConfig;
|
|
552
|
+
signal: AbortSignal;
|
|
553
|
+
},
|
|
554
|
+
options: BufferDrainRunnerOptions,
|
|
555
|
+
heartbeat: NodeJS.Timeout
|
|
556
|
+
): Promise<DrainSessionOutcome> {
|
|
557
|
+
// An emptied claim acks here: the ack verdict is the truth (drained), or
|
|
558
|
+
// live ingress merged between the read and the ack — deferred, source kept.
|
|
559
|
+
if (ctx.turns.length === 0) {
|
|
560
|
+
const outcome = await ackEmptyClaim(ctx.claims, ctx.key, ctx.ownerId, ctx.turnsAtStart);
|
|
561
|
+
options.onEvent?.({ type: "session_finished", key: ctx.key, outcome });
|
|
562
|
+
return outcome;
|
|
563
|
+
}
|
|
564
|
+
const { groupOutcomes, groupError } = await drainScopeGroups(ctx);
|
|
565
|
+
// Heartbeat cleared on EVERY exit path: a rejected drain must not keep
|
|
566
|
+
// renewing the lease forever (cleared in the caller's finally).
|
|
567
|
+
const remaining = await ctx.claims.load(ctx.key);
|
|
568
|
+
const { status, reason, turnsRemaining } = await settleDrainVerdict(
|
|
569
|
+
ctx.claims,
|
|
570
|
+
ctx.key,
|
|
571
|
+
ctx.ownerId,
|
|
572
|
+
groupOutcomes,
|
|
573
|
+
groupError,
|
|
574
|
+
pendingTurnCount(remaining?.entry)
|
|
575
|
+
);
|
|
576
|
+
const outcome = assembleOutcome(
|
|
577
|
+
ctx.key,
|
|
578
|
+
ctx.turnsAtStart,
|
|
579
|
+
{ status, reason, turnsRemaining },
|
|
580
|
+
groupCounts(groupOutcomes),
|
|
581
|
+
deniedByReason(groupOutcomes)
|
|
582
|
+
);
|
|
583
|
+
log.info(`buffer drain: session ${ctx.key} ${status} (${outcome.turnsRemaining} turn(s) remaining)`);
|
|
584
|
+
options.onEvent?.({ type: "session_finished", key: ctx.key, outcome });
|
|
585
|
+
return outcome;
|
|
586
|
+
}
|
|
587
|
+
|
|
116
588
|
export class BufferDrainRunner {
|
|
117
589
|
constructor(private readonly orchestrator: Orchestrator) {}
|
|
118
590
|
|
|
@@ -128,150 +600,59 @@ export class BufferDrainRunner {
|
|
|
128
600
|
async drainSession(key: string, options: BufferDrainRunnerOptions): Promise<DrainSessionOutcome> {
|
|
129
601
|
const claims = this.claims;
|
|
130
602
|
const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
host: hostname(),
|
|
135
|
-
acquiredAt: new Date().toISOString(),
|
|
136
|
-
heartbeatAt: new Date().toISOString(),
|
|
137
|
-
leaseMs,
|
|
138
|
-
};
|
|
139
|
-
const claim = await claims.load(key);
|
|
140
|
-
if (!claim) {
|
|
141
|
-
return { key, status: "skipped", turnsAtStart: 0, turnsRemaining: 0, persistedCount: 0, durableOutputCount: 0, reason: "not_claimed" };
|
|
142
|
-
}
|
|
143
|
-
const turnsAtStart = claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0);
|
|
603
|
+
const claimed = await loadClaimedSession(claims, key);
|
|
604
|
+
if (claimed.kind === "notClaimed") return claimed.notClaimed;
|
|
605
|
+
const { claim, turnsAtStart } = claimed;
|
|
144
606
|
options.onEvent?.({ type: "session_started", key, turns: turnsAtStart });
|
|
145
607
|
|
|
146
608
|
// Take the lease unless this drainer already holds it.
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
const outcome
|
|
150
|
-
key,
|
|
151
|
-
status: "skipped",
|
|
152
|
-
turnsAtStart,
|
|
153
|
-
turnsRemaining: turnsAtStart,
|
|
154
|
-
persistedCount: 0,
|
|
155
|
-
durableOutputCount: 0,
|
|
156
|
-
reason: "claimed_by_another_drain",
|
|
157
|
-
};
|
|
609
|
+
const busy = await adoptLease(claims, key, options.ownerId, leaseMs);
|
|
610
|
+
if (busy !== null) {
|
|
611
|
+
const outcome = busy;
|
|
158
612
|
options.onEvent?.({ type: "session_finished", key, outcome });
|
|
159
613
|
return outcome;
|
|
160
614
|
}
|
|
161
615
|
|
|
162
|
-
// The claim is the buffer for this run: every successful chunk is
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
616
|
+
// The claim is the buffer for this run: every successful chunk is removed
|
|
617
|
+
// from it, so a kill leaves exactly the un-extracted turns.
|
|
618
|
+
const { claimBuffer, coordinator, executionDrain } = buildClaimExecution(
|
|
619
|
+
this.orchestrator,
|
|
620
|
+
claims,
|
|
621
|
+
key,
|
|
622
|
+
options.ownerId,
|
|
623
|
+
claim
|
|
624
|
+
);
|
|
625
|
+
await claimBuffer.load();
|
|
168
626
|
|
|
169
627
|
// The claim's turns (retained first, like the live buffer's order).
|
|
170
628
|
const turns = [...(claim.entry.retainedTurns ?? []), ...claim.entry.turns];
|
|
171
|
-
if (turns.length === 0) {
|
|
172
|
-
await claims.ack(key, options.ownerId);
|
|
173
|
-
const outcome: DrainSessionOutcome = {
|
|
174
|
-
key,
|
|
175
|
-
status: "drained",
|
|
176
|
-
turnsAtStart,
|
|
177
|
-
turnsRemaining: 0,
|
|
178
|
-
persistedCount: 0,
|
|
179
|
-
durableOutputCount: 0,
|
|
180
|
-
};
|
|
181
|
-
options.onEvent?.({ type: "session_finished", key, outcome });
|
|
182
|
-
return outcome;
|
|
183
|
-
}
|
|
184
629
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// writing progress into a claim it no longer owns.
|
|
630
|
+
// A lost lease means another drainer took the claim: stop instead of
|
|
631
|
+
// writing progress into a claim this run no longer owns.
|
|
188
632
|
const abort = new AbortController();
|
|
189
|
-
const signal =
|
|
190
|
-
const heartbeat =
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
bufferKey: key,
|
|
212
|
-
clearBufferAfterExtraction: true,
|
|
213
|
-
// The drain owns this session's content: the daemon's own dedupe
|
|
214
|
-
// gate must not skip it, and the thresholds do not apply to a
|
|
215
|
-
// backlog.
|
|
216
|
-
skipDedupeCheck: true,
|
|
217
|
-
skipCharThreshold: true,
|
|
218
|
-
skipUserTurnThreshold: true,
|
|
219
|
-
abortSignal: signal,
|
|
220
|
-
onTaskSettled: (error, result) => {
|
|
221
|
-
if (settled) return;
|
|
222
|
-
settled = true;
|
|
223
|
-
resolve({ error, result });
|
|
224
|
-
},
|
|
225
|
-
})
|
|
226
|
-
.catch((error: DrainExtractionError) => {
|
|
227
|
-
if (settled) return;
|
|
228
|
-
settled = true;
|
|
229
|
-
resolve({ error });
|
|
230
|
-
});
|
|
231
|
-
return await promise;
|
|
232
|
-
});
|
|
233
|
-
clearInterval(heartbeat);
|
|
234
|
-
const remaining = await claims.load(key);
|
|
235
|
-
const turnsRemaining = remaining
|
|
236
|
-
? remaining.entry.turns.length + (remaining.entry.retainedTurns?.length ?? 0)
|
|
237
|
-
: 0;
|
|
238
|
-
const result = settledResult.result;
|
|
239
|
-
const persistedCount = result?.persistedCount ?? 0;
|
|
240
|
-
const durableOutputCount = result?.durableOutputCount ?? 0;
|
|
241
|
-
|
|
242
|
-
let status: DrainSessionStatus;
|
|
243
|
-
let reason: string | undefined;
|
|
244
|
-
if (turnsRemaining === 0) {
|
|
245
|
-
// Everything extracted (or dropped with a recovery side-file): the
|
|
246
|
-
// claim is finished and the session file is gone for good.
|
|
247
|
-
await claims.ack(key, options.ownerId);
|
|
248
|
-
status = "drained";
|
|
249
|
-
} else if (settledResult.error) {
|
|
250
|
-
// A failed run keeps the remaining turns for the next attempt.
|
|
251
|
-
await claims.releaseLease(key, options.ownerId);
|
|
252
|
-
status = "failed";
|
|
253
|
-
reason = describeError(settledResult.error);
|
|
254
|
-
} else {
|
|
255
|
-
// Deferred: provider breaker / backoff / a chunk still pending. The
|
|
256
|
-
// content stays claimed and the next run resumes it.
|
|
257
|
-
await claims.releaseLease(key, options.ownerId);
|
|
258
|
-
status = "deferred";
|
|
259
|
-
reason = result?.reason ?? "deferred";
|
|
633
|
+
const signal = drainAbortSignal(options.signal, abort);
|
|
634
|
+
const heartbeat = startLeaseHeartbeat(claims, key, options.ownerId, leaseMs, abort);
|
|
635
|
+
try {
|
|
636
|
+
return await runClaimDrain(
|
|
637
|
+
{
|
|
638
|
+
orchestrator: this.orchestrator,
|
|
639
|
+
claims,
|
|
640
|
+
key,
|
|
641
|
+
ownerId: options.ownerId,
|
|
642
|
+
claimBuffer,
|
|
643
|
+
coordinator,
|
|
644
|
+
executionDrain,
|
|
645
|
+
turns,
|
|
646
|
+
turnsAtStart,
|
|
647
|
+
config: this.orchestrator.config,
|
|
648
|
+
signal,
|
|
649
|
+
},
|
|
650
|
+
options,
|
|
651
|
+
heartbeat
|
|
652
|
+
);
|
|
653
|
+
} finally {
|
|
654
|
+
clearInterval(heartbeat);
|
|
260
655
|
}
|
|
261
|
-
log.info(
|
|
262
|
-
`buffer drain: session ${key} ${status} (${turnsRemaining} turn(s) remaining)`,
|
|
263
|
-
);
|
|
264
|
-
const outcome: DrainSessionOutcome = {
|
|
265
|
-
key,
|
|
266
|
-
status,
|
|
267
|
-
turnsAtStart,
|
|
268
|
-
turnsRemaining,
|
|
269
|
-
persistedCount,
|
|
270
|
-
durableOutputCount,
|
|
271
|
-
};
|
|
272
|
-
if (reason !== undefined) outcome.reason = reason;
|
|
273
|
-
options.onEvent?.({ type: "session_finished", key, outcome });
|
|
274
|
-
return outcome;
|
|
275
656
|
}
|
|
276
657
|
|
|
277
658
|
/** Claim a session file for the drain. Returns false when it is not claimable. */
|
|
@@ -288,25 +669,9 @@ export class BufferDrainRunner {
|
|
|
288
669
|
}
|
|
289
670
|
|
|
290
671
|
/** Sessions with buffered turns, claimed or not (CLI scan). */
|
|
291
|
-
async scan(): Promise<
|
|
672
|
+
async scan(): Promise<DrainScanEntry[]> {
|
|
292
673
|
const sessions = await this.orchestrator.storage.loadBuffer();
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
const seen = new Set<string>();
|
|
296
|
-
for (const [key, entry] of Object.entries(entries)) {
|
|
297
|
-
seen.add(key);
|
|
298
|
-
const claim = this.claims.isClaimed(key) ? await this.claims.load(key) : null;
|
|
299
|
-
const turns = claim
|
|
300
|
-
? claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0)
|
|
301
|
-
: entry.turns.length + (entry.retainedTurns?.length ?? 0);
|
|
302
|
-
out.push({ key, turns, claimed: claim !== null });
|
|
303
|
-
}
|
|
304
|
-
// Claims the buffer state no longer lists (drained by an earlier run
|
|
305
|
-
// whose claim file outlived its session file).
|
|
306
|
-
for (const listed of await this.claims.list()) {
|
|
307
|
-
if (seen.has(listed.key)) continue;
|
|
308
|
-
out.push({ key: listed.key, turns: listed.turnCount, claimed: true });
|
|
309
|
-
}
|
|
310
|
-
return out;
|
|
674
|
+
const { listings, seen } = await bufferSessionListings(this.claims, sessions.entries ?? {});
|
|
675
|
+
return [...listings, ...(await orphanClaimListings(this.claims, seen))];
|
|
311
676
|
}
|
|
312
677
|
}
|