From 663ba5a3cdc183b3c139b20539b241a8a2103333 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 20:08:49 +0000 Subject: [PATCH] perf: speed up test parallelism --- docs/help/testing.md | 6 +- package.json | 1 + scripts/test-planner/executor.mjs | 181 ++- scripts/test-planner/planner.mjs | 91 +- scripts/test-planner/runtime-profile.mjs | 12 +- scripts/test-runner-manifest.mjs | 10 + scripts/test-update-timings.mjs | 42 +- src/config/sessions/store.ts | 115 +- src/test-utils/session-state-cleanup.test.ts | 58 + src/test-utils/session-state-cleanup.ts | 6 +- test/fixtures/test-timings.extensions.json | 1031 ++++++++++++++++++ test/scripts/test-parallel.test.ts | 41 +- test/vitest-config.test.ts | 22 + 13 files changed, 1517 insertions(+), 99 deletions(-) create mode 100644 src/test-utils/session-state-cleanup.test.ts create mode 100644 test/fixtures/test-timings.extensions.json diff --git a/docs/help/testing.md b/docs/help/testing.md index 829ee76d689..fa5be3734e8 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -55,10 +55,14 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Should be fast and stable - Scheduler note: - `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files. + - Extension-only local runs now also use a checked-in extensions timing snapshot so the shared extensions lane can split into a few measured batches instead of one oversized run. + - High-memory local channel runs now reuse the checked-in channel timing snapshot to split the shared channels lane into a few measured batches instead of one long shared worker. - Shared unit, extension, channel, and gateway runs all stay on Vitest `forks`. - The wrapper keeps measured fork-isolated exceptions and heavy singleton lanes explicit in `test/fixtures/test-parallel.behavior.json`. - The wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list. - - Refresh the timing snapshot with `pnpm test:perf:update-timings` after major suite shape changes. + - For surface-only local runs, unit, extension, and channel shared lanes can overlap their isolated hotspots instead of waiting behind one serial prefix. + - For multi-surface local runs, the wrapper keeps the shared surface phases ordered, but batches inside the same shared phase now fan out together, deferred isolated work can overlap the next shared phase, and spare `unit-fast` headroom now starts that deferred work earlier instead of leaving those slots idle. + - Refresh the timing snapshots with `pnpm test:perf:update-timings` and `pnpm test:perf:update-timings:extensions` after major suite shape changes. - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. diff --git a/package.json b/package.json index 98d22530d32..6f41b3544f0 100644 --- a/package.json +++ b/package.json @@ -750,6 +750,7 @@ "test:perf:profile:runner": "node scripts/run-vitest-profile.mjs runner", "test:perf:update-memory-hotspots": "node scripts/test-update-memory-hotspots.mjs", "test:perf:update-timings": "node scripts/test-update-timings.mjs", + "test:perf:update-timings:extensions": "node scripts/test-update-timings.mjs --config vitest.extensions.config.ts", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:serial": "node scripts/test-parallel.mjs --profile serial", "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", diff --git a/scripts/test-planner/executor.mjs b/scripts/test-planner/executor.mjs index 5eee7a3d4f8..b39544dd69d 100644 --- a/scripts/test-planner/executor.mjs +++ b/scripts/test-planner/executor.mjs @@ -132,6 +132,62 @@ export function formatExplanation(explanation) { ].join("\n"); } +const buildOrderedParallelSegments = (units) => { + const segments = []; + let deferredUnits = []; + for (const unit of units) { + if (unit.serialPhase) { + if (deferredUnits.length > 0) { + segments.push({ type: "deferred", units: deferredUnits }); + deferredUnits = []; + } + const lastSegment = segments.at(-1); + if (lastSegment?.type === "serialPhase" && lastSegment.phase === unit.serialPhase) { + lastSegment.units.push(unit); + } else { + segments.push({ type: "serialPhase", phase: unit.serialPhase, units: [unit] }); + } + continue; + } + deferredUnits.push(unit); + } + if (deferredUnits.length > 0) { + segments.push({ type: "deferred", units: deferredUnits }); + } + return segments; +}; + +const prioritizeDeferredUnitsForPhase = (units, phase) => { + const preferredSurface = + phase === "extensions" || phase === "channels" ? phase : phase === "unit-fast" ? "unit" : null; + if (preferredSurface === null) { + return units; + } + const preferred = []; + const remaining = []; + for (const unit of units) { + if (unit.surface === preferredSurface) { + preferred.push(unit); + } else { + remaining.push(unit); + } + } + return preferred.length > 0 ? [...preferred, ...remaining] : units; +}; + +const partitionUnitsBySurface = (units, surface) => { + const matching = []; + const remaining = []; + for (const unit of units) { + if (unit.surface === surface) { + matching.push(unit); + } else { + remaining.push(unit); + } + } + return { matching, remaining }; +}; + export async function executePlan(plan, options = {}) { const env = options.env ?? process.env; const artifacts = options.artifacts ?? createExecutionArtifacts(env); @@ -632,23 +688,116 @@ export async function executePlan(plan, options = {}) { } if (plan.serialPrefixUnits.length > 0) { - const failedSerialPrefix = await runUnitsWithLimit( - plan.serialPrefixUnits, - plan.passthroughOptionArgs, - 1, - ); - if (failedSerialPrefix !== undefined) { - return failedSerialPrefix; + const orderedSegments = buildOrderedParallelSegments(plan.parallelUnits); + let pendingDeferredSegment = null; + let carriedDeferredPromise = null; + let carriedDeferredSurface = null; + for (const segment of orderedSegments) { + if (segment.type === "deferred") { + pendingDeferredSegment = segment; + continue; + } + // Preserve phase ordering, but let batches inside the same shared phase use + // the normal top-level concurrency budget. + let deferredPromise = null; + let deferredCarryPromise = carriedDeferredPromise; + let deferredCarrySurface = carriedDeferredSurface; + if ( + segment.phase === "unit-fast" && + pendingDeferredSegment !== null && + plan.topLevelParallelEnabled + ) { + const availableSlots = Math.max(0, plan.topLevelParallelLimit - segment.units.length); + if (availableSlots > 0) { + const prePhaseDeferred = pendingDeferredSegment.units; + if (prePhaseDeferred.length > 0) { + deferredCarryPromise = runUnitsWithLimit( + prePhaseDeferred, + plan.passthroughOptionArgs, + availableSlots, + ); + deferredCarrySurface = prePhaseDeferred.some((unit) => unit.surface === "channels") + ? "channels" + : null; + pendingDeferredSegment = null; + } + } + } + if (pendingDeferredSegment !== null) { + const prioritizedDeferred = prioritizeDeferredUnitsForPhase( + pendingDeferredSegment.units, + segment.phase, + ); + if (segment.phase === "extensions") { + const { matching: channelDeferred, remaining: otherDeferred } = partitionUnitsBySurface( + prioritizedDeferred, + "channels", + ); + deferredPromise = + otherDeferred.length > 0 + ? runUnitsWithLimit( + otherDeferred, + plan.passthroughOptionArgs, + plan.deferredRunConcurrency ?? 1, + ) + : null; + deferredCarryPromise = + channelDeferred.length > 0 + ? runUnitsWithLimit( + channelDeferred, + plan.passthroughOptionArgs, + plan.deferredRunConcurrency ?? 1, + ) + : carriedDeferredPromise; + deferredCarrySurface = channelDeferred.length > 0 ? "channels" : carriedDeferredSurface; + } else { + deferredPromise = runUnitsWithLimit( + prioritizedDeferred, + plan.passthroughOptionArgs, + plan.deferredRunConcurrency ?? 1, + ); + } + } + pendingDeferredSegment = null; + // eslint-disable-next-line no-await-in-loop + const failedSerialPhase = await runUnits(segment.units, plan.passthroughOptionArgs); + if (failedSerialPhase !== undefined) { + return failedSerialPhase; + } + if (deferredCarryPromise !== null && deferredCarrySurface === segment.phase) { + // eslint-disable-next-line no-await-in-loop + const failedCarriedDeferred = await deferredCarryPromise; + if (failedCarriedDeferred !== undefined) { + return failedCarriedDeferred; + } + deferredCarryPromise = null; + deferredCarrySurface = null; + } + if (deferredPromise !== null) { + // eslint-disable-next-line no-await-in-loop + const failedDeferredPhase = await deferredPromise; + if (failedDeferredPhase !== undefined) { + return failedDeferredPhase; + } + } + carriedDeferredPromise = deferredCarryPromise; + carriedDeferredSurface = deferredCarrySurface; } - const failedDeferredParallel = plan.deferredRunConcurrency - ? await runUnitsWithLimit( - plan.deferredParallelUnits, - plan.passthroughOptionArgs, - plan.deferredRunConcurrency, - ) - : await runUnits(plan.deferredParallelUnits, plan.passthroughOptionArgs); - if (failedDeferredParallel !== undefined) { - return failedDeferredParallel; + if (pendingDeferredSegment !== null) { + const failedDeferredParallel = await runUnitsWithLimit( + pendingDeferredSegment.units, + plan.passthroughOptionArgs, + plan.deferredRunConcurrency ?? 1, + ); + if (failedDeferredParallel !== undefined) { + return failedDeferredParallel; + } + } + if (carriedDeferredPromise !== null) { + const failedCarriedDeferred = await carriedDeferredPromise; + if (failedCarriedDeferred !== undefined) { + return failedCarriedDeferred; + } } } else { const failedParallel = await runUnits(plan.parallelUnits, plan.passthroughOptionArgs); diff --git a/scripts/test-planner/planner.mjs b/scripts/test-planner/planner.mjs index 0e8070cd872..ada727d267d 100644 --- a/scripts/test-planner/planner.mjs +++ b/scripts/test-planner/planner.mjs @@ -2,6 +2,7 @@ import path from "node:path"; import { isUnitConfigTestFile } from "../../vitest.unit-paths.mjs"; import { loadChannelTimingManifest, + loadExtensionTimingManifest, loadUnitMemoryHotspotManifest, loadUnitTimingManifest, packFilesByDuration, @@ -145,6 +146,7 @@ const createPlannerContext = (request, options = {}) => { const catalog = options.catalog ?? loadTestCatalog(); const unitTimingManifest = loadUnitTimingManifest(); const channelTimingManifest = loadChannelTimingManifest(); + const extensionTimingManifest = loadExtensionTimingManifest(); const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest(); return { env, @@ -153,6 +155,7 @@ const createPlannerContext = (request, options = {}) => { catalog, unitTimingManifest, channelTimingManifest, + extensionTimingManifest, unitMemoryHotspotManifest, }; }; @@ -198,11 +201,16 @@ const resolveEntryTimingEstimator = (entry, context) => { context.unitTimingManifest.files[file]?.durationMs ?? context.unitTimingManifest.defaultDurationMs; } - if (config === "vitest.channels.config.ts" || config === "vitest.extensions.config.ts") { + if (config === "vitest.channels.config.ts") { return (file) => context.channelTimingManifest.files[file]?.durationMs ?? context.channelTimingManifest.defaultDurationMs; } + if (config === "vitest.extensions.config.ts") { + return (file) => + context.extensionTimingManifest.files[file]?.durationMs ?? + context.extensionTimingManifest.defaultDurationMs; + } return null; }; @@ -233,6 +241,20 @@ const splitFilesByDurationBudget = (files, targetDurationMs, estimateDurationMs) return batches; }; +const splitFilesByBalancedDurationBudget = (files, targetDurationMs, estimateDurationMs) => { + if (!Number.isFinite(targetDurationMs) || targetDurationMs <= 0 || files.length <= 1) { + return [files]; + } + const totalDurationMs = files.reduce((sum, file) => sum + estimateDurationMs(file), 0); + const batchCount = clamp(Math.ceil(totalDurationMs / targetDurationMs), 1, files.length); + const originalOrder = new Map(files.map((file, index) => [file, index])); + return packFilesByDuration(files, batchCount, estimateDurationMs).map((batch) => + [...batch].toSorted( + (left, right) => (originalOrder.get(left) ?? 0) - (originalOrder.get(right) ?? 0), + ), + ); +}; + const resolveMaxWorkersForUnit = (unit, context) => { const overrideWorkers = Number.parseInt(context.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = @@ -332,11 +354,20 @@ const resolveUnitHeavyFileGroups = (context) => { }; const buildDefaultUnits = (context, request) => { - const { env, executionBudget, catalog, unitTimingManifest, channelTimingManifest } = context; + const { + env, + executionBudget, + catalog, + unitTimingManifest, + channelTimingManifest, + extensionTimingManifest, + } = context; const noIsolateArgs = context.noIsolateArgs; const selectedSurfaces = buildRequestedSurfaces(request, env); const selectedSurfaceSet = new Set(selectedSurfaces); + const unitOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("unit"); const channelsOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("channels"); + const extensionsOnlyRun = selectedSurfaceSet.size === 1 && selectedSurfaceSet.has("extensions"); const { heavyUnitLaneCount, @@ -361,6 +392,8 @@ const buildDefaultUnits = (context, request) => { unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; const estimateChannelDurationMs = (file) => channelTimingManifest.files[file]?.durationMs ?? channelTimingManifest.defaultDurationMs; + const estimateExtensionDurationMs = (file) => + extensionTimingManifest.files[file]?.durationMs ?? extensionTimingManifest.defaultDurationMs; const unitFastCandidateFiles = catalog.allKnownUnitFiles.filter( (file) => !new Set(unitFastExcludedFiles).has(file), ); @@ -421,7 +454,7 @@ const buildDefaultUnits = (context, request) => { id: unitId, surface: "unit", isolate: false, - serialPhase: "unit-fast", + serialPhase: unitOnlyRun ? undefined : "unit-fast", includeFiles: batch, estimatedDurationMs: estimateEntryFilesDurationMs( { args: ["vitest", "run", "--config", "vitest.unit.config.ts"] }, @@ -453,6 +486,7 @@ const buildDefaultUnits = (context, request) => { id: `unit-${path.basename(file, ".test.ts")}-isolated`, surface: "unit", isolate: true, + estimatedDurationMs: estimateUnitDurationMs(file), args: [ "vitest", "run", @@ -478,6 +512,7 @@ const buildDefaultUnits = (context, request) => { id: `unit-heavy-${String(index + 1)}`, surface: "unit", isolate: false, + estimatedDurationMs: files.reduce((sum, file) => sum + estimateUnitDurationMs(file), 0), args: [ "vitest", "run", @@ -498,6 +533,7 @@ const buildDefaultUnits = (context, request) => { id: `unit-${path.basename(file, ".test.ts")}-memory-isolated`, surface: "unit", isolate: true, + estimatedDurationMs: estimateUnitDurationMs(file), args: [ "vitest", "run", @@ -533,6 +569,29 @@ const buildDefaultUnits = (context, request) => { } } + if (selectedSurfaceSet.has("channels")) { + for (const file of catalog.channelIsolatedFiles) { + units.push( + createExecutionUnit(context, { + id: `${path.basename(file, ".test.ts")}-channels-isolated`, + surface: "channels", + isolate: true, + estimatedDurationMs: estimateChannelDurationMs(file), + args: [ + "vitest", + "run", + "--config", + "vitest.channels.config.ts", + "--pool=forks", + ...noIsolateArgs, + file, + ], + reasons: ["channels-isolated-rule"], + }), + ); + } + } + if (selectedSurfaceSet.has("extensions")) { for (const file of catalog.extensionForkIsolatedFiles) { units.push( @@ -540,15 +599,16 @@ const buildDefaultUnits = (context, request) => { id: `extensions-${path.basename(file, ".test.ts")}-isolated`, surface: "extensions", isolate: true, + estimatedDurationMs: estimateExtensionDurationMs(file), args: ["vitest", "run", "--config", "vitest.extensions.config.ts", "--pool=forks", file], reasons: ["extensions-isolated-manifest"], }), ); } - const extensionBatches = splitFilesByDurationBudget( + const extensionBatches = splitFilesByBalancedDurationBudget( extensionSharedCandidateFiles, extensionsBatchTargetMs, - estimateChannelDurationMs, + estimateExtensionDurationMs, ); for (const [batchIndex, batch] of extensionBatches.entries()) { if (batch.length === 0) { @@ -561,7 +621,7 @@ const buildDefaultUnits = (context, request) => { id: unitId, surface: "extensions", isolate: false, - serialPhase: "extensions", + serialPhase: extensionsOnlyRun ? undefined : "extensions", includeFiles: batch, estimatedDurationMs: estimateEntryFilesDurationMs( { args: ["vitest", "run", "--config", "vitest.extensions.config.ts"] }, @@ -581,25 +641,6 @@ const buildDefaultUnits = (context, request) => { } if (selectedSurfaceSet.has("channels")) { - for (const file of catalog.channelIsolatedFiles) { - units.push( - createExecutionUnit(context, { - id: `${path.basename(file, ".test.ts")}-channels-isolated`, - surface: "channels", - isolate: true, - args: [ - "vitest", - "run", - "--config", - "vitest.channels.config.ts", - "--pool=forks", - ...noIsolateArgs, - file, - ], - reasons: ["channels-isolated-rule"], - }), - ); - } const channelBatches = splitFilesByDurationBudget( channelSharedCandidateFiles, channelsBatchTargetMs, diff --git a/scripts/test-planner/runtime-profile.mjs b/scripts/test-planner/runtime-profile.mjs index cc08391c2aa..f31d49c0aac 100644 --- a/scripts/test-planner/runtime-profile.mjs +++ b/scripts/test-planner/runtime-profile.mjs @@ -102,6 +102,7 @@ const LOCAL_MEMORY_BUDGETS = { heavyLaneCount: 3, memoryHeavyFileLimit: 8, unitFastBatchTargetMs: 10_000, + channelsBatchTargetMs: 0, }, moderate: { vitestCap: 3, @@ -117,6 +118,7 @@ const LOCAL_MEMORY_BUDGETS = { heavyLaneCount: 4, memoryHeavyFileLimit: 12, unitFastBatchTargetMs: 15_000, + channelsBatchTargetMs: 0, }, mid: { vitestCap: 4, @@ -132,6 +134,7 @@ const LOCAL_MEMORY_BUDGETS = { heavyLaneCount: 4, memoryHeavyFileLimit: 16, unitFastBatchTargetMs: 0, + channelsBatchTargetMs: 0, }, high: { vitestCap: 6, @@ -140,13 +143,14 @@ const LOCAL_MEMORY_BUDGETS = { unitHeavy: 2, extensions: 4, gateway: 3, - topLevelNoIsolate: 12, + topLevelNoIsolate: 14, topLevelIsolated: 4, - deferred: 3, + deferred: 8, heavyFileLimit: 80, heavyLaneCount: 5, memoryHeavyFileLimit: 16, unitFastBatchTargetMs: 45_000, + channelsBatchTargetMs: 30_000, }, }; @@ -296,8 +300,8 @@ export function resolveExecutionBudget(runtimeCapabilities) { memoryHeavyUnitFileLimit: bandBudget.memoryHeavyFileLimit, unitFastLaneCount: 1, unitFastBatchTargetMs: bandBudget.unitFastBatchTargetMs, - channelsBatchTargetMs: 0, - extensionsBatchTargetMs: 0, + channelsBatchTargetMs: bandBudget.channelsBatchTargetMs ?? 0, + extensionsBatchTargetMs: 240_000, }; const loadAdjustedBudget = { diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index 11ceab6b787..2a48a61ae02 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -3,6 +3,7 @@ import { normalizeTrackedRepoPath, tryReadJsonFile } from "./test-report-utils.m export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json"; export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json"; export const channelTimingManifestPath = "test/fixtures/test-timings.channels.json"; +export const extensionTimingManifestPath = "test/fixtures/test-timings.extensions.json"; export const unitMemoryHotspotManifestPath = "test/fixtures/test-memory-hotspots.unit.json"; const defaultTimingManifest = { @@ -15,6 +16,11 @@ const defaultChannelTimingManifest = { defaultDurationMs: 3000, files: {}, }; +const defaultExtensionTimingManifest = { + config: "vitest.extensions.config.ts", + defaultDurationMs: 1000, + files: {}, +}; const defaultMemoryHotspotManifest = { config: "vitest.unit.config.ts", defaultMinDeltaKb: 256 * 1024, @@ -137,6 +143,10 @@ export function loadChannelTimingManifest() { return loadTimingManifest(channelTimingManifestPath, defaultChannelTimingManifest); } +export function loadExtensionTimingManifest() { + return loadTimingManifest(extensionTimingManifestPath, defaultExtensionTimingManifest); +} + export function loadUnitMemoryHotspotManifest() { const raw = tryReadJsonFile(unitMemoryHotspotManifestPath, defaultMemoryHotspotManifest); const defaultMinDeltaKb = diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs index f016479465a..5aca9a3fd93 100644 --- a/scripts/test-update-timings.mjs +++ b/scripts/test-update-timings.mjs @@ -5,26 +5,42 @@ import { normalizeTrackedRepoPath, writeJsonFile, } from "./test-report-utils.mjs"; -import { unitTimingManifestPath } from "./test-runner-manifest.mjs"; +import { extensionTimingManifestPath, unitTimingManifestPath } from "./test-runner-manifest.mjs"; + +const resolveDefaultManifestSettings = (config) => { + if (config === "vitest.extensions.config.ts") { + return { + out: extensionTimingManifestPath, + defaultDurationMs: 1000, + description: "extension", + }; + } + return { + out: unitTimingManifestPath, + defaultDurationMs: 250, + description: "unit", + }; +}; if (process.argv.slice(2).includes("--help")) { console.log( [ "Usage: node scripts/test-update-timings.mjs [options]", "", - "Generate or refresh the unit test timing manifest from a Vitest JSON report.", + "Generate or refresh a test timing manifest from a Vitest JSON report.", "", "Options:", " --config Vitest config to run when no report is supplied", " --report Reuse an existing Vitest JSON report", - " --out Output manifest path (default: test/fixtures/test-timings.unit.json)", + " --out Output manifest path (default follows --config)", " --limit Max number of file timings to retain (default: 256)", - " --default-duration-ms Fallback duration for unknown files (default: 250)", + " --default-duration-ms Fallback duration for unknown files (default follows --config)", " --help Show this help text", "", "Examples:", " node scripts/test-update-timings.mjs", " node scripts/test-update-timings.mjs --config vitest.unit.config.ts --limit 128", + " node scripts/test-update-timings.mjs --config vitest.extensions.config.ts", " node scripts/test-update-timings.mjs --report /tmp/vitest-report.json --out /tmp/timings.json", ].join("\n"), ); @@ -32,14 +48,14 @@ if (process.argv.slice(2).includes("--help")) { } function parseArgs(argv) { - return parseFlagArgs( + const parsed = parseFlagArgs( argv, { config: "vitest.unit.config.ts", limit: 256, reportPath: "", - out: unitTimingManifestPath, - defaultDurationMs: 250, + out: "", + defaultDurationMs: 0, }, [ stringFlag("--config", "config"), @@ -49,6 +65,16 @@ function parseArgs(argv) { intFlag("--default-duration-ms", "defaultDurationMs", { min: 1 }), ], ); + const defaults = resolveDefaultManifestSettings(parsed.config); + return { + ...parsed, + out: parsed.out || defaults.out, + defaultDurationMs: + Number.isFinite(parsed.defaultDurationMs) && parsed.defaultDurationMs > 0 + ? parsed.defaultDurationMs + : defaults.defaultDurationMs, + description: defaults.description, + }; } const opts = parseArgs(process.argv.slice(2)); @@ -75,5 +101,5 @@ const output = { writeJsonFile(opts.out, output); console.log( - `[test-update-timings] wrote ${String(Object.keys(files).length)} timings to ${opts.out}`, + `[test-update-timings] wrote ${String(Object.keys(files).length)} ${opts.description} timings to ${opts.out}`, ); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index ff5f1096ae6..0a9bb4d6f0a 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -159,6 +159,26 @@ export function clearSessionStoreCacheForTest(): void { LOCK_QUEUES.clear(); } +export async function drainSessionStoreLockQueuesForTest(): Promise { + while (LOCK_QUEUES.size > 0) { + const queues = [...LOCK_QUEUES.values()]; + for (const queue of queues) { + for (const task of queue.pending) { + task.reject(new Error("session store queue cleared for test")); + } + queue.pending.length = 0; + } + const activeDrains = queues.flatMap((queue) => + queue.drainPromise ? [queue.drainPromise] : [], + ); + if (activeDrains.length === 0) { + LOCK_QUEUES.clear(); + return; + } + await Promise.allSettled(activeDrains); + } +} + /** Expose lock queue size for tests. */ export function getSessionStoreLockQueueSizeForTest(): number { return LOCK_QUEUES.size; @@ -602,6 +622,7 @@ type SessionStoreLockTask = { type SessionStoreLockQueue = { running: boolean; pending: SessionStoreLockTask[]; + drainPromise: Promise | null; }; const LOCK_QUEUES = new Map(); @@ -686,63 +707,71 @@ function getOrCreateLockQueue(storePath: string): SessionStoreLockQueue { if (existing) { return existing; } - const created: SessionStoreLockQueue = { running: false, pending: [] }; + const created: SessionStoreLockQueue = { running: false, pending: [], drainPromise: null }; LOCK_QUEUES.set(storePath, created); return created; } async function drainSessionStoreLockQueue(storePath: string): Promise { const queue = LOCK_QUEUES.get(storePath); - if (!queue || queue.running) { + if (!queue) { + return; + } + if (queue.drainPromise) { + await queue.drainPromise; return; } queue.running = true; - try { - while (queue.pending.length > 0) { - const task = queue.pending.shift(); - if (!task) { - continue; - } + queue.drainPromise = (async () => { + try { + while (queue.pending.length > 0) { + const task = queue.pending.shift(); + if (!task) { + continue; + } - const remainingTimeoutMs = task.timeoutMs ?? Number.POSITIVE_INFINITY; - if (task.timeoutMs != null && remainingTimeoutMs <= 0) { - task.reject(lockTimeoutError(storePath)); - continue; - } + const remainingTimeoutMs = task.timeoutMs ?? Number.POSITIVE_INFINITY; + if (task.timeoutMs != null && remainingTimeoutMs <= 0) { + task.reject(lockTimeoutError(storePath)); + continue; + } - let lock: { release: () => Promise } | undefined; - let result: unknown; - let failed: unknown; - let hasFailure = false; - try { - lock = await acquireSessionWriteLock({ - sessionFile: storePath, - timeoutMs: remainingTimeoutMs, - staleMs: task.staleMs, + let lock: { release: () => Promise } | undefined; + let result: unknown; + let failed: unknown; + let hasFailure = false; + try { + lock = await acquireSessionWriteLock({ + sessionFile: storePath, + timeoutMs: remainingTimeoutMs, + staleMs: task.staleMs, + }); + result = await task.fn(); + } catch (err) { + hasFailure = true; + failed = err; + } finally { + await lock?.release().catch(() => undefined); + } + if (hasFailure) { + task.reject(failed); + continue; + } + task.resolve(result); + } + } finally { + queue.running = false; + queue.drainPromise = null; + if (queue.pending.length === 0) { + LOCK_QUEUES.delete(storePath); + } else { + queueMicrotask(() => { + void drainSessionStoreLockQueue(storePath); }); - result = await task.fn(); - } catch (err) { - hasFailure = true; - failed = err; - } finally { - await lock?.release().catch(() => undefined); } - if (hasFailure) { - task.reject(failed); - continue; - } - task.resolve(result); } - } finally { - queue.running = false; - if (queue.pending.length === 0) { - LOCK_QUEUES.delete(storePath); - } else { - queueMicrotask(() => { - void drainSessionStoreLockQueue(storePath); - }); - } - } + })(); + await queue.drainPromise; } async function withSessionStoreLock( diff --git a/src/test-utils/session-state-cleanup.test.ts b/src/test-utils/session-state-cleanup.test.ts new file mode 100644 index 00000000000..2259369005f --- /dev/null +++ b/src/test-utils/session-state-cleanup.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getSessionStoreLockQueueSizeForTest, + withSessionStoreLockForTest, +} from "../config/sessions/store.js"; +import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +describe("cleanupSessionStateForTest", () => { + afterEach(async () => { + await cleanupSessionStateForTest(); + }); + + it("waits for in-flight session store locks before clearing test state", async () => { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-cleanup-")); + const storePath = path.join(fixtureRoot, "openclaw-sessions.json"); + const started = createDeferred(); + const release = createDeferred(); + try { + const running = withSessionStoreLockForTest(storePath, async () => { + started.resolve(); + await release.promise; + }); + + await started.promise; + expect(getSessionStoreLockQueueSizeForTest()).toBe(1); + + let settled = false; + const cleanupPromise = cleanupSessionStateForTest().then(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(settled).toBe(false); + + release.resolve(); + await running; + await cleanupPromise; + + expect(getSessionStoreLockQueueSizeForTest()).toBe(0); + } finally { + release.resolve(); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/test-utils/session-state-cleanup.ts b/src/test-utils/session-state-cleanup.ts index 4ea68b6bdd6..7487fc5b802 100644 --- a/src/test-utils/session-state-cleanup.ts +++ b/src/test-utils/session-state-cleanup.ts @@ -1,8 +1,12 @@ import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; -import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; +import { + clearSessionStoreCacheForTest, + drainSessionStoreLockQueuesForTest, +} from "../config/sessions/store.js"; import { drainFileLockStateForTest } from "../infra/file-lock.js"; export async function cleanupSessionStateForTest(): Promise { + await drainSessionStoreLockQueuesForTest(); clearSessionStoreCacheForTest(); await drainFileLockStateForTest(); await drainSessionWriteLockStateForTest(); diff --git a/test/fixtures/test-timings.extensions.json b/test/fixtures/test-timings.extensions.json new file mode 100644 index 00000000000..9dec9ff9f29 --- /dev/null +++ b/test/fixtures/test-timings.extensions.json @@ -0,0 +1,1031 @@ +{ + "config": "vitest.extensions.config.ts", + "generatedAt": "2026-03-26T17:02:23.374Z", + "defaultDurationMs": 1000, + "files": { + "extensions/bluebubbles/src/monitor.webhook-auth.test.ts": { + "durationMs": 30152.246337890625, + "testCount": 19 + }, + "extensions/matrix/src/matrix/monitor/index.test.ts": { + "durationMs": 23302.03173828125, + "testCount": 8 + }, + "extensions/line/src/setup-surface.test.ts": { + "durationMs": 12208.14111328125, + "testCount": 8 + }, + "extensions/mattermost/src/mattermost/client.retry.test.ts": { + "durationMs": 9429.540283203125, + "testCount": 17 + }, + "extensions/feishu/src/monitor.webhook-security.test.ts": { + "durationMs": 5187.25, + "testCount": 8 + }, + "extensions/synology-chat/src/webhook-handler.test.ts": { + "durationMs": 5038.8330078125, + "testCount": 20 + }, + "extensions/mattermost/src/mattermost/slash-http.test.ts": { + "durationMs": 5006.665283203125, + "testCount": 5 + }, + "extensions/acpx/src/runtime.test.ts": { + "durationMs": 4928.570556640625, + "testCount": 22 + }, + "extensions/msteams/src/streaming-message.test.ts": { + "durationMs": 4410.16259765625, + "testCount": 10 + }, + "extensions/matrix/src/matrix/send.test.ts": { + "durationMs": 4319.607421875, + "testCount": 16 + }, + "extensions/matrix/src/matrix/monitor/events.test.ts": { + "durationMs": 2489.9775390625, + "testCount": 29 + }, + "extensions/feishu/src/bot.test.ts": { + "durationMs": 1024.728759765625, + "testCount": 48 + }, + "extensions/matrix/src/matrix/thread-bindings.test.ts": { + "durationMs": 461.74658203125, + "testCount": 11 + }, + "extensions/bluebubbles/src/monitor.test.ts": { + "durationMs": 453.485595703125, + "testCount": 64 + }, + "extensions/msteams/src/monitor.test.ts": { + "durationMs": 424.484130859375, + "testCount": 4 + }, + "extensions/acpx/src/runtime-internals/process.test.ts": { + "durationMs": 357.69775390625, + "testCount": 16 + }, + "extensions/memory-lancedb/index.test.ts": { + "durationMs": 349.611328125, + "testCount": 18 + }, + "extensions/synology-chat/src/channel.test.ts": { + "durationMs": 276.8447265625, + "testCount": 30 + }, + "extensions/msteams/src/setup-surface.test.ts": { + "durationMs": 259.621337890625, + "testCount": 6 + }, + "extensions/twitch/src/outbound.test.ts": { + "durationMs": 231.44189453125, + "testCount": 22 + }, + "extensions/feishu/src/client.test.ts": { + "durationMs": 231.306884765625, + "testCount": 15 + }, + "extensions/feishu/src/bot.broadcast.test.ts": { + "durationMs": 216.171875, + "testCount": 6 + }, + "extensions/feishu/src/monitor.webhook-e2e.test.ts": { + "durationMs": 214.897705078125, + "testCount": 8 + }, + "extensions/zalo/src/monitor.webhook.test.ts": { + "durationMs": 205.934326171875, + "testCount": 13 + }, + "extensions/diffs/src/tool.test.ts": { + "durationMs": 197.076171875, + "testCount": 16 + }, + "extensions/diffs/src/config.test.ts": { + "durationMs": 187.2568359375, + "testCount": 24 + }, + "extensions/zalo/src/monitor.pairing.lifecycle.test.ts": { + "durationMs": 166.725830078125, + "testCount": 2 + }, + "extensions/line/src/markdown-to-line.test.ts": { + "durationMs": 158.2265625, + "testCount": 18 + }, + "extensions/zalo/src/monitor.reply-once.lifecycle.test.ts": { + "durationMs": 157.7900390625, + "testCount": 2 + }, + "extensions/zalo/src/monitor.lifecycle.test.ts": { + "durationMs": 156.505615234375, + "testCount": 4 + }, + "extensions/line/src/bot-message-context.test.ts": { + "durationMs": 155.55517578125, + "testCount": 11 + }, + "extensions/matrix/src/matrix/client/storage.test.ts": { + "durationMs": 153.7314453125, + "testCount": 12 + }, + "extensions/acpx/src/runtime-internals/mcp-proxy.test.ts": { + "durationMs": 152.1640625, + "testCount": 1 + }, + "extensions/msteams/src/conversation-store-fs.test.ts": { + "durationMs": 151.787353515625, + "testCount": 4 + }, + "extensions/voice-call/src/media-stream.test.ts": { + "durationMs": 142.008544921875, + "testCount": 9 + }, + "extensions/tlon/src/urbit/sse-client.test.ts": { + "durationMs": 136.485107421875, + "testCount": 13 + }, + "extensions/matrix/src/matrix/sdk.test.ts": { + "durationMs": 131.332275390625, + "testCount": 53 + }, + "extensions/feishu/src/docx.test.ts": { + "durationMs": 130.966796875, + "testCount": 13 + }, + "extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts": { + "durationMs": 129.5693359375, + "testCount": 2 + }, + "extensions/feishu/src/monitor.reply-once.lifecycle.test.ts": { + "durationMs": 129.031005859375, + "testCount": 2 + }, + "extensions/feishu/src/outbound.test.ts": { + "durationMs": 128.026611328125, + "testCount": 13 + }, + "extensions/google/oauth.test.ts": { + "durationMs": 127.036376953125, + "testCount": 8 + }, + "extensions/msteams/src/polls.test.ts": { + "durationMs": 117.5244140625, + "testCount": 6 + }, + "extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts": { + "durationMs": 116.278564453125, + "testCount": 2 + }, + "extensions/feishu/src/monitor.bot-menu.test.ts": { + "durationMs": 110.828369140625, + "testCount": 4 + }, + "extensions/feishu/src/monitor.card-action.lifecycle.test.ts": { + "durationMs": 110.7607421875, + "testCount": 2 + }, + "extensions/matrix/src/matrix/monitor/startup-verification.test.ts": { + "durationMs": 109.208984375, + "testCount": 9 + }, + "extensions/matrix/src/matrix/client/file-sync-store.test.ts": { + "durationMs": 104.33837890625, + "testCount": 7 + }, + "extensions/mattermost/src/mattermost/send.test.ts": { + "durationMs": 102.6015625, + "testCount": 30 + }, + "extensions/openshell/src/openshell-core.test.ts": { + "durationMs": 95.810546875, + "testCount": 14 + }, + "extensions/twitch/src/twitch-client.test.ts": { + "durationMs": 88.92333984375, + "testCount": 30 + }, + "extensions/diffs/src/store.test.ts": { + "durationMs": 88.814453125, + "testCount": 20 + }, + "extensions/matrix/src/cli.test.ts": { + "durationMs": 87.73828125, + "testCount": 25 + }, + "extensions/tlon/src/core.test.ts": { + "durationMs": 87.28759765625, + "testCount": 11 + }, + "extensions/nostr/src/nostr-profile.fuzz.test.ts": { + "durationMs": 84.99072265625, + "testCount": 51 + }, + "extensions/mattermost/src/mattermost/reconnect.test.ts": { + "durationMs": 81.615478515625, + "testCount": 9 + }, + "extensions/line/src/send.test.ts": { + "durationMs": 71.626708984375, + "testCount": 8 + }, + "extensions/twitch/src/send.test.ts": { + "durationMs": 70.67431640625, + "testCount": 9 + }, + "extensions/googlechat/src/setup.test.ts": { + "durationMs": 67.10302734375, + "testCount": 11 + }, + "extensions/nextcloud-talk/src/core.test.ts": { + "durationMs": 65.344970703125, + "testCount": 17 + }, + "extensions/line/src/monitor.lifecycle.test.ts": { + "durationMs": 62.67041015625, + "testCount": 5 + }, + "extensions/feishu/src/reply-dispatcher.test.ts": { + "durationMs": 62.144775390625, + "testCount": 29 + }, + "extensions/zalo/src/channel.startup.test.ts": { + "durationMs": 62.13134765625, + "testCount": 1 + }, + "extensions/msteams/src/attachments.test.ts": { + "durationMs": 62.111083984375, + "testCount": 16 + }, + "extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts": { + "durationMs": 59.12890625, + "testCount": 3 + }, + "extensions/voice-call/src/webhook/tailscale.test.ts": { + "durationMs": 57.841552734375, + "testCount": 5 + }, + "extensions/zalo/src/monitor.image.polling.test.ts": { + "durationMs": 56.554443359375, + "testCount": 1 + }, + "extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts": { + "durationMs": 56.537353515625, + "testCount": 2 + }, + "extensions/voice-call/src/providers/twilio.test.ts": { + "durationMs": 54.70068359375, + "testCount": 16 + }, + "extensions/msteams/src/monitor.lifecycle.test.ts": { + "durationMs": 53.74462890625, + "testCount": 2 + }, + "extensions/matrix/src/matrix/credentials.test.ts": { + "durationMs": 51.688720703125, + "testCount": 6 + }, + "extensions/device-pair/index.test.ts": { + "durationMs": 50.2451171875, + "testCount": 15 + }, + "extensions/voice-call/src/webhook.test.ts": { + "durationMs": 49.31201171875, + "testCount": 17 + }, + "extensions/matrix/src/matrix/probe.test.ts": { + "durationMs": 45.84521484375, + "testCount": 4 + }, + "extensions/microsoft-foundry/index.test.ts": { + "durationMs": 41.1240234375, + "testCount": 22 + }, + "extensions/msteams/src/directory-live.test.ts": { + "durationMs": 37.424560546875, + "testCount": 3 + }, + "extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts": { + "durationMs": 36.56787109375, + "testCount": 5 + }, + "extensions/lobster/src/lobster-tool.test.ts": { + "durationMs": 31.674560546875, + "testCount": 17 + }, + "extensions/matrix/src/matrix/actions/verification.test.ts": { + "durationMs": 31.3134765625, + "testCount": 3 + }, + "extensions/matrix/src/channel.account-paths.test.ts": { + "durationMs": 29.037841796875, + "testCount": 2 + }, + "extensions/googlechat/src/targets.test.ts": { + "durationMs": 28.31982421875, + "testCount": 17 + }, + "extensions/nostr/src/nostr-profile.test.ts": { + "durationMs": 28.27978515625, + "testCount": 31 + }, + "extensions/voice-call/src/manager.notify.test.ts": { + "durationMs": 27.918212890625, + "testCount": 10 + }, + "extensions/bluebubbles/src/actions.test.ts": { + "durationMs": 27.385009765625, + "testCount": 29 + }, + "extensions/nextcloud-talk/src/setup.test.ts": { + "durationMs": 27.300537109375, + "testCount": 12 + }, + "extensions/matrix/src/matrix/actions/devices.test.ts": { + "durationMs": 27.01171875, + "testCount": 2 + }, + "extensions/tlon/src/urbit/upload.test.ts": { + "durationMs": 26.323974609375, + "testCount": 7 + }, + "extensions/synology-chat/src/client.test.ts": { + "durationMs": 26.11181640625, + "testCount": 11 + }, + "extensions/openai/index.test.ts": { + "durationMs": 25.549072265625, + "testCount": 9 + }, + "extensions/matrix/src/matrix/client-bootstrap.test.ts": { + "durationMs": 25.3876953125, + "testCount": 2 + }, + "extensions/voice-call/src/manager.closed-loop.test.ts": { + "durationMs": 24.541748046875, + "testCount": 5 + }, + "extensions/google/image-generation-provider.test.ts": { + "durationMs": 23.315185546875, + "testCount": 7 + }, + "extensions/feishu/src/monitor.reaction.test.ts": { + "durationMs": 22.403564453125, + "testCount": 23 + }, + "extensions/nostr/src/nostr-bus.test.ts": { + "durationMs": 20.373046875, + "testCount": 32 + }, + "extensions/voice-call/src/telephony-audio.test.ts": { + "durationMs": 19.759033203125, + "testCount": 4 + }, + "extensions/twitch/src/probe.test.ts": { + "durationMs": 19.66455078125, + "testCount": 10 + }, + "extensions/bluebubbles/src/media-send.test.ts": { + "durationMs": 19.399658203125, + "testCount": 10 + }, + "extensions/diffs/src/browser.test.ts": { + "durationMs": 17.973876953125, + "testCount": 6 + }, + "extensions/matrix/src/matrix/monitor/replies.test.ts": { + "durationMs": 16.81591796875, + "testCount": 5 + }, + "extensions/feishu/src/send.reply-fallback.test.ts": { + "durationMs": 16.70458984375, + "testCount": 10 + }, + "extensions/feishu/src/probe.test.ts": { + "durationMs": 16.581298828125, + "testCount": 16 + }, + "extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts": { + "durationMs": 16.513427734375, + "testCount": 14 + }, + "extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts": { + "durationMs": 16.29443359375, + "testCount": 10 + }, + "extensions/matrix/src/matrix/monitor/handler.test.ts": { + "durationMs": 16.2109375, + "testCount": 35 + }, + "extensions/llm-task/src/llm-task-tool.test.ts": { + "durationMs": 15.7490234375, + "testCount": 13 + }, + "extensions/twitch/src/setup-surface.test.ts": { + "durationMs": 15.28662109375, + "testCount": 15 + }, + "extensions/msteams/src/reply-dispatcher.test.ts": { + "durationMs": 15.24462890625, + "testCount": 5 + }, + "extensions/mattermost/src/mattermost/directory.test.ts": { + "durationMs": 14.81591796875, + "testCount": 3 + }, + "extensions/thread-ownership/index.test.ts": { + "durationMs": 14.474609375, + "testCount": 9 + }, + "extensions/openshell/src/mirror.test.ts": { + "durationMs": 14.3525390625, + "testCount": 3 + }, + "extensions/msteams/src/graph.test.ts": { + "durationMs": 14.23095703125, + "testCount": 10 + }, + "extensions/bluebubbles/src/setup-surface.test.ts": { + "durationMs": 14.174560546875, + "testCount": 43 + }, + "extensions/bluebubbles/src/send.test.ts": { + "durationMs": 13.150146484375, + "testCount": 47 + }, + "extensions/nostr/src/nostr-bus.fuzz.test.ts": { + "durationMs": 13.064208984375, + "testCount": 76 + }, + "extensions/matrix/src/matrix/client.test.ts": { + "durationMs": 12.445556640625, + "testCount": 27 + }, + "extensions/msteams/src/messenger.test.ts": { + "durationMs": 12.3515625, + "testCount": 19 + }, + "extensions/nostr/src/nostr-profile-http.test.ts": { + "durationMs": 12.300537109375, + "testCount": 22 + }, + "extensions/matrix/src/matrix/format.test.ts": { + "durationMs": 12.230712890625, + "testCount": 7 + }, + "extensions/bluebubbles/src/chat.test.ts": { + "durationMs": 12.222900390625, + "testCount": 37 + }, + "extensions/bluebubbles/src/attachments.test.ts": { + "durationMs": 11.833984375, + "testCount": 27 + }, + "extensions/zalouser/src/monitor.group-gating.test.ts": { + "durationMs": 11.812744140625, + "testCount": 18 + }, + "extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts": { + "durationMs": 11.766357421875, + "testCount": 2 + }, + "extensions/matrix/src/matrix/sdk/idb-persistence.test.ts": { + "durationMs": 11.326416015625, + "testCount": 3 + }, + "extensions/feishu/src/media.test.ts": { + "durationMs": 11.277099609375, + "testCount": 30 + }, + "extensions/msteams/src/monitor-handler.feedback-authz.test.ts": { + "durationMs": 11.26171875, + "testCount": 4 + }, + "extensions/nextcloud-talk/src/monitor.replay.test.ts": { + "durationMs": 11.179931640625, + "testCount": 5 + }, + "extensions/line/src/bot-handlers.test.ts": { + "durationMs": 10.427490234375, + "testCount": 23 + }, + "extensions/github-copilot/models.test.ts": { + "durationMs": 10.251953125, + "testCount": 20 + }, + "extensions/feishu/src/channel.test.ts": { + "durationMs": 10.119384765625, + "testCount": 43 + }, + "extensions/bluebubbles/src/reactions.test.ts": { + "durationMs": 10.1005859375, + "testCount": 46 + }, + "extensions/phone-control/index.test.ts": { + "durationMs": 9.85791015625, + "testCount": 3 + }, + "extensions/voice-call/src/manager/timers.test.ts": { + "durationMs": 9.802734375, + "testCount": 4 + }, + "extensions/googlechat/src/channel.test.ts": { + "durationMs": 9.51123046875, + "testCount": 11 + }, + "extensions/voice-call/src/webhook-security.test.ts": { + "durationMs": 9.156494140625, + "testCount": 21 + }, + "extensions/nostr/src/channel.test.ts": { + "durationMs": 8.323486328125, + "testCount": 32 + }, + "extensions/line/src/webhook-node.test.ts": { + "durationMs": 8.2236328125, + "testCount": 28 + }, + "extensions/line/src/accounts.test.ts": { + "durationMs": 8.2021484375, + "testCount": 13 + }, + "extensions/nostr/src/nostr-state-store.test.ts": { + "durationMs": 8.105712890625, + "testCount": 7 + }, + "extensions/feishu/src/card-ux-launcher.test.ts": { + "durationMs": 8.009033203125, + "testCount": 4 + }, + "extensions/voice-call/src/manager/events.test.ts": { + "durationMs": 7.883056640625, + "testCount": 10 + }, + "extensions/talk-voice/index.test.ts": { + "durationMs": 7.751220703125, + "testCount": 10 + }, + "extensions/line/src/download.test.ts": { + "durationMs": 7.52587890625, + "testCount": 4 + }, + "extensions/matrix/src/matrix/accounts.test.ts": { + "durationMs": 7.3603515625, + "testCount": 12 + }, + "extensions/acpx/src/config.test.ts": { + "durationMs": 7.31298828125, + "testCount": 13 + }, + "extensions/mattermost/src/mattermost/interactions.test.ts": { + "durationMs": 7.303466796875, + "testCount": 48 + }, + "extensions/bluebubbles/src/monitor-self-chat-cache.test.ts": { + "durationMs": 7.2880859375, + "testCount": 6 + }, + "extensions/tlon/src/security.test.ts": { + "durationMs": 7.126953125, + "testCount": 56 + }, + "extensions/voice-call/src/manager.inbound-allowlist.test.ts": { + "durationMs": 7.1240234375, + "testCount": 5 + }, + "extensions/voice-call/src/manager.restore.test.ts": { + "durationMs": 6.91845703125, + "testCount": 6 + }, + "extensions/nostr/src/nostr-bus.integration.test.ts": { + "durationMs": 6.494140625, + "testCount": 26 + }, + "extensions/matrix/src/matrix/actions/client.test.ts": { + "durationMs": 6.365478515625, + "testCount": 10 + }, + "extensions/matrix/src/matrix/monitor/direct.test.ts": { + "durationMs": 6.345458984375, + "testCount": 10 + }, + "extensions/matrix/src/tool-actions.test.ts": { + "durationMs": 6.29541015625, + "testCount": 15 + }, + "extensions/matrix/src/onboarding.test.ts": { + "durationMs": 6.2275390625, + "testCount": 9 + }, + "extensions/voice-call/src/manager/outbound.test.ts": { + "durationMs": 6.2216796875, + "testCount": 6 + }, + "extensions/matrix/src/channel.directory.test.ts": { + "durationMs": 6.139892578125, + "testCount": 18 + }, + "extensions/msteams/src/attachments/shared.test.ts": { + "durationMs": 6.132568359375, + "testCount": 30 + }, + "extensions/msteams/src/graph-messages.test.ts": { + "durationMs": 6.111083984375, + "testCount": 35 + }, + "extensions/msteams/src/media-helpers.test.ts": { + "durationMs": 6.070556640625, + "testCount": 39 + }, + "extensions/mattermost/src/mattermost/model-picker.test.ts": { + "durationMs": 5.98388671875, + "testCount": 8 + }, + "extensions/irc/src/accounts.test.ts": { + "durationMs": 5.873046875, + "testCount": 9 + }, + "extensions/msteams/src/feedback-reflection.test.ts": { + "durationMs": 5.866943359375, + "testCount": 16 + }, + "extensions/diagnostics-otel/src/service.test.ts": { + "durationMs": 5.76513671875, + "testCount": 8 + }, + "extensions/irc/src/setup.test.ts": { + "durationMs": 5.7607421875, + "testCount": 8 + }, + "extensions/feishu/src/bot.card-action.test.ts": { + "durationMs": 5.676513671875, + "testCount": 12 + }, + "extensions/line/src/channel.sendPayload.test.ts": { + "durationMs": 5.55517578125, + "testCount": 7 + }, + "extensions/msteams/src/monitor-handler/message-handler.authz.test.ts": { + "durationMs": 5.469970703125, + "testCount": 3 + }, + "extensions/matrix/src/matrix/sdk/verification-manager.test.ts": { + "durationMs": 5.420654296875, + "testCount": 12 + }, + "extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts": { + "durationMs": 5.376708984375, + "testCount": 3 + }, + "extensions/synology-chat/src/core.test.ts": { + "durationMs": 5.31787109375, + "testCount": 19 + }, + "extensions/feishu/src/config-schema.test.ts": { + "durationMs": 5.198974609375, + "testCount": 22 + }, + "extensions/mattermost/src/channel.test.ts": { + "durationMs": 5.1962890625, + "testCount": 21 + }, + "extensions/irc/src/inbound.behavior.test.ts": { + "durationMs": 5.007568359375, + "testCount": 2 + }, + "extensions/nextcloud-talk/src/inbound.behavior.test.ts": { + "durationMs": 4.94482421875, + "testCount": 2 + }, + "extensions/matrix/src/matrix/monitor/room-info.test.ts": { + "durationMs": 4.869384765625, + "testCount": 2 + }, + "extensions/matrix/src/matrix/actions/messages.test.ts": { + "durationMs": 4.765869140625, + "testCount": 3 + }, + "extensions/voice-call/src/runtime.test.ts": { + "durationMs": 4.73291015625, + "testCount": 2 + }, + "extensions/matrix/src/onboarding.resolve.test.ts": { + "durationMs": 4.39111328125, + "testCount": 1 + }, + "extensions/google/google-shared.test.ts": { + "durationMs": 4.324951171875, + "testCount": 11 + }, + "extensions/firecrawl/src/firecrawl-tools.test.ts": { + "durationMs": 4.3115234375, + "testCount": 14 + }, + "extensions/tavily/src/tavily-tools.test.ts": { + "durationMs": 4.30224609375, + "testCount": 11 + }, + "extensions/googlechat/src/monitor-access.test.ts": { + "durationMs": 4.29150390625, + "testCount": 5 + }, + "extensions/zalouser/src/send.test.ts": { + "durationMs": 4.2861328125, + "testCount": 13 + }, + "extensions/msteams/src/send.test.ts": { + "durationMs": 4.218017578125, + "testCount": 8 + }, + "extensions/voice-call/src/providers/telnyx.test.ts": { + "durationMs": 4.214599609375, + "testCount": 7 + }, + "extensions/acpx/src/ensure.test.ts": { + "durationMs": 4.14990234375, + "testCount": 10 + }, + "extensions/zalouser/src/accounts.test.ts": { + "durationMs": 4.146728515625, + "testCount": 13 + }, + "extensions/voice-call/src/config.test.ts": { + "durationMs": 3.82763671875, + "testCount": 9 + }, + "extensions/irc/src/send.test.ts": { + "durationMs": 3.75048828125, + "testCount": 2 + }, + "extensions/matrix/src/matrix/monitor/media.test.ts": { + "durationMs": 3.75048828125, + "testCount": 3 + }, + "extensions/feishu/src/monitor.startup.test.ts": { + "durationMs": 3.647216796875, + "testCount": 4 + }, + "extensions/feishu/src/tool-account-routing.test.ts": { + "durationMs": 3.581787109375, + "testCount": 5 + }, + "extensions/mattermost/src/mattermost/slash-commands.test.ts": { + "durationMs": 3.384765625, + "testCount": 9 + }, + "extensions/zalouser/src/setup-surface.test.ts": { + "durationMs": 3.376953125, + "testCount": 6 + }, + "extensions/matrix/src/matrix/send/targets.test.ts": { + "durationMs": 3.356689453125, + "testCount": 9 + }, + "extensions/matrix/src/matrix/send/client.test.ts": { + "durationMs": 3.352294921875, + "testCount": 5 + }, + "extensions/feishu/src/card-interaction.test.ts": { + "durationMs": 3.349609375, + "testCount": 6 + }, + "extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts": { + "durationMs": 3.34228515625, + "testCount": 1 + }, + "extensions/matrix/src/matrix/monitor/route.test.ts": { + "durationMs": 3.331298828125, + "testCount": 4 + }, + "extensions/feishu/src/docx-batch-insert.test.ts": { + "durationMs": 3.28759765625, + "testCount": 2 + }, + "extensions/mattermost/src/mattermost/monitor-slash.test.ts": { + "durationMs": 3.272216796875, + "testCount": 3 + }, + "extensions/line/src/rich-menu.test.ts": { + "durationMs": 3.264892578125, + "testCount": 13 + }, + "extensions/msteams/src/channel.directory.test.ts": { + "durationMs": 3.23876953125, + "testCount": 8 + }, + "extensions/feishu/src/subagent-hooks.test.ts": { + "durationMs": 3.2177734375, + "testCount": 12 + }, + "extensions/nostr/src/nostr-bus.inbound.test.ts": { + "durationMs": 3.209716796875, + "testCount": 4 + }, + "extensions/matrix/src/matrix/monitor/rooms.test.ts": { + "durationMs": 3.2021484375, + "testCount": 8 + }, + "extensions/mattermost/src/mattermost/client.test.ts": { + "durationMs": 3.18212890625, + "testCount": 19 + }, + "extensions/msteams/src/graph-upload.test.ts": { + "durationMs": 3.170166015625, + "testCount": 10 + }, + "extensions/matrix/src/matrix/actions/profile.test.ts": { + "durationMs": 3.1572265625, + "testCount": 2 + }, + "extensions/msteams/src/channel.actions.test.ts": { + "durationMs": 3.152099609375, + "testCount": 9 + }, + "extensions/synology-chat/src/channel.integration.test.ts": { + "durationMs": 3.148193359375, + "testCount": 2 + }, + "extensions/msteams/src/policy.test.ts": { + "durationMs": 3.140625, + "testCount": 17 + }, + "extensions/feishu/src/send.test.ts": { + "durationMs": 3.1298828125, + "testCount": 10 + }, + "extensions/mattermost/src/config-schema.test.ts": { + "durationMs": 3.12548828125, + "testCount": 5 + }, + "extensions/msteams/src/mentions.test.ts": { + "durationMs": 3.082763671875, + "testCount": 20 + }, + "extensions/bluebubbles/src/participant-contact-names.test.ts": { + "durationMs": 3.0771484375, + "testCount": 8 + }, + "extensions/msteams/src/graph-thread.test.ts": { + "durationMs": 3.069580078125, + "testCount": 23 + }, + "extensions/twitch/src/access-control.test.ts": { + "durationMs": 3.05859375, + "testCount": 32 + }, + "extensions/xai/web-search.test.ts": { + "durationMs": 3.056396484375, + "testCount": 21 + }, + "extensions/mattermost/src/mattermost/monitor-websocket.test.ts": { + "durationMs": 2.91259765625, + "testCount": 3 + }, + "extensions/nostr/src/channel.outbound.test.ts": { + "durationMs": 2.844970703125, + "testCount": 1 + }, + "extensions/msteams/src/monitor-handler.file-consent.test.ts": { + "durationMs": 2.759765625, + "testCount": 3 + }, + "extensions/googlechat/src/monitor.webhook-routing.test.ts": { + "durationMs": 2.7568359375, + "testCount": 5 + }, + "extensions/zalouser/src/channel.test.ts": { + "durationMs": 2.692138671875, + "testCount": 7 + }, + "extensions/tlon/src/urbit/send.test.ts": { + "durationMs": 2.661865234375, + "testCount": 1 + }, + "extensions/matrix/src/matrix/sdk/transport.test.ts": { + "durationMs": 2.64013671875, + "testCount": 3 + }, + "extensions/googlechat/src/actions.test.ts": { + "durationMs": 2.63623046875, + "testCount": 3 + }, + "extensions/duckduckgo/src/ddg-search-provider.test.ts": { + "durationMs": 2.543701171875, + "testCount": 8 + }, + "extensions/tlon/src/urbit/base-url.test.ts": { + "durationMs": 2.522705078125, + "testCount": 5 + }, + "extensions/googlechat/src/monitor-webhook.test.ts": { + "durationMs": 2.45458984375, + "testCount": 2 + }, + "extensions/matrix/src/matrix/monitor/config.test.ts": { + "durationMs": 2.448486328125, + "testCount": 3 + }, + "extensions/mattermost/src/mattermost/reply-delivery.test.ts": { + "durationMs": 2.433349609375, + "testCount": 2 + }, + "extensions/feishu/src/chat.test.ts": { + "durationMs": 2.409423828125, + "testCount": 2 + }, + "extensions/feishu/src/bot.checkBotMentioned.test.ts": { + "durationMs": 2.40869140625, + "testCount": 15 + }, + "extensions/matrix/src/actions.test.ts": { + "durationMs": 2.389404296875, + "testCount": 4 + }, + "extensions/exa/src/exa-web-search-provider.test.ts": { + "durationMs": 2.3779296875, + "testCount": 10 + }, + "extensions/fal/image-generation-provider.test.ts": { + "durationMs": 2.342041015625, + "testCount": 6 + }, + "extensions/voice-call/src/providers/shared/guarded-json-api.test.ts": { + "durationMs": 2.32666015625, + "testCount": 3 + }, + "extensions/matrix/src/matrix/direct-management.test.ts": { + "durationMs": 2.3056640625, + "testCount": 5 + }, + "extensions/matrix/src/matrix/client/shared.test.ts": { + "durationMs": 2.3037109375, + "testCount": 7 + }, + "extensions/matrix/src/matrix/poll-types.test.ts": { + "durationMs": 2.288818359375, + "testCount": 9 + }, + "extensions/feishu/src/monitor.cleanup.test.ts": { + "durationMs": 2.2841796875, + "testCount": 3 + }, + "extensions/matrix/src/matrix/sdk/http-client.test.ts": { + "durationMs": 2.269287109375, + "testCount": 4 + }, + "extensions/line/src/auto-reply-delivery.test.ts": { + "durationMs": 2.24951171875, + "testCount": 4 + }, + "extensions/mattermost/src/mattermost/target-resolution.test.ts": { + "durationMs": 2.23291015625, + "testCount": 4 + }, + "extensions/msteams/src/file-consent-helpers.test.ts": { + "durationMs": 2.226806640625, + "testCount": 20 + }, + "extensions/acpx/src/service.test.ts": { + "durationMs": 2.209228515625, + "testCount": 5 + }, + "extensions/mattermost/src/normalize.test.ts": { + "durationMs": 2.20361328125, + "testCount": 17 + }, + "extensions/line/src/message-cards.test.ts": { + "durationMs": 2.19970703125, + "testCount": 18 + }, + "extensions/matrix/src/matrix/profile.test.ts": { + "durationMs": 2.194091796875, + "testCount": 7 + }, + "extensions/feishu/src/docx.account-selection.test.ts": { + "durationMs": 2.19189453125, + "testCount": 2 + }, + "extensions/matrix/src/channel.setup.test.ts": { + "durationMs": 2.178466796875, + "testCount": 6 + }, + "extensions/msteams/src/inbound.test.ts": { + "durationMs": 2.172607421875, + "testCount": 25 + }, + "extensions/feishu/src/accounts.test.ts": { + "durationMs": 2.158447265625, + "testCount": 22 + }, + "extensions/msteams/src/errors.test.ts": { + "durationMs": 2.15673828125, + "testCount": 13 + }, + "extensions/mattermost/src/mattermost/probe.test.ts": { + "durationMs": 2.155517578125, + "testCount": 5 + } + } +} diff --git a/test/scripts/test-parallel.test.ts b/test/scripts/test-parallel.test.ts index 434895141a9..fee39f0bfe2 100644 --- a/test/scripts/test-parallel.test.ts +++ b/test/scripts/test-parallel.test.ts @@ -200,7 +200,46 @@ describe("scripts/test-parallel lane planning", () => { expect(output).toContain("mode=local intent=normal memoryBand=mid"); expect(output).toContain("unit-fast filters=all maxWorkers="); - expect(output).toContain("extensions filters=all maxWorkers="); + expect(output).toMatch(/extensions(?:-batch-1)? filters=all maxWorkers=/); + }); + + it("starts isolated channel lanes before shared extension batches on high-memory local hosts", () => { + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const output = execFileSync( + "node", + [ + "scripts/test-parallel.mjs", + "--plan", + "--surface", + "unit", + "--surface", + "extensions", + "--surface", + "channels", + ], + { + cwd: repoRoot, + env: { + ...clearPlannerShardEnv(process.env), + CI: "", + GITHUB_ACTIONS: "", + RUNNER_OS: "macOS", + OPENCLAW_TEST_HOST_CPU_COUNT: "12", + OPENCLAW_TEST_HOST_MEMORY_GIB: "128", + OPENCLAW_TEST_LOAD_AWARE: "0", + }, + encoding: "utf8", + }, + ); + + const firstChannelIsolated = output.indexOf( + "message-handler.preflight.acp-bindings-channels-isolated", + ); + const firstExtensionBatch = output.indexOf("extensions-batch-1"); + const firstChannelBatch = output.indexOf("channels-batch-1"); + expect(firstChannelIsolated).toBeGreaterThanOrEqual(0); + expect(firstExtensionBatch).toBeGreaterThan(firstChannelIsolated); + expect(firstChannelBatch).toBeGreaterThan(firstExtensionBatch); }); it("explains targeted file ownership and execution policy", () => { diff --git a/test/vitest-config.test.ts b/test/vitest-config.test.ts index b34f9ab4878..5ef78957e30 100644 --- a/test/vitest-config.test.ts +++ b/test/vitest-config.test.ts @@ -153,4 +153,26 @@ describe("resolveLocalVitestMaxWorkers", () => { expect(budget.vitestMaxWorkers).toBe(2); expect(budget.topLevelParallelLimit).toBe(2); }); + + it("enables shared channel batching on high-memory local hosts", () => { + const runtime = resolveRuntimeCapabilities( + { + RUNNER_OS: "macOS", + }, + { + cpuCount: 16, + totalMemoryBytes: 128 * 1024 ** 3, + platform: "darwin", + mode: "local", + loadAverage: [0.2, 0.2, 0.2], + }, + ); + const budget = resolveExecutionBudget(runtime); + + expect(runtime.memoryBand).toBe("high"); + expect(runtime.loadBand).toBe("idle"); + expect(budget.channelsBatchTargetMs).toBe(30_000); + expect(budget.deferredRunConcurrency).toBe(8); + expect(budget.topLevelParallelLimitNoIsolate).toBe(14); + }); });