perf(gateway): warm handler families and cache identity avatars (#114754)

* perf(gateway): warm handler families and cache identity avatars

* chore: drop changelog edit (release-generation-owned)

* fix(ci): regenerate protocol models, satisfy knip, absorb sidecar drift

- commit regenerated Swift GatewayModels for the additive cron.list param
- split the avatar data-url cache into assistant-avatar-cache.ts so its
  factory has a production consumer (knip production scan)
- de-export prewarm family names/type; test now drives fake families only
- postReadySidecarCount 2 -> 3: main added a post-ready sidecar in parallel
This commit is contained in:
Peter Steinberger
2026-07-27 19:37:13 -04:00
committed by GitHub
parent b12f631a4a
commit 313daad549
28 changed files with 1045 additions and 207 deletions
@@ -13506,6 +13506,7 @@ public struct CronListParams: Codable, Sendable {
public let sortdir: AnyCodable?
public let agentid: String?
public let compact: Bool?
public let includedeliverypreviews: Bool?
public init(
includedisabled: Bool? = nil,
@@ -13518,7 +13519,8 @@ public struct CronListParams: Codable, Sendable {
sortby: AnyCodable? = nil,
sortdir: AnyCodable? = nil,
agentid: String? = nil,
compact: Bool? = nil)
compact: Bool? = nil,
includedeliverypreviews: Bool? = nil)
{
self.includedisabled = includedisabled
self.limit = limit
@@ -13531,6 +13533,7 @@ public struct CronListParams: Codable, Sendable {
self.sortdir = sortdir
self.agentid = agentid
self.compact = compact
self.includedeliverypreviews = includedeliverypreviews
}
private enum CodingKeys: String, CodingKey {
@@ -13545,6 +13548,7 @@ public struct CronListParams: Codable, Sendable {
case sortdir = "sortDir"
case agentid = "agentId"
case compact
case includedeliverypreviews = "includeDeliveryPreviews"
}
}
@@ -440,6 +440,7 @@ describe("cron protocol validators", () => {
sortDir: "asc",
agentId: "ops",
compact: true,
includeDeliveryPreviews: false,
}),
).toBe(true);
expect(validateCronListParams({ offset: -1 })).toBe(false);
@@ -569,6 +569,7 @@ export const CronListParamsSchema = closedObject({
sortDir: Type.Optional(CronSortDirSchema),
agentId: Type.Optional(NonEmptyString),
compact: Type.Optional(Type.Boolean()),
includeDeliveryPreviews: Type.Optional(Type.Boolean()),
});
/** Empty request payload for scheduler status. */
+18 -1
View File
@@ -29,6 +29,13 @@ type LocalAgentAvatarFailureReason =
export type OpenedLocalAgentAvatarFile = {
path: string;
fd: number;
stat: {
ctimeMs: number;
dev: number;
ino: number;
mtimeMs: number;
size: number;
};
};
type LocalAgentAvatarPath = {
@@ -97,7 +104,17 @@ function openResolvedLocalAgentAvatarFile(
fs.closeSync(opened.fd);
return null;
}
return { path: opened.path, fd: opened.fd };
return {
path: opened.path,
fd: opened.fd,
stat: {
ctimeMs: opened.stat.ctimeMs,
dev: opened.stat.dev,
ino: opened.stat.ino,
mtimeMs: opened.stat.mtimeMs,
size: opened.stat.size,
},
};
} catch {
return null;
}
+72
View File
@@ -0,0 +1,72 @@
// Bounded data-URL cache for locally resolved assistant avatars. Kept apart
// from the avatar projection so cache policy (identity validation, LRU bound)
// stays independently testable with injected read/close seams.
import fs from "node:fs";
import {
readOpenedLocalAgentAvatarDataUrl,
type OpenedLocalAgentAvatarFile,
} from "../agents/identity-avatar-file.js";
type AvatarDataUrlCacheEntry = {
ctimeMs: number;
dev: number;
ino: number;
mtimeMs: number;
size: number;
dataUrl: string;
};
const GATEWAY_AVATAR_DATA_URL_CACHE_MAX_ENTRIES = 4;
export function createGatewayAvatarDataUrlCache(params?: {
maxEntries?: number;
read?: (opened: OpenedLocalAgentAvatarFile) => string | undefined;
close?: (fd: number) => void;
}) {
const maxEntries = params?.maxEntries ?? GATEWAY_AVATAR_DATA_URL_CACHE_MAX_ENTRIES;
const read = params?.read ?? readOpenedLocalAgentAvatarDataUrl;
const close = params?.close ?? ((fd: number) => fs.closeSync(fd));
const entries = new Map<string, AvatarDataUrlCacheEntry>();
return {
read(opened: OpenedLocalAgentAvatarFile): string | undefined {
const cached = entries.get(opened.path);
if (
cached &&
cached.ctimeMs === opened.stat.ctimeMs &&
cached.dev === opened.stat.dev &&
cached.ino === opened.stat.ino &&
cached.mtimeMs === opened.stat.mtimeMs &&
cached.size === opened.stat.size
) {
close(opened.fd);
entries.delete(opened.path);
entries.set(opened.path, cached);
return cached.dataUrl;
}
entries.delete(opened.path);
const dataUrl = read(opened);
if (!dataUrl || maxEntries <= 0) {
return dataUrl;
}
// The boundary-safe open already fstats the pinned descriptor. Reuse base64
// only while its file identity and mtime/size match; otherwise an atomic
// same-size replacement could leave stale identity data indefinitely.
entries.set(opened.path, {
ctimeMs: opened.stat.ctimeMs,
dev: opened.stat.dev,
ino: opened.stat.ino,
mtimeMs: opened.stat.mtimeMs,
size: opened.stat.size,
dataUrl,
});
while (entries.size > maxEntries) {
const oldestPath = entries.keys().next().value;
if (oldestPath === undefined) {
break;
}
entries.delete(oldestPath);
}
return dataUrl;
},
};
}
+35 -1
View File
@@ -1,9 +1,10 @@
// Gateway assistant-avatar tests cover selected-source precedence and safe fallbacks.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createGatewayAvatarDataUrlCache } from "./assistant-avatar-cache.js";
import { openGatewayAssistantAvatar, resolveGatewayAssistantAvatar } from "./assistant-avatar.js";
import { resolveAssistantIdentity } from "./assistant-identity.js";
@@ -31,6 +32,39 @@ function projectAvatar(cfg: OpenClawConfig): GatewayAssistantAvatarProjection {
}
describe("resolveGatewayAssistantAvatar", () => {
it("reuses unchanged pinned files and rereads after mtime or size changes", () => {
const read = vi.fn(
(opened: { stat: { mtimeMs: number; size: number } }) =>
`data:image/png;base64,${opened.stat.mtimeMs}:${opened.stat.size}`,
);
const close = vi.fn();
const cache = createGatewayAvatarDataUrlCache({ maxEntries: 2, read, close });
const opened = (
fd: number,
mtimeMs: number,
size: number,
identity = { ctimeMs: 5, dev: 1, ino: 2 },
) => ({
path: "/workspace/avatar.png",
fd,
stat: { ...identity, mtimeMs, size },
});
expect(cache.read(opened(1, 10, 20))).toBe("data:image/png;base64,10:20");
expect(cache.read(opened(2, 10, 20))).toBe("data:image/png;base64,10:20");
expect(read).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledWith(2);
expect(cache.read(opened(3, 11, 20))).toBe("data:image/png;base64,11:20");
expect(cache.read(opened(4, 11, 21))).toBe("data:image/png;base64,11:21");
expect(read).toHaveBeenCalledTimes(3);
expect(cache.read(opened(5, 11, 21, { ctimeMs: 6, dev: 1, ino: 3 }))).toBe(
"data:image/png;base64,11:21",
);
expect(read).toHaveBeenCalledTimes(4);
});
it("inlines the selected local file", () => {
const { cfg, workspace } = createWorkspace();
fs.writeFileSync(path.join(workspace, "avatar.png"), REAL_PNG);
+4 -2
View File
@@ -1,7 +1,6 @@
// Gateway assistant-avatar projection binds the selected value to effective metadata.
import {
openLocalAgentAvatarFile,
readOpenedLocalAgentAvatarDataUrl,
type OpenedLocalAgentAvatarFile,
} from "../agents/identity-avatar-file.js";
import type { AgentAvatarResolution } from "../agents/identity-avatar.js";
@@ -14,6 +13,7 @@ import {
isWindowsAbsolutePath,
looksLikeAvatarPath,
} from "../shared/avatar-policy.js";
import { createGatewayAvatarDataUrlCache } from "./assistant-avatar-cache.js";
import { DEFAULT_ASSISTANT_IDENTITY } from "./assistant-identity.js";
import { CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath } from "./control-ui-shared.js";
@@ -33,6 +33,8 @@ type OpenGatewayAssistantAvatarProjection = {
openedFile?: OpenedLocalAgentAvatarFile;
};
const gatewayAvatarDataUrlCache = createGatewayAvatarDataUrlCache();
function resolveSameOriginAvatarUrl(cfg: OpenClawConfig, source: string): string | undefined {
const basePath = normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath);
const unbasedPrefix = `${CONTROL_UI_AVATAR_PREFIX}/`;
@@ -104,7 +106,7 @@ export function resolveGatewayAssistantAvatar(params: {
return { avatar: source, resolution: opened.resolution };
}
const dataUrl = readOpenedLocalAgentAvatarDataUrl(opened.openedFile);
const dataUrl = gatewayAvatarDataUrlCache.read(opened.openedFile);
if (!dataUrl) {
return {
avatar: identity.emoji ?? DEFAULT_ASSISTANT_IDENTITY.avatar,
+9 -1
View File
@@ -53,6 +53,10 @@ const loadAgentHandlers = lazyHandlerModule(
() => import("./server-methods/agent.js"),
(module) => module.agentHandlers,
);
const loadAgentIdentityHandlers = lazyHandlerModule(
() => import("./server-methods/agent-identity.js"),
(module) => module.agentIdentityHandlers,
);
const loadAgentsHandlers = lazyHandlerModule(
() => import("./server-methods/agents.js"),
(module) => module.agentsHandlers,
@@ -852,9 +856,13 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
loadHandlers: loadUsageHandlers,
}),
...createLazyCoreHandlers({
methods: ["agent", "agent.identity.get", "agent.wait"],
methods: ["agent", "agent.wait"],
loadHandlers: loadAgentHandlers,
}),
...createLazyCoreHandlers({
methods: ["agent.identity.get"],
loadHandlers: loadAgentIdentityHandlers,
}),
...createLazyCoreHandlers({
methods: [
"agents.list",
@@ -65,3 +65,7 @@ export const agentIdentityGetHandler: GatewayRequestHandlers["agent.identity.get
undefined,
);
};
export const agentIdentityHandlers: GatewayRequestHandlers = {
"agent.identity.get": agentIdentityGetHandler,
};
@@ -18,6 +18,7 @@ import {
} from "../../tasks/task-runtime.test-helpers.js";
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
import { createChatRunState } from "../server-chat-state.js";
import { agentIdentityHandlers } from "./agent-identity.js";
import { agentHandlers } from "./agent.js";
import { suspendHandlers } from "./suspend.js";
import type { GatewayRequestContext } from "./types.js";
@@ -322,7 +323,7 @@ export type AgentParams = AgentHandlerArgs["params"];
export type AgentCommandCall = Record<string, unknown>;
type AgentIdentityGetHandler = NonNullable<(typeof agentHandlers)["agent.identity.get"]>;
type AgentIdentityGetHandler = NonNullable<(typeof agentIdentityHandlers)["agent.identity.get"]>;
type AgentIdentityGetHandlerArgs = Parameters<AgentIdentityGetHandler>[0];
@@ -894,8 +895,8 @@ export async function invokeAgentIdentityGet(
) {
const respond = options?.respond ?? vi.fn();
await expectDefined(
agentHandlers["agent.identity.get"],
'agentHandlers["agent.identity.get"] test invariant',
agentIdentityHandlers["agent.identity.get"],
'agentIdentityHandlers["agent.identity.get"] test invariant',
)({
params,
respond: respond as never,
+1 -3
View File
@@ -1,11 +1,9 @@
import { agentIdentityGetHandler } from "./agent-identity.js";
import { agentRunHandler } from "./agent-run-handler.js";
import { agentWaitHandler } from "./agent-wait.js";
// Gateway agent methods implement agent.run, agent.wait, and agent identity RPCs.
// Gateway agent methods implement agent.run and agent.wait RPCs.
import type { GatewayRequestHandlers } from "./types.js";
export const agentHandlers: GatewayRequestHandlers = {
agent: agentRunHandler,
"agent.identity.get": agentIdentityGetHandler,
"agent.wait": agentWaitHandler,
};
+10 -1
View File
@@ -438,6 +438,7 @@ export const cronHandlers: GatewayRequestHandlers = {
sortDir?: "asc" | "desc";
agentId?: string;
compact?: boolean;
includeDeliveryPreviews?: boolean;
};
const callerScope = readCronCallerScope(client);
const requestedAgentId = p.agentId ? normalizeAgentId(p.agentId) : undefined;
@@ -468,12 +469,20 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(true, { ...page, jobs: page.jobs.map(compactCronListJob) }, undefined);
return;
}
const jobs = page.jobs.map(cronJobReadView);
if (p.includeDeliveryPreviews === false) {
// Full job rows are the default because editors need their payloads. Delivery
// previews are independently suppressible so list-only callers avoid per-job I/O
// without weakening the shipped full-response default.
respond(true, { ...page, jobs }, undefined);
return;
}
const deliveryPreviews = await resolveCronDeliveryPreviews({
cfg: context.getRuntimeConfig(),
defaultAgentId: context.cron.getDefaultAgentId(),
jobs: page.jobs,
});
respond(true, { ...page, jobs: page.jobs.map(cronJobReadView), deliveryPreviews }, undefined);
respond(true, { ...page, jobs, deliveryPreviews }, undefined);
},
"cron.status": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateCronStatusParams, "cron.status", respond)) {
@@ -0,0 +1,34 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
const runStackLoaded = vi.hoisted(() => vi.fn());
vi.mock("./agent-run-handler.js", () => {
runStackLoaded();
return { agentRunHandler: vi.fn() };
});
describe("lazy core handler families", () => {
it("loads agent identity without importing the agent run stack", async () => {
const { coreGatewayHandlers } = await import("../server-methods.js");
const respond = vi.fn();
await expectDefined(
coreGatewayHandlers["agent.identity.get"],
"agent.identity.get lazy handler",
)({
req: { type: "req", id: "identity-light-family", method: "agent.identity.get" },
params: { agentId: "main" },
respond,
context: { getRuntimeConfig: () => ({}) } as never,
client: null,
isWebchatConnect: () => false,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ agentId: "main" }),
undefined,
);
expect(runStackLoaded).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,216 @@
// Stale-while-revalidate cache for models.authStatus provider usage enrichment.
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import {
fingerprintAuthProfileCredential,
fingerprintAuthProfileOwnerShape,
fingerprintResolvedProviderAuth,
} from "../../agents/execution-auth-binding.js";
import { resolveUsableCustomProviderApiKey } from "../../agents/model-auth.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js";
import type { ProviderUsageSnapshot, UsageProviderId } from "../../infra/provider-usage.types.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { formatForLog } from "../ws-log.js";
const log = createSubsystemLogger("models-auth-status");
const USAGE_CACHE_TTL_MS = 60_000;
export type ProviderUsageStatus = Pick<
ProviderUsageSnapshot,
"windows" | "summary" | "plan" | "billing" | "accountEmail"
>;
type ProviderUsageCacheEntry = {
agentDir: string;
configRef: object;
credentialKey: string;
providerKey: string;
refreshedAt: number;
usageByProvider: Map<string, ProviderUsageStatus>;
};
type ProviderUsageRefresh = {
agentDir: string;
configRef: object;
credentialKey: string;
providerKey: string;
};
const usageCacheByAgentId = new Map<string, ProviderUsageCacheEntry>();
const usageRefreshByAgentId = new Map<string, ProviderUsageRefresh>();
let cacheGeneration = 0;
function sortedRecordEntries<T>(value: Record<string, T> | undefined) {
return Object.entries(value ?? {}).toSorted(([left], [right]) => left.localeCompare(right));
}
export function fingerprintProviderUsageCredentials(params: {
cfg: OpenClawConfig;
directApiKeys: ReadonlyMap<string, { source: "config" | "env"; envVar?: string } | undefined>;
store: AuthProfileStore;
}): string {
const profiles = Object.entries(params.store.profiles)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([profileId, credential]) => {
const fingerprint =
fingerprintAuthProfileCredential({ profileId, credential }) ??
fingerprintAuthProfileOwnerShape({ profileId, credential });
return fingerprint ?? `${profileId}:${credential.type}:${credential.provider}`;
});
const direct = [...params.directApiKeys]
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([provider, evidence]) => {
const configured = resolveUsableCustomProviderApiKey({
cfg: params.cfg,
provider,
env: process.env,
});
const envValue = evidence?.envVar ? process.env[evidence.envVar]?.trim() : undefined;
const resolved =
configured ??
(envValue ? { apiKey: envValue, source: `env: ${evidence?.envVar}` } : undefined);
const fingerprint = resolved
? fingerprintResolvedProviderAuth({
apiKey: resolved.apiKey,
source: resolved.source,
mode: "api-key",
})
: undefined;
return [provider, fingerprint ?? null];
});
// Profile selection can switch accounts without changing the profile set.
// Include every non-secret selector that resolveAuthProfileOrder consults.
return JSON.stringify({
profiles,
direct,
order: sortedRecordEntries(params.store.order),
lastGood: sortedRecordEntries(params.store.lastGood),
usageStats: sortedRecordEntries(params.store.usageStats),
});
}
export function clearModelAuthStatusUsageCache(): void {
cacheGeneration += 1;
usageCacheByAgentId.clear();
usageRefreshByAgentId.clear();
}
function providerUsageCacheKey(providerIds: readonly UsageProviderId[]): string {
return providerIds.toSorted().join("\0");
}
function mapProviderUsage(usage: Awaited<ReturnType<typeof loadProviderUsageSummary>>) {
const usageByProvider = new Map<string, ProviderUsageStatus>();
for (const snap of usage.providers) {
usageByProvider.set(snap.provider, {
windows: snap.windows,
...(snap.summary ? { summary: snap.summary } : {}),
...(snap.plan ? { plan: snap.plan } : {}),
...(snap.billing?.length ? { billing: snap.billing } : {}),
...(snap.accountEmail ? { accountEmail: snap.accountEmail } : {}),
});
}
return usageByProvider;
}
function scheduleProviderUsageRefresh(params: {
agentId: string;
agentDir: string;
configRef: object;
credentialKey: string;
providerIds: UsageProviderId[];
providerKey: string;
}): void {
const active = usageRefreshByAgentId.get(params.agentId);
if (
active?.agentDir === params.agentDir &&
active.configRef === params.configRef &&
active.credentialKey === params.credentialKey &&
active.providerKey === params.providerKey
) {
return;
}
const publishGeneration = cacheGeneration;
const refresh = {
agentDir: params.agentDir,
configRef: params.configRef,
credentialKey: params.credentialKey,
providerKey: params.providerKey,
};
usageRefreshByAgentId.set(params.agentId, refresh);
void loadProviderUsageSummary({
providers: params.providerIds,
agentDir: params.agentDir,
timeoutMs: 3500,
})
.then((usage) => {
if (
publishGeneration !== cacheGeneration ||
usageRefreshByAgentId.get(params.agentId) !== refresh
) {
return;
}
usageCacheByAgentId.set(params.agentId, {
agentDir: params.agentDir,
configRef: params.configRef,
credentialKey: params.credentialKey,
providerKey: params.providerKey,
refreshedAt: Date.now(),
usageByProvider: mapProviderUsage(usage),
});
})
.catch((err: unknown) => {
// Usage is auxiliary and stale data remains valid. Keep failures visible at
// debug level without delaying or failing the fresh auth-health response.
log.debug(
`usage enrichment failed (auth status still returned): providers=${params.providerIds.join(",")} error=${formatForLog(err)}`,
);
})
.finally(() => {
if (usageRefreshByAgentId.get(params.agentId) === refresh) {
usageRefreshByAgentId.delete(params.agentId);
}
});
}
export function readProviderUsageStaleWhileRevalidate(params: {
agentId: string;
agentDir: string;
configRef: object;
credentialKey: string;
forceRefresh?: boolean;
providerIds: UsageProviderId[];
now: number;
}): Map<string, ProviderUsageStatus> {
if (params.providerIds.length === 0) {
usageCacheByAgentId.delete(params.agentId);
return new Map();
}
const providerIds = params.providerIds.toSorted();
const providerKey = providerUsageCacheKey(providerIds);
const cached = usageCacheByAgentId.get(params.agentId);
const matching =
cached?.agentDir === params.agentDir &&
cached.configRef === params.configRef &&
cached.credentialKey === params.credentialKey &&
cached.providerKey === providerKey
? cached
: undefined;
if (
params.forceRefresh === true ||
!matching ||
params.now - matching.refreshedAt >= USAGE_CACHE_TTL_MS
) {
// Never couple the RPC deadline to provider HTTP. A cold call returns auth
// without usage; stale calls return the last snapshot while one refresh runs.
scheduleProviderUsageRefresh({
agentId: params.agentId,
agentDir: params.agentDir,
configRef: params.configRef,
credentialKey: params.credentialKey,
providerIds,
providerKey,
});
}
return matching?.usageByProvider ?? new Map();
}
@@ -224,6 +224,14 @@ async function firstAuthStatusProvider() {
return (payload as ModelAuthStatusResult).providers[0];
}
async function readAuthStatus(params: Record<string, unknown> = {}) {
const opts = createOptions(params);
await handler(opts);
const [ok, payload, error] = firstRespondCall(opts) ?? [];
expect(ok, JSON.stringify(error)).toBe(true);
return payload as ModelAuthStatusResult;
}
function resetAuthStatusMocks(): void {
vi.clearAllMocks();
invalidateModelAuthStatusCache();
@@ -405,15 +413,15 @@ describe("models.authStatus", () => {
},
);
it("keeps cached auth snapshots isolated by agent", async () => {
it("rebuilds fresh auth snapshots for each requested agent", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } };
mocks.getRuntimeConfig.mockReturnValue(cfg);
mocks.listAgentIds.mockReturnValue(["main", "writer"]);
await handler(createOptions({ agentId: "main" }));
await handler(createOptions({ agentId: "writer" }));
const cachedMain = createOptions({ agentId: "main" });
await handler(cachedMain);
const freshMain = createOptions({ agentId: "main" });
await handler(freshMain);
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
1,
@@ -425,8 +433,13 @@ describe("models.authStatus", () => {
"/tmp/agent-writer",
expect.any(Object),
);
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(2);
expect(firstRespondCall(cachedMain)?.[3]).toEqual({ cached: true });
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
3,
"/tmp/agent",
expect.any(Object),
);
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(3);
expect(firstRespondCall(freshMain)?.[3]).toBeUndefined();
});
it("re-reads runtime config after an explicit auth refresh", async () => {
@@ -768,7 +781,7 @@ describe("models.authStatus", () => {
expect(result.providers[0]?.profiles[0]?.reasonCode).toBe("unresolved_ref");
});
it("serves cached response within TTL and marks it as cached", async () => {
it("keeps auth health fresh while usage caching stays auxiliary", async () => {
const opts1 = createOptions();
await handler(opts1);
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1);
@@ -776,11 +789,8 @@ describe("models.authStatus", () => {
const opts2 = createOptions();
await handler(opts2);
// Auth health should NOT be re-queried on the cached call.
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1);
const lastCall = opts2.respond.mock.calls.at(-1);
expect(requireRecord(lastCall?.[3]).cached).toBe(true);
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
expect(opts2.respond.mock.calls.at(-1)?.[3]).toBeUndefined();
});
it("bypasses cache when params.refresh is set", async () => {
@@ -818,15 +828,16 @@ describe("models.authStatus", () => {
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
});
it("invalidateModelAuthStatusCache() clears the cached response", async () => {
it("invalidateModelAuthStatusCache() preserves fresh auth reads", async () => {
await handler(createOptions());
invalidateModelAuthStatusCache();
await handler(createOptions());
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
});
it("does not cache status captured before a concurrent logout", async () => {
it("does not publish usage captured before a concurrent logout", async () => {
let releaseUsage: (() => void) | undefined;
let usageFinished = false;
const usageBlocked = new Promise<void>((resolve) => {
releaseUsage = resolve;
});
@@ -846,16 +857,28 @@ describe("models.authStatus", () => {
});
mocks.loadProviderUsageSummary.mockImplementationOnce(async () => {
await usageBlocked;
return emptyUsageSummary();
usageFinished = true;
return {
updatedAt: 0,
providers: [
{
provider: "openrouter",
displayName: "OpenRouter",
windows: [{ label: "day", usedPercent: 99 }],
},
],
};
});
const inFlightStatus = handler(createOptions());
const first = await readAuthStatus();
expect(first.providers[0]?.usage).toBeUndefined();
await waitForFast(() => expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce());
await logoutHandler(createLogoutOptions({ provider: "openrouter" }));
releaseUsage?.();
await inFlightStatus;
await waitForFast(() => expect(usageFinished).toBe(true));
await handler(createOptions());
const afterLogout = await readAuthStatus();
expect(afterLogout.providers[0]?.usage).toBeUndefined();
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
});
@@ -900,18 +923,22 @@ describe("models.authStatus", () => {
],
});
const opts = createOptions();
await handler(opts);
const first = await readAuthStatus();
expect(first.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({
providers: ["anthropic"],
agentDir: "/tmp/agent",
timeoutMs: 3500,
});
const [, payload] = firstRespondCall(opts) ?? [];
const result = payload as ModelAuthStatusResult;
expect(result.providers[0]?.displayName).toBe("Claude");
expect(result.providers[0]?.usage).toEqual({
let result: ModelAuthStatusResult | undefined;
await waitForFast(async () => {
result = await readAuthStatus();
expect(result.providers[0]?.usage).toBeDefined();
});
const refreshed = expectDefined(result, "refreshed auth status");
expect(refreshed.providers[0]?.displayName).toBe("Claude");
expect(refreshed.providers[0]?.usage).toEqual({
providerId: "anthropic",
windows: [{ label: "5h", usedPercent: 22 }],
plan: "Max (20x)",
@@ -939,23 +966,287 @@ describe("models.authStatus", () => {
],
});
const opts = createOptions();
await handler(opts);
const first = await readAuthStatus();
expect(first.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({
providers: ["deepseek"],
agentDir: "/tmp/agent",
timeoutMs: 3500,
});
const [, payload] = firstRespondCall(opts) ?? [];
const result = payload as ModelAuthStatusResult;
expect(result.providers[0]?.usage).toEqual({
let result: ModelAuthStatusResult | undefined;
await waitForFast(async () => {
result = await readAuthStatus();
expect(result.providers[0]?.usage).toBeDefined();
});
const refreshed = expectDefined(result, "refreshed auth status");
expect(refreshed.providers[0]?.usage).toEqual({
providerId: "deepseek",
windows: [],
summary: "Balance ¥42.50",
});
});
it("serves stale usage immediately while one background refresh replaces it", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
const profile = {
profileId: "openai:default",
provider: "openai",
type: "oauth",
status: "ok",
source: "store",
label: "openai:default",
} satisfies AuthHealthSummary["profiles"][number];
mocks.buildAuthHealthSummary.mockReturnValue({
now: 0,
warnAfterMs: 0,
profiles: [profile],
providers: [{ provider: "openai", status: "ok", profiles: [profile] }],
});
mocks.loadProviderUsageSummary.mockResolvedValueOnce({
updatedAt: 1_000,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 10 }],
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
let releaseRefresh: (() => void) | undefined;
const refreshBlocked = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
now.mockReturnValue(61_000);
mocks.loadProviderUsageSummary.mockImplementationOnce(async () => {
await refreshBlocked;
return {
updatedAt: 61_000,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 20 }],
},
],
};
});
const stale = await readAuthStatus();
expect(stale.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2);
releaseRefresh?.();
await waitForFast(async () => {
const refreshed = await readAuthStatus();
expect(refreshed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(20);
});
now.mockRestore();
});
it("keeps same-account stale usage visible during an explicit refresh", async () => {
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
mocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 10 }],
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
let releaseRefresh: (() => void) | undefined;
const refreshBlocked = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
mocks.loadProviderUsageSummary.mockImplementationOnce(async () => {
await refreshBlocked;
return emptyUsageSummary();
});
const refreshing = await readAuthStatus({ refresh: true });
expect(refreshing.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2);
releaseRefresh?.();
});
it("does not reuse usage after the same agent moves to another agent directory", async () => {
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
mocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 10 }],
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
mocks.resolveAgentDir.mockReturnValue("/tmp/rebound-agent");
const rebound = await readAuthStatus();
expect(rebound.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenLastCalledWith({
providers: ["openai"],
agentDir: "/tmp/rebound-agent",
timeoutMs: 3500,
});
});
it("does not reuse usage after credentials rotate within the same provider", async () => {
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "first-access",
refresh: "first-refresh",
expires: 1_000_000,
},
},
});
mocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 10 }],
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "second-access",
refresh: "second-refresh",
expires: 1_000_000,
},
},
});
const rotated = await readAuthStatus();
expect(rotated.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2);
});
it("does not reuse usage after a direct provider key rotates", async () => {
const cfg = {
models: { providers: { deepseek: { apiKey: "first-direct-value" } } },
};
mocks.getRuntimeConfig.mockReturnValue(cfg);
mocks.buildAuthHealthSummary.mockReturnValue({
now: 0,
warnAfterMs: 0,
profiles: [createApiKeyProfile("deepseek")],
providers: [createStaticApiKeyProvider("deepseek")],
});
mocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "deepseek",
displayName: "DeepSeek",
windows: [],
summary: "Balance 10",
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.summary).toBe("Balance 10");
});
cfg.models.providers.deepseek.apiKey = "second-direct-value";
const rotated = await readAuthStatus();
expect(rotated.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2);
});
it("does not reuse usage after profile selection state switches accounts", async () => {
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
const profiles = {
"openai:first": {
type: "oauth" as const,
provider: "openai",
access: "first-access",
refresh: "first-refresh",
expires: 1_000_000,
},
"openai:second": {
type: "oauth" as const,
provider: "openai",
access: "second-access",
refresh: "second-refresh",
expires: 1_000_000,
},
};
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles,
lastGood: { openai: "openai:first" },
});
mocks.loadProviderUsageSummary.mockResolvedValue({
updatedAt: 0,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [{ label: "5h", usedPercent: 10 }],
},
],
});
await readAuthStatus();
await waitForFast(async () => {
const warmed = await readAuthStatus();
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles,
lastGood: { openai: "openai:second" },
});
const switched = await readAuthStatus();
expect(switched.providers[0]?.usage).toBeUndefined();
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2);
});
it("scopes external CLI auth overlays to configured providers", async () => {
mocks.getRuntimeConfig.mockReturnValue({
auth: {
+46 -127
View File
@@ -24,7 +24,6 @@ import {
removeProviderAuthProfilesWithLock,
resolvePersistedAuthProfileOwnerAgentDir,
} from "../../agents/auth-profiles.js";
import type { AuthCredentialReasonCode } from "../../agents/auth-profiles/credential-state.js";
import {
listProviderEnvAuthLookupKeys,
resolveProviderEnvAuthLookupMaps,
@@ -46,14 +45,8 @@ import {
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import type { OpenClawConfig } from "../../config/config.js";
import { coerceSecretRef, hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js";
import { providerUsageLabel, resolveUsageProviderId } from "../../infra/provider-usage.shared.js";
import type {
ProviderUsageBilling,
ProviderUsageSnapshot,
UsageProviderId,
UsageWindow,
} from "../../infra/provider-usage.types.js";
import type { UsageProviderId } from "../../infra/provider-usage.types.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js";
import { asDateTimestampMs } from "../../shared/number-coercion.js";
@@ -63,93 +56,38 @@ import {
resolveModelAuthAgentScope,
unknownModelAuthAgentIdError,
} from "./model-auth-agent-scope.js";
import {
clearModelAuthStatusUsageCache,
fingerprintProviderUsageCredentials,
type ProviderUsageStatus,
readProviderUsageStaleWhileRevalidate,
} from "./models-auth-status-usage-cache.js";
import type {
ModelAuthExpiry,
ModelAuthLogoutResult,
ModelAuthStatusProvider,
ModelAuthStatusResult,
} from "./models-auth-status.types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
export type {
ModelAuthExpiry,
ModelAuthLogoutResult,
ModelAuthStatusProfile,
ModelAuthStatusProvider,
ModelAuthStatusResult,
} from "./models-auth-status.types.js";
const log = createSubsystemLogger("models-auth-status");
const apiKeyUsageStatusProviders = new Set<UsageProviderId>(["clawrouter", "deepseek"]);
type ProviderUsageStatus = Pick<
ProviderUsageSnapshot,
"windows" | "summary" | "plan" | "billing" | "accountEmail"
>;
/**
* Models-auth status wire types. Mirrored in ui/src/ui/types.ts via an
* `import(...)` re-export — edit here and the UI picks up the change.
*
* Expiry fields are grouped into a sub-object so they're present together or
* not at all: a profile either has a time-bounded credential or it doesn't.
*/
export type ModelAuthExpiry = {
/** Absolute expiry timestamp, ms since epoch. */
at: number;
/** Remaining time in ms (negative if already expired). */
remainingMs: number;
/** Human-readable remaining time (e.g. "10d", "2h", "45m"). */
label: string;
};
export type ModelAuthStatusProfile = {
profileId: string;
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
reasonCode?: AuthCredentialReasonCode;
expiry?: ModelAuthExpiry;
/** True only for saved OAuth/token profiles this gateway can remove. */
logoutSupported?: boolean;
};
export type ModelAuthStatusProvider = {
provider: string;
displayName: string;
status: AuthProviderHealthStatus;
expiry?: ModelAuthExpiry;
profiles: ModelAuthStatusProfile[];
apiKey?: {
source: "config" | "env";
envVar?: string;
};
usage?: {
/**
* Normalized usage provider id this payload was fetched under (e.g.
* "anthropic" for a claude-cli auth row). Session rows report canonical
* model providers, so consumers must match against both ids.
*/
providerId: UsageProviderId;
windows: UsageWindow[];
summary?: string;
plan?: string;
billing?: ProviderUsageBilling[];
/** Account email the usage was fetched under, when known. */
accountEmail?: string;
};
};
export type ModelAuthStatusResult = {
/** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */
ts: number;
providers: ModelAuthStatusProvider[];
};
export type ModelAuthLogoutResult = {
provider: string;
removedProfiles: string[];
abortedRunIds: string[];
};
const CACHE_TTL_MS = 60_000;
const cachedByAgentId = new Map<string, { ts: number; result: ModelAuthStatusResult }>();
let cacheGeneration = 0;
/**
* Invalidate the in-memory cache. Reserved for future gateway-side auth
* mutation handlers (login, logout, token rotation) so the next read returns
* fresh data. Today those mutations happen via the CLI and the 60s TTL plus
* `{refresh: true}` param cover the stale-data window.
* Invalidate auxiliary usage and prepared provider-auth state after an auth
* mutation. Auth health itself is rebuilt on every request; only outbound
* usage enrichment is cached.
*/
export function invalidateModelAuthStatusCache(): void {
cacheGeneration += 1;
cachedByAgentId.clear();
clearModelAuthStatusUsageCache();
// The prepared provider-auth map (model-provider-auth.ts) was built from
// the pre-mutation auth state, so it must be invalidated alongside this
// cache whenever an auth-profile mutation lands (logout, login, token
@@ -159,7 +97,10 @@ export function invalidateModelAuthStatusCache(): void {
}
async function refreshModelAuthStatusRuntimeState(): Promise<void> {
invalidateModelAuthStatusCache();
// Keep same-credential usage visible while the explicit refresh replaces it.
// A changed credential/config produces a different cache key below; logout
// still uses invalidateModelAuthStatusCache() and clears usage immediately.
clearCurrentProviderAuthState();
try {
if (await refreshActiveProviderAuthRuntimeSnapshot()) {
return;
@@ -589,8 +530,8 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
);
return;
}
// Fence status work that may have captured the removed profiles before
// it awaits auxiliary usage. It must not repopulate the cache afterward.
// Fence auxiliary usage work that captured the removed profiles before
// logout. Its later completion must not repopulate the cache.
invalidateModelAuthStatusCache();
await refreshActiveProviderAuthRuntimeSnapshot();
void warmCurrentProviderAuthStateOffMainThread(context.getRuntimeConfig()).catch(
@@ -623,7 +564,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
},
"models.authStatus": async ({ params, respond, context }) => {
const now = Date.now();
const bypassCache = Boolean(params.refresh);
const refreshRequested = Boolean(params.refresh);
try {
let cfg = context.getRuntimeConfig();
let scope = resolveModelAuthAgentScope(cfg, params.agentId);
@@ -631,7 +572,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId));
return;
}
if (bypassCache) {
if (refreshRequested) {
await refreshModelAuthStatusRuntimeState();
cfg = context.getRuntimeConfig();
scope = resolveModelAuthAgentScope(cfg, params.agentId);
@@ -640,13 +581,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
return;
}
}
const publishGeneration = cacheGeneration;
const { agentId, agentDir } = scope;
const cached = cachedByAgentId.get(agentId);
if (!bypassCache && cached && now - cached.ts < CACHE_TTL_MS) {
respond(true, cached.result, undefined, { cached: true });
return;
}
// Use the external-profile-aware store for status reads so the dashboard
// reflects CLI-discovered credentials without persisting them here.
const store = ensureAuthProfileStore(agentDir, {
@@ -690,32 +625,19 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
),
];
const usageByProvider = new Map<string, ProviderUsageStatus>();
if (usageProviderIds.length > 0) {
try {
const usage = await loadProviderUsageSummary({
providers: usageProviderIds,
agentDir,
timeoutMs: 3500,
});
for (const snap of usage.providers) {
usageByProvider.set(snap.provider, {
windows: snap.windows,
...(snap.summary ? { summary: snap.summary } : {}),
...(snap.plan ? { plan: snap.plan } : {}),
...(snap.billing?.length ? { billing: snap.billing } : {}),
...(snap.accountEmail ? { accountEmail: snap.accountEmail } : {}),
});
}
} catch (err) {
// Usage data is auxiliary — failing here must not block auth status,
// but log at debug so a silently-broken usage endpoint is still
// diagnosable in gateway logs.
log.debug(
`usage enrichment failed (auth status still returned): providers=${usageProviderIds.join(",")} error=${formatForLog(err)}`,
);
}
}
const usageByProvider = readProviderUsageStaleWhileRevalidate({
agentId,
agentDir,
configRef: cfg,
credentialKey: fingerprintProviderUsageCredentials({
cfg,
directApiKeys: apiKeys,
store,
}),
forceRefresh: refreshRequested,
providerIds: usageProviderIds,
now,
});
const externalProfileIds = new Set(store.runtimeExternalProfileIds ?? []);
const logoutProfileIds = new Set(
@@ -739,9 +661,6 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
),
);
const result: ModelAuthStatusResult = { ts: now, providers };
if (publishGeneration === cacheGeneration) {
cachedByAgentId.set(agentId, { ts: now, result });
}
respond(true, result, undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
@@ -0,0 +1,60 @@
import type {
AuthProfileHealthStatus,
AuthProviderHealthStatus,
} from "../../agents/auth-health.js";
import type { AuthCredentialReasonCode } from "../../agents/auth-profiles/credential-state.js";
import type {
ProviderUsageBilling,
UsageProviderId,
UsageWindow,
} from "../../infra/provider-usage.types.js";
/** Time-bounded credential expiry projected to gateway clients. */
export type ModelAuthExpiry = {
at: number;
remainingMs: number;
label: string;
};
export type ModelAuthStatusProfile = {
profileId: string;
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
reasonCode?: AuthCredentialReasonCode;
expiry?: ModelAuthExpiry;
/** True only for saved OAuth/token profiles this gateway can remove. */
logoutSupported?: boolean;
};
export type ModelAuthStatusProvider = {
provider: string;
displayName: string;
status: AuthProviderHealthStatus;
expiry?: ModelAuthExpiry;
profiles: ModelAuthStatusProfile[];
apiKey?: {
source: "config" | "env";
envVar?: string;
};
usage?: {
/** Normalized provider id the usage payload was fetched under. */
providerId: UsageProviderId;
windows: UsageWindow[];
summary?: string;
plan?: string;
billing?: ProviderUsageBilling[];
accountEmail?: string;
};
};
export type ModelAuthStatusResult = {
/** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */
ts: number;
providers: ModelAuthStatusProvider[];
};
export type ModelAuthLogoutResult = {
provider: string;
removedProfiles: string[];
abortedRunIds: string[];
};
+2 -1
View File
@@ -14,7 +14,6 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { canonicalizeMainSessionAlias } from "../../config/sessions.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { getTaskById, listTaskRecordsUnsorted } from "../../tasks/runtime-internal.js";
import { cancelDetachedTaskRunById } from "../../tasks/task-executor.js";
import type { TaskRecord, TaskStatus } from "../../tasks/task-registry.types.js";
import { mapTaskSummary, taskUpdatedAt } from "./task-summary.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -165,6 +164,8 @@ export const tasksHandlers: GatewayRequestHandlers = {
}
const taskId = params.taskId;
const reason = normalizeOptionalString(params.reason);
const { cancelDetachedTaskRunById } =
await import("../../tasks/task-executor-cancel.runtime.js");
const result = await cancelDetachedTaskRunById({
cfg: context.getRuntimeConfig(),
taskId,
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js";
import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js";
afterEach(() => {
vi.useRealTimers();
resetGatewayWorkAdmission();
});
describe("scheduleGatewayHandlerPrewarm", () => {
it("loads every scheduled family in order only after the post-ready timer yields", async () => {
vi.useFakeTimers();
const familyNames = ["sessions", "chat", "tasks", "cron"];
const loaded: string[] = [];
const families = familyNames.map((name) => ({
name,
load: vi.fn(async () => {
loaded.push(name);
}),
}));
const sidecar = scheduleGatewayHandlerPrewarm({
families,
log: { warn: vi.fn() },
});
expect(loaded).toEqual([]);
await vi.runAllTimersAsync();
expect(loaded).toEqual(familyNames);
expect(families.every((family) => vi.mocked(family.load).mock.calls.length === 1)).toBe(true);
sidecar.stop();
});
it("stops before importing a scheduled family", async () => {
vi.useFakeTimers();
const load = vi.fn(async () => {});
const sidecar = scheduleGatewayHandlerPrewarm({
families: [{ name: "sessions", load }],
log: { warn: vi.fn() },
});
sidecar.stop();
await vi.runAllTimersAsync();
expect(load).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
type StartupTrace = {
measure: <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
};
type GatewayHandlerPrewarmFamily = {
name: string;
load: () => Promise<unknown>;
};
type GatewayHandlerPrewarmHandle = {
stop: () => void;
};
// These are the families requested by the Control UI's first dashboard turn.
// Keep the list explicit so adding a cold import is a conscious startup tradeoff.
const DASHBOARD_HANDLER_FAMILIES: readonly GatewayHandlerPrewarmFamily[] = [
{ name: "sessions", load: () => import("./server-methods/sessions.js") },
{ name: "chat", load: () => import("./server-methods/chat.js") },
{ name: "tasks", load: () => import("./server-methods/tasks.js") },
{ name: "cron", load: () => import("./server-methods/cron.js") },
{ name: "models-auth-status", load: () => import("./server-methods/models-auth-status.js") },
{ name: "agent-identity", load: () => import("./server-methods/agent-identity.js") },
{ name: "board", load: () => import("./server-methods/board.js") },
{ name: "channels", load: () => import("./server-methods/channels.js") },
];
export function scheduleGatewayHandlerPrewarm(params: {
startupTrace?: StartupTrace;
log: { warn: (msg: string) => void };
families?: readonly GatewayHandlerPrewarmFamily[];
}): GatewayHandlerPrewarmHandle {
const families = params.families ?? DASHBOARD_HANDLER_FAMILIES;
let stopped = false;
let nextIndex = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleNext = () => {
if (stopped || nextIndex >= families.length) {
return;
}
timer = setTimeout(() => {
timer = undefined;
if (stopped) {
return;
}
const family = families[nextIndex++];
if (!family) {
return;
}
const load = () => family.load();
void runWithGatewayIndependentRootWorkAdmission(() =>
params.startupTrace
? params.startupTrace.measure(`post-ready.gateway-handler.${family.name}`, load)
: load(),
)
.catch((err: unknown) => {
params.log.warn(
`post-ready gateway handler prewarm failed for ${family.name}: ${String(err)}`,
);
})
.finally(scheduleNext);
}, 0);
timer.unref?.();
};
// One family per event-loop turn keeps this work behind readiness and lets
// immediate client traffic run between imports instead of recreating a startup wall.
scheduleNext();
return {
stop: () => {
stopped = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
};
}
+11 -5
View File
@@ -65,6 +65,7 @@ const hoisted = vi.hoisted(() => {
const refreshPreparedModelRuntimeSnapshots = vi.fn(async (_cfg?: unknown) => {});
const ensureRuntimePluginsLoaded = vi.fn();
const ensureContextWindowCacheLoaded = vi.fn(async () => {});
const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() }));
const clearCurrentProviderAuthState = vi.fn();
const warmCurrentProviderAuthStateOffMainThread = vi.fn(
async (_cfg?: unknown, _options?: unknown) => {},
@@ -105,6 +106,7 @@ const hoisted = vi.hoisted(() => {
refreshPreparedModelRuntimeSnapshots,
ensureRuntimePluginsLoaded,
ensureContextWindowCacheLoaded,
scheduleGatewayHandlerPrewarm,
clearCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
setAuthProfileFailureHook,
@@ -222,6 +224,10 @@ vi.mock("../agents/model-provider-auth.js", () => ({
warmCurrentProviderAuthStateOffMainThread: hoisted.warmCurrentProviderAuthStateOffMainThread,
}));
vi.mock("./server-startup-handler-prewarm.js", () => ({
scheduleGatewayHandlerPrewarm: hoisted.scheduleGatewayHandlerPrewarm,
}));
vi.mock("../agents/model-provider-auth-state.js", () => ({
clearCurrentProviderAuthState: hoisted.clearCurrentProviderAuthState,
}));
@@ -1076,7 +1082,7 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.advanceTimersToNextTimerAsync();
expect(postReadyRequestTurn).toHaveBeenCalledTimes(1);
expect(onPostReadySidecars.mock.calls[0]?.[0]).toHaveLength(0);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4);
await vi.dynamicImportSettled();
await waitForGatewayTestState(() => {
expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1);
@@ -1108,7 +1114,7 @@ describe("startGatewayPostAttachRuntime", () => {
await waitForGatewayTestState(() => {
expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1);
});
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4);
await vi.advanceTimersByTimeAsync(10_000);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
@@ -1232,7 +1238,7 @@ describe("startGatewayPostAttachRuntime", () => {
| { stop: () => void }[]
| undefined;
expect(gmailSidecars).toHaveLength(1);
expect(lifetimeSidecars).toHaveLength(3);
expect(lifetimeSidecars).toHaveLength(4);
for (const sidecar of gmailSidecars ?? []) {
sidecar.stop();
@@ -1294,7 +1300,7 @@ describe("startGatewayPostAttachRuntime", () => {
| Array<{ stop: () => Promise<void> | void }>
| undefined;
expect(gmailSidecars).toHaveLength(1);
expect(lifetimeSidecars).toHaveLength(3);
expect(lifetimeSidecars).toHaveLength(4);
await waitForGatewayTestState(() => {
expect(hoisted.transcriptsAutoStartService.start).toHaveBeenCalledTimes(1);
@@ -1789,7 +1795,7 @@ describe("startGatewayPostAttachRuntime", () => {
name: "sidecars.ready",
metrics: [
["loadedPluginCount", 2],
["postReadySidecarCount", 2],
["postReadySidecarCount", 3],
],
});
});
+5 -1
View File
@@ -28,6 +28,7 @@ import type { GatewayRecoveryRuntime } from "./server-instance-runtime.types.js"
import type { refreshLatestUpdateRestartSentinel } from "./server-restart-sentinel.js";
import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js";
import { scheduleContextCachePrewarm } from "./server-startup-context-cache-prewarm.js";
import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js";
import type { logGatewayStartup } from "./server-startup-log.js";
import {
createGatewayStartupOutcomeRecorder,
@@ -1230,7 +1231,10 @@ export async function startGatewayPostAttachRuntime(
reportPluginServices(result.pluginServices);
}
const postReadySidecars = [...result.postReadySidecars];
const gatewayLifetimeSidecars = [scheduleContextCachePrewarm(params)];
const gatewayLifetimeSidecars = [
scheduleContextCachePrewarm(params),
scheduleGatewayHandlerPrewarm(params),
];
if (workerEnvironmentSidecar) {
gatewayLifetimeSidecars.push(workerEnvironmentSidecar);
}
+9
View File
@@ -568,6 +568,15 @@ describe("gateway server cron", () => {
detail: "webhook",
});
const noPreviewListRes = await directCronReq(cronState, "cron.list", {
includeDeliveryPreviews: false,
includeDisabled: true,
});
expect(noPreviewListRes.ok).toBe(true);
expect(
(noPreviewListRes.payload as { deliveryPreviews?: unknown } | null)?.deliveryPreviews,
).toBeUndefined();
const compactListRes = await directCronReq(cronState, "cron.list", {
compact: true,
includeDisabled: true,
+44
View File
@@ -0,0 +1,44 @@
// Lazy runtime boundary for task cancellation and its runtime-specific control stack.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { getRegisteredDetachedTaskLifecycleRuntime } from "./detached-task-runtime-state.js";
import {
assertTaskCancellationReadyById,
cancelTaskById,
getTaskById,
} from "./runtime-internal.js";
export async function cancelDetachedTaskRunById(params: {
cfg: OpenClawConfig;
taskId: string;
reason?: string;
}) {
const task = getTaskById(params.taskId);
const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
if (!task) {
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
return cancelled;
}
}
return cancelTaskById(params);
}
try {
assertTaskCancellationReadyById(task.taskId);
} catch (error) {
return {
found: true,
cancelled: false,
reason: formatErrorMessage(error),
task,
};
}
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
return cancelled;
}
}
return cancelTaskById(params);
}
+2 -32
View File
@@ -1,6 +1,5 @@
// Executes task records through configured runtimes and updates registry state.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type {
DetachedRunningTaskCreateParams,
@@ -9,10 +8,7 @@ import type {
DetachedTaskFailParams,
DetachedTaskFinalizeParams,
} from "./detached-task-runtime-contract.js";
import { getRegisteredDetachedTaskLifecycleRuntime } from "./detached-task-runtime-state.js";
import {
assertTaskCancellationReadyById,
cancelTaskById,
createTaskRecord,
findTaskByRunId as findTaskByRunIdInRegistry,
getTaskById,
@@ -598,32 +594,6 @@ export async function cancelDetachedTaskRunById(params: {
taskId: string;
reason?: string;
}) {
const task = getTaskById(params.taskId);
const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
if (!task) {
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
return cancelled;
}
}
return cancelTaskById(params);
}
try {
assertTaskCancellationReadyById(task.taskId);
} catch (error) {
return {
found: true,
cancelled: false,
reason: formatErrorMessage(error),
task,
};
}
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
return cancelled;
}
}
return cancelTaskById(params);
const runtime = await import("./task-executor-cancel.runtime.js");
return runtime.cancelDetachedTaskRunById(params);
}
+2
View File
@@ -1903,6 +1903,7 @@ describe("cron controller", () => {
offset: 0,
query: "daily",
enabled: "enabled",
includeDeliveryPreviews: false,
scheduleKind: "cron",
lastRunStatus: "error",
sortBy: "updatedAtMs",
@@ -2622,6 +2623,7 @@ describe("loadCronFailingCount", () => {
expect(request).toHaveBeenCalledWith("cron.list", {
enabled: "enabled",
includeDeliveryPreviews: false,
lastRunStatus: "error",
limit: 1,
offset: 0,
+1
View File
@@ -563,6 +563,7 @@ export async function loadCronJobsPage(
const res = await state.client.request<CronJobsListResult>("cron.list", {
...(state.cronAgentId ? { agentId: state.cronAgentId } : {}),
includeDisabled: state.cronJobsEnabledFilter === "all",
includeDeliveryPreviews: false,
limit: state.cronJobsLimit,
offset,
query: state.cronJobsQuery.trim() || undefined,
+3
View File
@@ -20,6 +20,7 @@ export async function loadCronFailingCount(state: CronScopeState) {
const res = await state.client.request<CronJobsListResult>("cron.list", {
...(state.cronAgentId ? { agentId: state.cronAgentId } : {}),
enabled: "enabled",
includeDeliveryPreviews: false,
lastRunStatus: "error",
limit: 1,
offset: 0,
@@ -41,12 +42,14 @@ export async function loadCronScopeStats(state: CronScopeState) {
state.client.request<CronJobsListResult>("cron.list", {
agentId: state.cronAgentId,
includeDisabled: true,
includeDeliveryPreviews: false,
limit: 1,
offset: 0,
}),
state.client.request<CronJobsListResult>("cron.list", {
agentId: state.cronAgentId,
enabled: "enabled",
includeDeliveryPreviews: false,
limit: 1,
offset: 0,
sortBy: "nextRunAtMs",