fix(deps): keep package runtime dependencies single-owned (#126119)

* fix(deps): consolidate shared runtime helpers

* test(concurrency): support current test lib target

* fix(time): preserve year-scale plugin durations

* fix(agents): preserve empty subagent completions
This commit is contained in:
Peter Steinberger
2026-08-18 22:12:45 -07:00
committed by GitHub
parent 0767902330
commit baefd067bb
34 changed files with 252 additions and 171 deletions
-6
View File
@@ -265,7 +265,6 @@ const bundledPluginIgnoredRuntimeDependencies = [
const rootBundledPluginRuntimeDependencies = [
"@anthropic-ai/sdk",
"@anthropic-ai/vertex-sdk",
"@google/genai",
"@grammyjs/runner",
"@grammyjs/transformer-throttler",
@@ -273,16 +272,11 @@ const rootBundledPluginRuntimeDependencies = [
"@mozilla/readability",
"@silvia-odwyer/photon-node",
"@trycua/cua-driver",
"@slack/bolt",
"@slack/types",
"@slack/web-api",
"grammy",
"linkedom",
"minimatch",
"node-edge-tts",
"openshell",
"clawpdf",
"tokenjuice",
] as const;
// Root installation and build workflows deliberately mirror these dependencies from their
-1
View File
@@ -13,7 +13,6 @@
"discord-api-types": "0.38.52",
"libopus-wasm": "0.2.0",
"mdast-util-from-markdown": "2.0.3",
"p-map": "7.0.6",
"typebox": "1.3.6",
"undici": "8.9.0",
"ws": "8.21.1",
@@ -1,4 +1,5 @@
import { readAcpSessionEntry, type AcpSessionStoreEntry } from "openclaw/plugin-sdk/acp-runtime";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Discord plugin module implements thread bindings.lifecycle behavior.
import {
@@ -6,7 +7,6 @@ import {
normalizeOptionalString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap from "p-map";
import { parseDiscordTarget } from "../targets.js";
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
import { getThreadBindingManager } from "./thread-bindings.manager.js";
@@ -266,9 +266,8 @@ export async function reconcileAcpThreadBindingsOnStartup(params: {
}
if (params.healthProbe && probeTargets.length > 0) {
const probeResults = await pMap(
probeTargets,
async ({ binding, sessionKey, session }) => {
const { results: probeResults } = await runTasksWithConcurrency({
tasks: probeTargets.map(({ binding, sessionKey, session }) => async () => {
try {
const result = await params.healthProbe?.({
cfg: params.cfg,
@@ -288,12 +287,11 @@ export async function reconcileAcpThreadBindingsOnStartup(params: {
status: "uncertain" satisfies AcpThreadBindingHealthStatus,
};
}
},
{
concurrency: ACP_STARTUP_HEALTH_PROBE_CONCURRENCY_LIMIT,
stopOnError: true,
},
);
}),
limit: ACP_STARTUP_HEALTH_PROBE_CONCURRENCY_LIMIT,
errorMode: "stop",
throwOnError: true,
});
for (const probeResult of probeResults) {
if (probeResult.status === "stale") {
-1
View File
@@ -9,7 +9,6 @@
"type": "module",
"dependencies": {
"jszip": "3.10.1",
"pretty-ms": "9.3.0",
"typebox": "1.3.6"
},
"devDependencies": {
+3 -4
View File
@@ -12,7 +12,7 @@ import {
parseStrictPositiveInteger,
} from "openclaw/plugin-sdk/number-runtime";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import prettyMilliseconds from "pretty-ms";
import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime";
import type { GoogleMeetCalendarLookupResult } from "./calendar.js";
import type { GoogleMeetModeInput, GoogleMeetTransport } from "./config.js";
import type { GoogleMeetRuntime } from "./runtime.js";
@@ -355,9 +355,8 @@ export function formatDuration(value: number | undefined): string {
if (value === undefined) {
return "n/a";
}
return prettyMilliseconds(Math.max(0, Math.round(value / 1000) * 1000), {
unitCount: 2,
});
const roundedMs = Math.max(0, Math.round(value / 1000) * 1000);
return formatDurationCompact(roundedMs, { showYears: true, spaced: true }) ?? "0ms";
}
export function writeDoctorStatus(status: Awaited<ReturnType<GoogleMeetRuntime["status"]>>): void {
-1
View File
@@ -6,7 +6,6 @@
"type": "module",
"dependencies": {
"mdast-util-from-markdown": "2.0.3",
"p-map": "7.0.6",
"typebox": "1.3.6",
"yaml": "2.9.0",
"zod": "4.4.3"
@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import type {
OpenKeyedStoreOptions,
@@ -12,7 +13,6 @@ import {
normalizeOptionalString,
normalizeUniqueTrimmedStringList,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap, { pMapSkip } from "p-map";
import { walkMemoryWikiDirectory } from "./bounded-walk.js";
const LEGACY_IMPORT_RUN_READ_CONCURRENCY = 16;
@@ -510,12 +510,16 @@ export async function readLegacyMemoryWikiImportRunRecords(
}
throw error;
});
return await pMap(
entries.filter((entry) => entry.kind === "file"),
async (entry) => {
const raw = await fs.readFile(path.join(importRunsDir, entry.relativePath), "utf8");
return normalizeMemoryWikiImportRunRecord(JSON.parse(raw) as unknown) ?? pMapSkip;
},
{ concurrency: LEGACY_IMPORT_RUN_READ_CONCURRENCY, stopOnError: true },
);
const { results } = await runTasksWithConcurrency({
tasks: entries
.filter((entry) => entry.kind === "file")
.map((entry) => async () => {
const raw = await fs.readFile(path.join(importRunsDir, entry.relativePath), "utf8");
return normalizeMemoryWikiImportRunRecord(JSON.parse(raw) as unknown);
}),
limit: LEGACY_IMPORT_RUN_READ_CONCURRENCY,
errorMode: "stop",
throwOnError: true,
});
return results.filter((record): record is ChatGptImportRunRecord => record !== null);
}
+10 -8
View File
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { filterMemorySearchHitsBySessionVisibility } from "@openclaw/memory-core/api.js";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
@@ -10,7 +11,6 @@ import {
normalizeLowercaseStringOrEmpty,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap, { pMapSkip } from "p-map";
import type { OpenClawConfig } from "../api.js";
import { walkMemoryWikiDirectory } from "./bounded-walk.js";
import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js";
@@ -230,16 +230,18 @@ async function readQueryableWikiPagesByPaths(
rootDir: string,
files: string[],
): Promise<QueryableWikiPage[]> {
return await pMap(
files,
async (relativePath) => {
const { results } = await runTasksWithConcurrency({
tasks: files.map((relativePath) => async () => {
const absolutePath = path.join(rootDir, relativePath);
const raw = await fs.readFile(absolutePath, "utf8");
const summary = toWikiPageSummary({ absolutePath, relativePath, raw });
return summary ? { ...summary, raw } : pMapSkip;
},
{ concurrency: QUERY_PAGE_READ_CONCURRENCY, stopOnError: true },
);
return summary ? { ...summary, raw } : null;
}),
limit: QUERY_PAGE_READ_CONCURRENCY,
errorMode: "stop",
throwOnError: true,
});
return results.filter((page): page is QueryableWikiPage => page !== null);
}
async function readQueryDigestBundle(
+8 -7
View File
@@ -2,8 +2,8 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap from "p-map";
import { walkMemoryWikiDirectory } from "./bounded-walk.js";
import type { BridgeMemoryWikiResult } from "./bridge.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
@@ -253,9 +253,8 @@ export async function syncMemoryWikiUnsafeLocalSources(
group: "unsafe-local",
incomingCount: new Set([...artifacts.map((artifact) => artifact.syncKey), ...activeKeys]).size,
});
const results = await pMap(
artifacts,
async (artifact) => {
const { results } = await runTasksWithConcurrency({
tasks: artifacts.map((artifact) => async () => {
const stats = await fs.stat(artifact.absolutePath);
activeKeys.add(artifact.syncKey);
return await writeUnsafeLocalSourcePage({
@@ -265,9 +264,11 @@ export async function syncMemoryWikiUnsafeLocalSources(
sourceSize: stats.size,
state,
});
},
{ concurrency: UNSAFE_LOCAL_SYNC_CONCURRENCY, stopOnError: true },
);
}),
limit: UNSAFE_LOCAL_SYNC_CONCURRENCY,
errorMode: "stop",
throwOnError: true,
});
const removedCount = await pruneImportedSourceEntries({
vaultRoot: config.vault.path,
-3
View File
@@ -8,10 +8,7 @@
"@copilotkit/aimock": "1.37.4",
"@modelcontextprotocol/sdk": "1.30.0",
"@openclaw/crabline": "0.1.11",
"p-limit": "7.3.1",
"p-map": "7.0.6",
"playwright-core": "1.62.1",
"pretty-ms": "9.3.0",
"semver": "7.8.5",
"ws": "8.21.1",
"yaml": "2.9.0",
+17 -17
View File
@@ -1,10 +1,10 @@
// Qa Lab plugin module implements character eval behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap from "p-map";
import prettyMilliseconds from "pretty-ms";
import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js";
import {
@@ -242,9 +242,7 @@ function formatDuration(ms: number) {
return "unknown";
}
const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 1000) * 1000;
return prettyMilliseconds(roundedMs, {
unitCount: 2,
});
return formatDurationCompact(roundedMs, { showYears: true, spaced: true }) ?? "0ms";
}
function logCharacterEvalProgress(
@@ -518,9 +516,8 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
`start scenario=${scenarioId} candidates=${models.length} candidateConcurrency=${candidateConcurrency} output=${outputDir}`,
);
const candidatesStartedAt = Date.now();
const runs = await pMap(
models,
async (model, index) => {
const { results: runs } = await runTasksWithConcurrency({
tasks: models.map((model, index) => async () => {
const thinkingDefault = resolveCandidateThinkingDefault({
model,
candidateThinkingDefault: params.candidateThinkingDefault,
@@ -599,9 +596,11 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
);
return run;
}
},
{ concurrency: candidateConcurrency, stopOnError: true },
);
}),
limit: candidateConcurrency,
errorMode: "stop",
throwOnError: true,
});
const failedCandidateCount = runs.filter((run) => run.status === "fail").length;
logCharacterEvalProgress(
params.progress,
@@ -626,9 +625,8 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
`judges start judges=${judgeModels.length} judgeConcurrency=${judgeConcurrency} timeout=${formatDuration(judgeTimeoutMs)} labels=${params.judgeBlindModels === true ? "blind" : "visible"}`,
);
const judgesStartedAt = Date.now();
const judgments = await pMap(
judgeModels,
async (judgeModel, index) => {
const { results: judgments } = await runTasksWithConcurrency({
tasks: judgeModels.map((judgeModel, index) => async () => {
const judgeOptions = resolveJudgeOptions({
model: judgeModel,
judgeThinkingDefault: params.judgeThinkingDefault,
@@ -680,9 +678,11 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
`judge done ${formatEvalIndex(index, judgeModels.length)} model=${judgeModel} rankings=${rankings.length} duration=${formatDuration(judgment.durationMs)}${judgeError ? ` error="${judgeError}"` : ""}`,
);
return judgment;
},
{ concurrency: judgeConcurrency, stopOnError: true },
);
}),
limit: judgeConcurrency,
errorMode: "stop",
throwOnError: true,
});
const failedJudgeCount = judgments.filter((judgment) => judgment.rankings.length === 0).length;
logCharacterEvalProgress(
params.progress,
+51 -42
View File
@@ -4,12 +4,12 @@ import os from "node:os";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
import { pathToFileURL } from "node:url";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
asNullableRecord as readRecord,
readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pLimit from "p-limit";
import type {
QaEvidenceArtifactView,
QaEvidenceGalleryEntryView,
@@ -890,50 +890,59 @@ export async function buildQaEvidenceGalleryModel(params: {
repoRoot,
summaryEntries: summary.entries,
});
const limitArtifactView = pLimit(ARTIFACT_VIEW_CONCURRENCY);
const entries = await Promise.all(
summary.entries.map(async (entry, entryIndex): Promise<QaEvidenceGalleryEntryView> => {
counts[entry.result.status] += 1;
const sanitizeEntryText = (value: string) =>
sanitizeGalleryText(value, {
const artifactTasks = summary.entries.flatMap((entry, entryIndex) =>
(entry.execution?.artifacts ?? []).map(
(artifact, artifactIndex) => () =>
buildArtifactView({
allowedArtifactFiles,
artifact,
artifactIndex,
evidenceDir,
entryIndex,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
});
return {
artifacts: await limitArtifactView.map(
entry.execution?.artifacts ?? [],
(artifact, artifactIndex) =>
buildArtifactView({
allowedArtifactFiles,
artifact,
artifactIndex,
evidenceDir,
entryIndex,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
}),
),
coverage: entry.coverage.map((coverage) => ({
id: sanitizeEntryText(coverage.id),
role: sanitizeEntryText(coverage.role),
})),
failureReason: entry.result.failure?.reason
? sanitizeEntryText(entry.result.failure.reason)
: null,
id: sanitizeEntryText(entry.test.id),
kind: sanitizeEntryText(entry.test.kind),
sourcePath: entry.test.source?.path
? displayGalleryPath(entry.test.source.path, {
extraRoots: [requestedRepoRoot],
repoRoot,
})
: null,
status: entry.result.status,
title: sanitizeEntryText(entry.test.title),
};
}),
}),
),
);
const { results: artifactViews } = await runTasksWithConcurrency({
tasks: artifactTasks,
limit: ARTIFACT_VIEW_CONCURRENCY,
errorMode: "continue",
throwOnError: true,
});
let artifactOffset = 0;
const entries = summary.entries.map((entry): QaEvidenceGalleryEntryView => {
counts[entry.result.status] += 1;
const artifactCount = entry.execution?.artifacts?.length ?? 0;
const artifacts = artifactViews.slice(artifactOffset, artifactOffset + artifactCount);
artifactOffset += artifactCount;
const sanitizeEntryText = (value: string) =>
sanitizeGalleryText(value, {
extraRoots: [requestedRepoRoot],
repoRoot,
});
return {
artifacts,
coverage: entry.coverage.map((coverage) => ({
id: sanitizeEntryText(coverage.id),
role: sanitizeEntryText(coverage.role),
})),
failureReason: entry.result.failure?.reason
? sanitizeEntryText(entry.result.failure.reason)
: null,
id: sanitizeEntryText(entry.test.id),
kind: sanitizeEntryText(entry.test.kind),
sourcePath: entry.test.source?.path
? displayGalleryPath(entry.test.source.path, {
extraRoots: [requestedRepoRoot],
repoRoot,
})
: null,
status: entry.result.status,
title: sanitizeEntryText(entry.test.title),
};
});
return {
counts,
entries,
@@ -1,8 +1,9 @@
// Qa Lab tests cover Slack live adapter message reconciliation.
import type { FetchFunction } from "@slack/web-api";
import { describe, expect, it } from "vitest";
import { createQaBusState } from "../../bus-state.js";
import { testing } from "./adapter.runtime.js";
import type { SlackQaFetchFunction as FetchFunction } from "./slack-live.contracts.js";
describe("Slack live adapter reconciliation", () => {
it("aborts an in-flight observer fetch when the adapter stops", async () => {
@@ -6,7 +6,6 @@ import {
createSlackWriteClient,
resolveSlackWebClientOptions,
} from "@openclaw/slack/api.js";
import type { FetchFunction } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { acquireDebugProxyCaptureStore } from "openclaw/plugin-sdk/proxy-capture";
@@ -22,7 +21,11 @@ import {
parseSlackQaCredentialPayload,
resolveSlackQaRuntimeEnv,
} from "./slack-live.config.js";
import type { SlackMessage, SlackQaRuntimeEnv } from "./slack-live.contracts.js";
import type {
SlackMessage,
SlackQaFetchFunction,
SlackQaRuntimeEnv,
} from "./slack-live.contracts.js";
import { waitForSlackChannelStable } from "./slack-live.message-observations.js";
import {
getSlackIdentity,
@@ -33,6 +36,7 @@ import {
type AdapterFactory = NonNullable<QaRunnerCliRegistration["adapterFactory"]>;
type FactoryContext = Parameters<AdapterFactory["create"]>[0];
type FetchFunction = SlackQaFetchFunction;
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>>;
const SLACK_POLL_INTERVAL_MS = 500;
@@ -1,5 +1,4 @@
import path from "node:path";
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime";
import {
@@ -14,6 +13,7 @@ import type {
SlackQaScenarioContext,
SlackQaScenarioMetadata,
SlackQaScenarioRun,
SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
import { assertSlackCodexApprovalModelSupported } from "./slack-live.contracts.js";
import { waitForSlackChannelStable } from "./slack-live.message-observations.js";
@@ -1,6 +1,5 @@
// QA Lab Slack native approval observation and resolution.
import { randomUUID } from "node:crypto";
import type { WebClient } from "@slack/web-api";
import type { ChannelApprovalKind } from "openclaw/plugin-sdk/approval-handler-runtime";
import { assertApprovalDecisionResult } from "../shared/live-approval-result.js";
import {
@@ -18,6 +17,7 @@ import {
type SlackObservedMessage,
type SlackApprovalArtifact,
type SlackMessage,
type SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
import {
listSlackMessages,
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { listSlackReactions } from "@openclaw/slack/api.js";
import type { WebClient } from "@slack/web-api";
import { extractGatewayMessageText } from "../../gateway-log-sentinel.js";
import { formatApprovalResultValue } from "../shared/live-approval-result.js";
import { asPlainRecord } from "./slack-live.config.js";
@@ -13,6 +12,7 @@ import {
type SlackQaCodexApprovalScenarioRun,
type SlackQaScenarioContext,
type SlackQaScenarioMetadata,
type SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
export function resolveCodexFileApprovalTargetPath(token: string) {
@@ -1,5 +1,4 @@
// QA Lab Slack credentials, instrumentation, and channel config.
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { asNonArrayRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
@@ -7,6 +6,7 @@ import {
type SlackQaConfigOverrides,
SLACK_QA_ENV_KEYS,
slackQaCredentialPayloadSchema,
type SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)[number]) {
@@ -1,11 +1,17 @@
// QA Lab Slack live domain contracts and wire schemas.
import type { WebClient } from "@slack/web-api";
import type { createSlackWebClient } from "@openclaw/slack/api.js";
import type { ChannelApprovalKind } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { z } from "zod";
import type { startQaGatewayChild } from "../../gateway-child.js";
import { splitQaModelRef } from "../../model-selection.js";
export type SlackQaWebClient = ReturnType<typeof createSlackWebClient>;
export type SlackQaFetchFunction = NonNullable<
NonNullable<Parameters<typeof createSlackWebClient>[1]>["fetch"]
>;
type WebClient = SlackQaWebClient;
export type SlackQaRuntimeEnv = {
channelId: string;
driverBotToken: string;
@@ -1,5 +1,4 @@
// QA Lab Slack scenario reply observation and channel readiness.
import type { WebClient } from "@slack/web-api";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import type { startQaGatewayChild } from "../../gateway-child.js";
@@ -11,6 +10,7 @@ import {
type SlackAuthIdentity,
type SlackObservedMessage,
type SlackMessage,
type SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
import {
listSlackMessages,
@@ -1,7 +1,6 @@
// QA Lab Slack Web API and stored-message observations.
import { isDeepStrictEqual } from "node:util";
import { createSlackWebClient, sendSlackMessage } from "@openclaw/slack/api.js";
import type { WebClient } from "@slack/web-api";
import {
asPlainRecord,
countSlackNativeDataBlocks,
@@ -23,6 +22,7 @@ import {
type SlackMessage,
slackHistorySchema,
slackRepliesSchema,
type SlackQaWebClient as WebClient,
} from "./slack-live.contracts.js";
import { buildSlackInvalidBlocksTableProbe } from "./slack-live.invalid-blocks.js";
+8 -10
View File
@@ -1,8 +1,8 @@
// Qa Lab plugin module implements suite planning behavior.
import path from "node:path";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap from "p-map";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
@@ -430,9 +430,8 @@ async function mapQaSuiteWithConcurrency<T, U>(
}
})();
}
const results = await pMap(
items,
async (item, index) => {
const { results } = await runTasksWithConcurrency({
tasks: items.map((item, index) => async () => {
if (stopped) {
return undefined;
}
@@ -445,12 +444,11 @@ async function mapQaSuiteWithConcurrency<T, U>(
stopped = true;
}
return result;
},
{
concurrency: Math.max(1, Math.floor(concurrency)),
stopOnError: true,
},
);
}),
limit: Math.max(1, Math.floor(concurrency)),
errorMode: "stop",
throwOnError: true,
});
const completed: U[] = [];
for (const result of results) {
if (result !== undefined) {
+2 -4
View File
@@ -1,4 +1,4 @@
import prettyMilliseconds from "pretty-ms";
import { formatDurationCompact } from "openclaw/plugin-sdk/time-runtime";
export function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString([], {
@@ -23,9 +23,7 @@ export function formatIso(iso?: string) {
export function formatDuration(ms: number): string {
const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 1000) * 1000;
return prettyMilliseconds(Math.max(0, roundedMs), {
unitCount: 2,
});
return formatDurationCompact(Math.max(0, roundedMs), { showYears: true, spaced: true }) ?? "0ms";
}
export function esc(text: string) {
-1
View File
@@ -13,7 +13,6 @@
"@slack/types": "3.0.0",
"@slack/web-api": "8.0.0",
"get-east-asian-width": "1.6.0",
"p-map": "7.0.6",
"typebox": "1.3.6",
"undici": "7.29.0",
"ws": "8.21.1",
+13 -11
View File
@@ -1,6 +1,7 @@
// Slack plugin module implements media behavior.
import fs from "node:fs/promises";
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
@@ -8,7 +9,6 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import pMap, { pMapSkip } from "p-map";
import { formatSlackFileReference } from "../file-reference.js";
import type { SlackAttachment, SlackFile } from "../types.js";
import { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "./media-types.js";
@@ -332,9 +332,8 @@ export async function resolveSlackMedia(params: {
const limitedFiles =
files.length > MAX_SLACK_MEDIA_FILES ? files.slice(0, MAX_SLACK_MEDIA_FILES) : files;
const resolved = await pMap(
limitedFiles,
async (file): Promise<SlackMediaResult | typeof pMapSkip> => {
const { results } = await runTasksWithConcurrency({
tasks: limitedFiles.map((file) => async (): Promise<SlackMediaResult | null> => {
// Audio preflight keys the original event file object so admission can
// reuse that exact download without turning this into a persistent cache.
const preloaded = params.preloadedMedia?.get(file);
@@ -344,7 +343,7 @@ export async function resolveSlackMedia(params: {
const eventUrl = file.url_private_download ?? file.url_private;
const url = eventUrl ?? (await fetchFreshSlackFileUrl({ file, client: params.client }));
if (!url) {
return pMapSkip;
return null;
}
const result = await downloadSlackMediaFile({
file,
@@ -357,12 +356,12 @@ export async function resolveSlackMedia(params: {
govSlack,
}).catch(() => null);
if (result || !eventUrl) {
return result ?? pMapSkip;
return result;
}
const freshUrl = await fetchFreshSlackFileUrl({ file, client: params.client });
if (!freshUrl) {
return pMapSkip;
return null;
}
const retryResult = await downloadSlackMediaFile({
file,
@@ -374,10 +373,13 @@ export async function resolveSlackMedia(params: {
abortSignal: params.abortSignal,
govSlack,
}).catch(() => null);
return retryResult ?? pMapSkip;
},
{ concurrency: MAX_SLACK_MEDIA_CONCURRENCY, stopOnError: true },
);
return retryResult;
}),
limit: MAX_SLACK_MEDIA_CONCURRENCY,
errorMode: "stop",
throwOnError: true,
});
const resolved = results.filter((result): result is SlackMediaResult => result !== null);
return resolved.length > 0 ? resolved : null;
}
-21
View File
@@ -832,9 +832,6 @@ importers:
mdast-util-from-markdown:
specifier: 2.0.3
version: 2.0.3(supports-color@10.2.2)
p-map:
specifier: 7.0.6
version: 7.0.6
typebox:
specifier: 1.3.6
version: 1.3.6
@@ -994,9 +991,6 @@ importers:
jszip:
specifier: 3.10.1
version: 3.10.1
pretty-ms:
specifier: 9.3.0
version: 9.3.0
typebox:
specifier: 1.3.6
version: 1.3.6
@@ -1286,9 +1280,6 @@ importers:
mdast-util-from-markdown:
specifier: 2.0.3
version: 2.0.3(supports-color@10.2.2)
p-map:
specifier: 7.0.6
version: 7.0.6
typebox:
specifier: 1.3.6
version: 1.3.6
@@ -1621,18 +1612,9 @@ importers:
'@openclaw/crabline':
specifier: 0.1.11
version: 0.1.11
p-limit:
specifier: 7.3.1
version: 7.3.1
p-map:
specifier: 7.0.6
version: 7.0.6
playwright-core:
specifier: 1.62.1
version: 1.62.1
pretty-ms:
specifier: 9.3.0
version: 9.3.0
semver:
specifier: 7.8.5
version: 7.8.5
@@ -1769,9 +1751,6 @@ importers:
get-east-asian-width:
specifier: 1.6.0
version: 1.6.0
p-map:
specifier: 7.0.6
version: 7.0.6
typebox:
specifier: 1.3.6
version: 1.3.6
@@ -2960,6 +2960,8 @@ describe("CLI attempt execution", () => {
});
expect(embeddedArg.suppressLiveStreamOutput).toBe(false);
expect(embeddedArg.terminalReplyExpectation).toBe("optional");
expect(embeddedArg.allowEmptyAssistantReplyAsSilent).toBe(true);
});
it("forwards exact cron creator authority into embedded execution", async () => {
+5 -1
View File
@@ -83,6 +83,7 @@ import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-lo
import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js";
import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
import { isCliProvider } from "../model-selection.js";
import { resolveOpenAIRuntimeProvider } from "../openai-routing.js";
@@ -1138,6 +1139,8 @@ export function runAgentAttempt(params: {
sandboxSessionKey: params.sessionKey,
agentId: params.sessionAgentId,
trigger: "user",
// Subagent lifecycle owns the stricter explicit visible/silent/empty evidence check.
terminalReplyExpectation: params.opts.lane === AGENT_LANE_SUBAGENT ? "optional" : undefined,
messageChannel: params.messageChannel,
messageProvider: params.opts.messageProvider ?? params.messageChannel,
agentAccountId: params.runContext.accountId,
@@ -1227,7 +1230,8 @@ export function runAgentAttempt(params: {
modelRun: params.opts.modelRun,
promptMode: params.opts.promptMode,
disableTools,
allowEmptyAssistantReplyAsSilent: isSubagentAnnounceHandoff,
allowEmptyAssistantReplyAsSilent:
params.opts.lane === AGENT_LANE_SUBAGENT || isSubagentAnnounceHandoff,
onAgentEvent: params.onAgentEvent,
deferTerminalLifecycle: params.deferTerminalLifecycle,
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
+3 -1
View File
@@ -9,6 +9,8 @@ export type FormatDurationSecondsOptions = {
};
export type FormatDurationCompactOptions = {
/** Show year units instead of folding them into days. Default: false */
showYears?: boolean;
/** Add space between units: "2m 5s" instead of "2m5s". Default: false */
spaced?: boolean;
};
@@ -64,7 +66,7 @@ export function formatDurationCompact(
return prettyMilliseconds(roundedMs);
}
const formatted = prettyMilliseconds(Math.round(ms / 1000) * 1000, {
hideYear: true,
hideYear: options?.showYears !== true,
unitCount: 2,
});
return options?.spaced ? formatted : formatted.replaceAll(" ", "");
@@ -61,6 +61,11 @@ describe("format-duration", () => {
{ input: 65000, options: { spaced: true }, expected: "1m 5s" },
{ input: 3660000, options: { spaced: true }, expected: "1h 1m" },
{ input: 90000000, options: { spaced: true }, expected: "1d 1h" },
{
input: 366 * 86400000,
options: { showYears: true, spaced: true },
expected: "1y 1d",
},
{ input: 59500, expected: "1m" },
{ input: 59400, expected: "59s" },
])("formats compact duration for %j", ({ input, options, expected }) => {
+1
View File
@@ -6,3 +6,4 @@ export {
formatZonedTimestamp,
resolveTimezone,
} from "../infra/format-time/format-datetime.js";
export { formatDurationCompact } from "../infra/format-time/format-duration.js";
+70
View File
@@ -2,6 +2,14 @@
import { describe, expect, it, vi } from "vitest";
import { runTasksWithConcurrency } from "./run-with-concurrency.js";
function createDeferred() {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("runTasksWithConcurrency", () => {
it("preserves task order with bounded worker count", async () => {
let running = 0;
@@ -107,4 +115,66 @@ describe("runTasksWithConcurrency", () => {
expect(onTaskError).toHaveBeenNthCalledWith(1, firstErr, 0);
expect(onTaskError).toHaveBeenNthCalledWith(2, secondErr, 2);
});
it("rejects early and stops scheduling new work in stop mode", async () => {
const err = new Error("boom");
const releaseInFlight = createDeferred();
const inFlightSettled = createDeferred();
const started: number[] = [];
const run = runTasksWithConcurrency({
tasks: [
async () => {
started.push(0);
await releaseInFlight.promise;
inFlightSettled.resolve();
return 10;
},
async () => {
started.push(1);
throw err;
},
async () => {
started.push(2);
return 30;
},
],
limit: 2,
errorMode: "stop",
throwOnError: true,
});
await expect(run).rejects.toBe(err);
releaseInFlight.resolve();
await inFlightSettled.promise;
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(started).toEqual([0, 1]);
});
it("keeps scheduling after an early rejection in continue mode", async () => {
const err = new Error("boom");
const completed = createDeferred();
const started: number[] = [];
const run = runTasksWithConcurrency({
tasks: [
async () => {
started.push(0);
throw err;
},
async () => {
started.push(1);
completed.resolve();
return 20;
},
],
limit: 1,
errorMode: "continue",
throwOnError: true,
});
await expect(run).rejects.toBe(err);
await completed.promise;
expect(started).toEqual([0, 1]);
});
});
+11 -2
View File
@@ -11,6 +11,8 @@ export type RunTasksWithConcurrencyOptions<T> = {
limit: number;
/** `stop` prevents new work after the first failure; in-flight workers still settle. */
errorMode?: ConcurrencyErrorMode;
/** Reject immediately on a task failure instead of returning aggregate error state. */
throwOnError?: boolean;
/** Called once per failed task with the original task index. */
onTaskError?: (error: unknown, index: number) => void;
};
@@ -29,7 +31,7 @@ export type RunTasksWithConcurrencyResult<T> = {
export async function runTasksWithConcurrency<T>(
params: RunTasksWithConcurrencyOptions<T>,
): Promise<RunTasksWithConcurrencyResult<T>> {
const { tasks, limit, onTaskError } = params;
const { tasks, limit, onTaskError, throwOnError = false } = params;
const errorMode = params.errorMode ?? "continue";
if (tasks.length === 0) {
return { results: [], firstError: undefined, hasError: false };
@@ -56,10 +58,17 @@ export async function runTasksWithConcurrency<T>(
hasError = true;
}
onTaskError?.(error, index);
if (throwOnError) {
throw error;
}
}
}),
);
await Promise.allSettled(runs);
if (throwOnError) {
await Promise.all(runs);
} else {
await Promise.allSettled(runs);
}
return { results, firstError, hasError };
}
+1 -1
View File
@@ -514,7 +514,7 @@ function shouldExternalizeGatewayProtocolDependency(id: string): boolean {
}
function shouldExternalizeGatewayClientDependency(id: string): boolean {
return ["ws", "@openclaw/gateway-protocol"].some(
return ["ws", "@openclaw/gateway-protocol", "ipaddr.js"].some(
(dependency) => id === dependency || id.startsWith(`${dependency}/`),
);
}