From 899774d636eb6c610496d1ff0912d0118caabbd3 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 29 Apr 2026 14:24:35 +0800 Subject: [PATCH] feat: add maintenance concurrency modes --- index.js | 4 + maintenance/compressor.js | 1 + maintenance/consolidator.js | 103 ++++++++++------- maintenance/extractor.js | 2 + maintenance/task-graph-stats.js | 4 + retrieval/authority-candidate-provider.js | 59 +++++++--- retrieval/retriever.js | 2 + retrieval/shared-ranking.js | 26 ++++- runtime/concurrency.js | 134 ++++++++++++++++++++++ runtime/settings-defaults.js | 7 ++ style.css | 40 +++++++ tests/default-settings.mjs | 7 ++ tests/runtime-concurrency.mjs | 90 +++++++++++++++ ui/panel.html | 18 +++ ui/panel.js | 65 +++++++++++ 15 files changed, 503 insertions(+), 59 deletions(-) create mode 100644 runtime/concurrency.js create mode 100644 tests/runtime-concurrency.mjs diff --git a/index.js b/index.js index 10019a6..e9260e0 100644 --- a/index.js +++ b/index.js @@ -256,6 +256,7 @@ import { getPersistedSettingsSnapshot, mergePersistedSettings, } from "./runtime/settings-defaults.js"; +import { resolveConcurrencyConfig } from "./runtime/concurrency.js"; import { createDefaultAuthorityCapabilityState, normalizeAuthoritySettings, @@ -21255,6 +21256,7 @@ function applyRecallInjection(settings, recallInput, recentMessages, result) { } function buildRecallRetrieveOptions(settings, context) { + const concurrency = resolveConcurrencyConfig(settings); return { topK: settings.recallTopK, maxRecallNodes: settings.recallMaxNodes, @@ -21298,6 +21300,8 @@ function buildRecallRetrieveOptions(settings, context) { residualNmfNoveltyThreshold: settings.recallNmfNoveltyThreshold ?? 0.4, residualThreshold: settings.recallResidualThreshold ?? 0.3, residualTopK: settings.recallResidualTopK ?? 5, + vectorQueryConcurrency: concurrency.vectorQueryConcurrency, + authorityCandidateQueryConcurrency: concurrency.vectorQueryConcurrency, enableScopedMemory: settings.enableScopedMemory ?? true, enablePovMemory: settings.enablePovMemory ?? true, enableRegionScopedObjective: diff --git a/maintenance/compressor.js b/maintenance/compressor.js index 8adc669..b332d41 100644 --- a/maintenance/compressor.js +++ b/maintenance/compressor.js @@ -540,6 +540,7 @@ async function summarizeBatch( activeNodes: getActiveNodes(graph).filter( (node) => !excludedNodeIds.has(String(node?.id || "").trim()), ), + settings, rankingOptions: { topK: 12, diffusionTopK: 48, diff --git a/maintenance/consolidator.js b/maintenance/consolidator.js index e450207..d37f06b 100644 --- a/maintenance/consolidator.js +++ b/maintenance/consolidator.js @@ -29,6 +29,7 @@ import { isDirectVectorConfig, validateVectorConfig, } from "../vector/vector-index.js"; +import { resolveConcurrencyConfig, runLimited } from "../runtime/concurrency.js"; function createAbortError(message = "操作已终止") { const error = new Error(message); @@ -335,6 +336,7 @@ export async function consolidateMemories({ connections: 0, updates: 0, }; + const concurrency = resolveConcurrencyConfig(settings); if (!newNodeIds || newNodeIds.length === 0) return stats; if (!validateVectorConfig(embeddingConfig).valid) { @@ -394,24 +396,26 @@ export async function consolidateMemories({ .filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0) .map((n) => ({ nodeId: n.id, embedding: n.embedding })); - for (let i = 0; i < newEntries.length; i++) { - throwIfAborted(signal); - const entry = newEntries[i]; - const candidates = candidatePool.filter((c) => { - if (c.nodeId === entry.id) return false; - const candidateNode = getNode(graph, c.nodeId); - return canMergeTemporalScopedMemories(entry.node, candidateNode); - }); + const directNeighborResults = await runLimited( + newEntries, + async (entry, i) => { + throwIfAborted(signal); + const candidates = candidatePool.filter((c) => { + if (c.nodeId === entry.id) return false; + const candidateNode = getNode(graph, c.nodeId); + return canMergeTemporalScopedMemories(entry.node, candidateNode); + }); + + if (queryVectors?.[i] && candidates.length > 0) { + // 本地 cosine 搜索(0 API 调用) + const neighbors = searchSimilar( + queryVectors[i], + candidates, + neighborCount, + ); + return { id: entry.id, neighbors }; + } - if (queryVectors?.[i] && candidates.length > 0) { - // 本地 cosine 搜索(0 API 调用) - const neighbors = searchSimilar( - queryVectors[i], - candidates, - neighborCount, - ); - neighborsMap.set(entry.id, neighbors); - } else { // fallback: 逐条 embed try { const neighbors = await findSimilarNodesByText( @@ -422,36 +426,52 @@ export async function consolidateMemories({ activeNodes.filter((n) => n.id !== entry.id), signal, ); - neighborsMap.set(entry.id, neighbors); + return { id: entry.id, neighbors }; } catch (e) { if (isAbortError(e)) throw e; console.warn(`[ST-BME] 近邻查询失败 (${entry.id}):`, e.message); - neighborsMap.set(entry.id, []); + return { id: entry.id, neighbors: [] }; } - } + }, + { + concurrency: concurrency.neighborQueryConcurrency, + signal, + }, + ); + for (const result of directNeighborResults) { + neighborsMap.set(result.id, result.neighbors || []); } } else { // ── 后端模式: 逐条 /api/vector/query ── - for (let i = 0; i < newEntries.length; i++) { - throwIfAborted(signal); - const entry = newEntries[i]; - try { - const neighbors = await findSimilarNodesByText( - graph, - entry.text, - embeddingConfig, - neighborCount, - activeNodes.filter( - (n) => n.id !== entry.id && canMergeTemporalScopedMemories(entry.node, n), - ), - signal, - ); - neighborsMap.set(entry.id, neighbors); - } catch (e) { - if (isAbortError(e)) throw e; - console.warn(`[ST-BME] 近邻查询失败 (${entry.id}):`, e.message); - neighborsMap.set(entry.id, []); - } + const backendNeighborResults = await runLimited( + newEntries, + async (entry) => { + throwIfAborted(signal); + try { + const neighbors = await findSimilarNodesByText( + graph, + entry.text, + embeddingConfig, + neighborCount, + activeNodes.filter( + (n) => n.id !== entry.id && canMergeTemporalScopedMemories(entry.node, n), + ), + signal, + ); + return { id: entry.id, neighbors }; + } catch (e) { + if (isAbortError(e)) throw e; + console.warn(`[ST-BME] 近邻查询失败 (${entry.id}):`, e.message); + return { id: entry.id, neighbors: [] }; + } + }, + { + concurrency: concurrency.neighborQueryConcurrency, + signal, + }, + ); + for (const result of backendNeighborResults) { + neighborsMap.set(result.id, result.neighbors || []); } } @@ -522,9 +542,10 @@ export async function consolidateMemories({ recentMessages: [], embeddingConfig, signal, - activeNodes: activeNodes.filter( + activeNodes: getActiveNodes(graph).filter( (node) => !newNodeIdSet.has(String(node?.id || "").trim()), ), + settings, rankingOptions: { topK: 12, diffusionTopK: 48, diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 41d9ec8..ca422be 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -949,6 +949,7 @@ export async function extractMemories({ recentMessages: [], embeddingConfig, signal, + settings, rankingOptions: { topK: 12, diffusionTopK: 48, @@ -2338,6 +2339,7 @@ export async function generateReflection({ recentMessages: [], embeddingConfig, signal, + settings, rankingOptions: { topK: 12, diffusionTopK: 48, diff --git a/maintenance/task-graph-stats.js b/maintenance/task-graph-stats.js index 7e562f2..a51f5d6 100644 --- a/maintenance/task-graph-stats.js +++ b/maintenance/task-graph-stats.js @@ -1,6 +1,7 @@ import { getActiveNodes } from "../graph/graph.js"; import { createPromptNodeReferenceMap } from "../prompting/prompt-node-references.js"; import { rankNodesForTaskContext } from "../retrieval/shared-ranking.js"; +import { resolveConcurrencyConfig } from "../runtime/concurrency.js"; const DEFAULT_TYPE_LABELS = Object.freeze({ event: "事件", @@ -155,6 +156,7 @@ export async function buildTaskGraphStats({ signal, activeNodes = null, rankingOptions = {}, + settings = {}, relevantHeading = "与当前任务最相关的既有节点", maxRelevantNodes = 6, prefix = "G", @@ -162,6 +164,7 @@ export async function buildTaskGraphStats({ } = {}) { const normalizedActiveNodes = normalizeActiveNodes(graph, activeNodes); const normalizedUserMessage = String(userMessage || "").trim(); + const concurrency = resolveConcurrencyConfig(settings); let ranking = null; if (graph && normalizedActiveNodes.length > 0 && normalizedUserMessage) { @@ -178,6 +181,7 @@ export async function buildTaskGraphStats({ enableContextQueryBlend: false, enableMultiIntent: true, maxTextLength: 1200, + vectorQueryConcurrency: concurrency.vectorQueryConcurrency, ...rankingOptions, }, }); diff --git a/retrieval/authority-candidate-provider.js b/retrieval/authority-candidate-provider.js index 05a4133..f1fe225 100644 --- a/retrieval/authority-candidate-provider.js +++ b/retrieval/authority-candidate-provider.js @@ -10,6 +10,7 @@ import { searchAuthorityTriviumNodes, } from "../vector/authority-vector-primary-adapter.js"; import { embedText } from "../vector/embedding.js"; +import { runLimited } from "../runtime/concurrency.js"; function nowMs() { if (typeof performance?.now === "function") { @@ -148,6 +149,7 @@ export async function resolveAuthorityRecallCandidates({ fallbackReason: "", timings: { total: 0, + embed: 0, filter: 0, search: 0, neighbors: 0, @@ -245,13 +247,20 @@ export async function resolveAuthorityRecallCandidates({ diagnostics.timings.filter = roundMs(nowMs() - filterStartedAt); const searchScores = new Map(); + let embedMs = 0; const searchStartedAt = nowMs(); - for (const queryEntry of queryPlan.queries) { - try { - const queryVec = await embedText(queryEntry.text, embeddingConfig, { signal, isQuery: true }); + const searchResultsByQuery = await runLimited( + queryPlan.queries, + async (queryEntry) => { + const embedStartedAt = nowMs(); + const queryVec = await embedText(queryEntry.text, embeddingConfig, { + signal, + isQuery: true, + }); + embedMs += nowMs() - embedStartedAt; if (!queryVec) { - diagnostics.fallbackReason = diagnostics.fallbackReason || "authority-candidate-query-embed-empty"; - continue; + diagnostics.fallbackReason ||= "authority-candidate-query-embed-empty"; + return []; } const searchResults = await searchAuthorityTriviumNodes( graph, @@ -267,16 +276,38 @@ export async function resolveAuthorityRecallCandidates({ signal, }, ); - for (const result of searchResults) { - const nodeId = normalizeRecordId(result?.nodeId); - if (!nodeId || !allowedIds.has(nodeId)) continue; - const weightedScore = Math.max(0.001, Number(result?.score || 0) || 0) * queryEntry.weight; - const previous = Number(searchScores.get(nodeId) || 0) || 0; - if (weightedScore > previous) { - searchScores.set(nodeId, weightedScore); - } + return searchResults.map((result) => ({ ...result, queryWeight: queryEntry.weight })); + }, + { + concurrency: Math.max(1, Math.floor(Number(options.queryConcurrency || 1)) || 1), + signal, + failFast: false, + }, + ); + diagnostics.timings.embed = roundMs(embedMs); + for (const searchResults of searchResultsByQuery) { + if (searchResults?.error) { + diagnostics.fallbackReason ||= "authority-candidate-search-failed"; + if (embeddingConfig?.failOpen === false) { + throw searchResults.error; } - } catch (error) { + continue; + } + for (const result of searchResults || []) { + const nodeId = normalizeRecordId(result?.nodeId); + if (!nodeId || !allowedIds.has(nodeId)) continue; + const weightedScore = + Math.max(0.001, Number(result?.score || 0) || 0) * + Math.max(0.05, Number(result?.queryWeight || 0) || 0.05); + const previous = Number(searchScores.get(nodeId) || 0) || 0; + if (weightedScore > previous) { + searchScores.set(nodeId, weightedScore); + } + } + } + for (const item of searchResultsByQuery) { + if (item?.error) { + const error = item.error; if (isAbortError(error)) throw error; diagnostics.fallbackReason ||= "authority-candidate-search-failed"; if (embeddingConfig?.failOpen === false) { diff --git a/retrieval/retriever.js b/retrieval/retriever.js index a6b61c1..b5a7754 100644 --- a/retrieval/retriever.js +++ b/retrieval/retriever.js @@ -1295,6 +1295,7 @@ export async function retrieve({ limit: options.authorityCandidateLimit, neighborLimit: options.authorityCandidateNeighborLimit, minimumUsedCandidateCount: options.authorityCandidateMinCount, + queryConcurrency: options.authorityCandidateQueryConcurrency || options.vectorQueryConcurrency, enableContextQueryBlend, contextAssistantWeight, contextPreviousUserWeight, @@ -1427,6 +1428,7 @@ export async function retrieve({ enableLexicalBoost, lexicalWeight, weights, + vectorQueryConcurrency: options.vectorQueryConcurrency, activeNodes: rankingActiveNodes, }, }); diff --git a/retrieval/shared-ranking.js b/retrieval/shared-ranking.js index 05b782a..31014f1 100644 --- a/retrieval/shared-ranking.js +++ b/retrieval/shared-ranking.js @@ -3,6 +3,7 @@ import { findSimilarNodesByText, validateVectorConfig } from "../vector/vector-i import { hybridScore } from "./dynamics.js"; import { diffuseAndRank } from "./diffusion.js"; import { mergeVectorResults, splitIntentSegments } from "./retrieval-enhancer.js"; +import { runLimited } from "../runtime/concurrency.js"; function nowMs() { if (typeof performance?.now === "function") { @@ -607,9 +608,22 @@ export async function rankNodesForTaskContext({ let vectorResults = []; const vectorStartedAt = nowMs(); if (enableVectorPrefilter && vectorValidation.valid) { - const groups = []; + const vectorTasks = []; for (const part of queryPlan.plan) { for (const queryText of part.queries) { + vectorTasks.push({ + queryText, + weight: part.weight || 1, + }); + } + } + const vectorQueryConcurrency = clampPositiveInt( + options.vectorQueryConcurrency, + 1, + ); + const groups = await runLimited( + vectorTasks, + async ({ queryText, weight }) => { const results = await vectorPreFilter( graph, queryText, @@ -618,9 +632,13 @@ export async function rankNodesForTaskContext({ topK, signal, ); - groups.push(scaleVectorResults(results, part.weight || 1)); - } - } + return scaleVectorResults(results, weight); + }, + { + concurrency: vectorQueryConcurrency, + signal, + }, + ); const merged = mergeVectorResults(groups, Math.max(topK * 2, 24)); diagnostics.vectorHits = merged.rawHitCount; diff --git a/runtime/concurrency.js b/runtime/concurrency.js new file mode 100644 index 0000000..98b7a34 --- /dev/null +++ b/runtime/concurrency.js @@ -0,0 +1,134 @@ +const MODE_ALIASES = Object.freeze({ + 1: "strict", + strict: "strict", + safe: "strict", + serial: "strict", + 2: "balanced", + balance: "balanced", + balanced: "balanced", + normal: "balanced", + 3: "fast", + fast: "fast", + async: "fast", +}); + +export const MAINTENANCE_EXECUTION_MODES = Object.freeze([ + "strict", + "balanced", + "fast", +]); + +function clampInt(value, fallback, min = 1, max = 64) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, parsed)); +} + +export function normalizeMaintenanceExecutionMode(value = "strict") { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + return MODE_ALIASES[normalized] || "strict"; +} + +export function getMaintenanceExecutionModeLevel(value = "strict") { + switch (normalizeMaintenanceExecutionMode(value)) { + case "balanced": + return 2; + case "fast": + return 3; + case "strict": + default: + return 1; + } +} + +export function isStrictMaintenanceMode(value = "strict") { + return normalizeMaintenanceExecutionMode(value) === "strict"; +} + +export function isFastMaintenanceMode(value = "strict") { + return normalizeMaintenanceExecutionMode(value) === "fast"; +} + +export function resolveConcurrencyConfig(settings = {}, modeOverride = null) { + const mode = normalizeMaintenanceExecutionMode( + modeOverride ?? settings?.maintenanceExecutionMode, + ); + const strict = mode === "strict"; + return { + mode, + level: getMaintenanceExecutionModeLevel(mode), + vectorQueryConcurrency: strict + ? 1 + : clampInt(settings?.parallelVectorQueryConcurrency, 3, 1, 12), + neighborQueryConcurrency: strict + ? 1 + : clampInt(settings?.parallelNeighborQueryConcurrency, 3, 1, 12), + llmConcurrency: strict + ? 1 + : clampInt(settings?.parallelLlmConcurrency, 2, 1, 4), + backgroundMaintenanceMaxRetries: clampInt( + settings?.backgroundMaintenanceMaxRetries, + 2, + 0, + 10, + ), + backgroundMaintenanceRetryBaseMs: clampInt( + settings?.backgroundMaintenanceRetryBaseMs, + 800, + 50, + 60000, + ), + backgroundMaintenanceMaxQueueItems: clampInt( + settings?.backgroundMaintenanceMaxQueueItems, + 24, + 1, + 256, + ), + }; +} + +export function throwIfSignalAborted(signal) { + if (!signal?.aborted) return; + const reason = signal.reason; + if (reason instanceof Error) throw reason; + throw new DOMException(String(reason || "Operation aborted"), "AbortError"); +} + +export async function runLimited( + items = [], + worker, + { concurrency = 1, signal = undefined, preserveOrder = true, failFast = true } = {}, +) { + const list = Array.isArray(items) ? items : []; + const limit = clampInt(concurrency, 1, 1, Math.max(1, list.length || 1)); + const results = new Array(list.length); + let cursor = 0; + + if (typeof worker !== "function" || list.length === 0) { + return preserveOrder ? results : []; + } + + async function runWorker() { + while (cursor < list.length) { + throwIfSignalAborted(signal); + const index = cursor; + cursor += 1; + try { + results[index] = await worker(list[index], index); + } catch (error) { + if (error?.name === "AbortError") throw error; + if (failFast) throw error; + results[index] = { error }; + } + } + } + + const workers = Array.from( + { length: Math.min(limit, list.length) }, + () => runWorker(), + ); + await Promise.all(workers); + return preserveOrder ? results : results.filter((item) => item !== undefined); +} diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index e0642d4..a02b2cd 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -193,6 +193,13 @@ export const defaultSettings = { consolidationAutoMinNewNodes: 2, enableAutoCompression: true, compressionEveryN: 10, + maintenanceExecutionMode: "strict", + parallelVectorQueryConcurrency: 3, + parallelNeighborQueryConcurrency: 3, + parallelLlmConcurrency: 2, + backgroundMaintenanceMaxRetries: 2, + backgroundMaintenanceRetryBaseMs: 800, + backgroundMaintenanceMaxQueueItems: 24, // UI 面板 noticeDisplayMode: "normal", diff --git a/style.css b/style.css index 70de74f..84aed20 100644 --- a/style.css +++ b/style.css @@ -3483,6 +3483,46 @@ background: var(--bme-primary-text, #ffb2b7); } +.bme-capability-card-mode { + cursor: default; +} + +.bme-mode-segmented { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--bme-border); + border-radius: 999px; + background: var(--bme-surface-high); + flex-shrink: 0; +} + +.bme-mode-segmented button { + width: 24px; + height: 20px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--bme-on-surface-dim); + cursor: pointer; + font-size: 11px; + font-weight: 700; + line-height: 1; + transition: background 0.16s, color 0.16s, box-shadow 0.16s; +} + +.bme-mode-segmented button:hover { + color: var(--bme-on-surface); + background: var(--bme-surface-highest); +} + +.bme-mode-segmented button.is-active { + background: color-mix(in srgb, var(--bme-primary) 72%, var(--bme-surface-high)); + color: var(--bme-primary-text, #fff); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--bme-primary) 55%, transparent); +} + /* --- STRIPE ROW LAYOUT (Advanced Settings) --- */ .bme-stripe-section { diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index a7c8b8d..9538e8a 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -64,6 +64,13 @@ assert.equal(defaultSettings.enableReflection, true); assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2); assert.equal(defaultSettings.enableAutoCompression, true); assert.equal(defaultSettings.compressionEveryN, 10); +assert.equal(defaultSettings.maintenanceExecutionMode, "strict"); +assert.equal(defaultSettings.parallelVectorQueryConcurrency, 3); +assert.equal(defaultSettings.parallelNeighborQueryConcurrency, 3); +assert.equal(defaultSettings.parallelLlmConcurrency, 2); +assert.equal(defaultSettings.backgroundMaintenanceMaxRetries, 2); +assert.equal(defaultSettings.backgroundMaintenanceRetryBaseMs, 800); +assert.equal(defaultSettings.backgroundMaintenanceMaxQueueItems, 24); assert.equal(defaultSettings.cloudStorageMode, "automatic"); assert.equal(defaultSettings.worldInfoFilterMode, "default"); assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); diff --git a/tests/runtime-concurrency.mjs b/tests/runtime-concurrency.mjs new file mode 100644 index 0000000..eb02746 --- /dev/null +++ b/tests/runtime-concurrency.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; + +import { + getMaintenanceExecutionModeLevel, + normalizeMaintenanceExecutionMode, + resolveConcurrencyConfig, + runLimited, +} from "../runtime/concurrency.js"; + +assert.equal(normalizeMaintenanceExecutionMode("1"), "strict"); +assert.equal(normalizeMaintenanceExecutionMode("balanced"), "balanced"); +assert.equal(normalizeMaintenanceExecutionMode("fast"), "fast"); +assert.equal(normalizeMaintenanceExecutionMode("unknown"), "strict"); +assert.equal(getMaintenanceExecutionModeLevel("strict"), 1); +assert.equal(getMaintenanceExecutionModeLevel("balanced"), 2); +assert.equal(getMaintenanceExecutionModeLevel("fast"), 3); + +assert.deepEqual( + resolveConcurrencyConfig({ maintenanceExecutionMode: "strict" }), + { + mode: "strict", + level: 1, + vectorQueryConcurrency: 1, + neighborQueryConcurrency: 1, + llmConcurrency: 1, + backgroundMaintenanceMaxRetries: 2, + backgroundMaintenanceRetryBaseMs: 800, + backgroundMaintenanceMaxQueueItems: 24, + }, +); +assert.equal( + resolveConcurrencyConfig({ + maintenanceExecutionMode: "balanced", + parallelVectorQueryConcurrency: 5, + parallelNeighborQueryConcurrency: 4, + parallelLlmConcurrency: 3, + }).vectorQueryConcurrency, + 5, +); + +{ + let active = 0; + let maxActive = 0; + const result = await runLimited( + [1, 2, 3, 4], + async (value) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, value === 1 ? 20 : 1)); + active -= 1; + return value * 2; + }, + { concurrency: 2 }, + ); + + assert.deepEqual(result, [2, 4, 6, 8]); + assert.equal(maxActive, 2); +} + +{ + let active = 0; + let maxActive = 0; + const result = await runLimited( + [1, 2, 3], + async (value) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 1)); + active -= 1; + return value; + }, + { concurrency: 1 }, + ); + + assert.deepEqual(result, [1, 2, 3]); + assert.equal(maxActive, 1); +} + +{ + const abortError = new Error("stop"); + abortError.name = "AbortError"; + await assert.rejects( + () => runLimited([1], async () => { + throw abortError; + }, { failFast: false }), + /stop/, + ); +} + +console.log("runtime-concurrency tests passed"); diff --git a/ui/panel.html b/ui/panel.html index f482aaf..1336e7b 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1162,6 +1162,24 @@