mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(status): keep default JSON scan lean
Default `openclaw status --json` stays on the lean health-probe path while preserving the JSON task summary, local update/install metadata, explicit probe timeouts, and configured gateway handshake timeouts. Deeper memory, registry, remote git, and local status-RPC diagnostics remain behind `status --json --all`. Also keeps generated diffs viewer output in its built form and ignores it in oxfmt so `pnpm build` leaves a clean tree. Proof: - `node scripts/run-vitest.mjs src/commands/status.scan.fast-json.test.ts src/commands/status-json-payload.test.ts src/commands/status.scan.shared.test.ts` - `OPENCLAW_LOCAL_CHECK=0 node scripts/run-oxlint-shards.mjs --threads=8` - `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo` - `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo` - `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` - GitHub checks green for head `47a63f87ea7c2351994fdb71e8cc18041aa0b64e` Thanks @andyylin. Co-authored-by: Andy <andyylin@users.noreply.github.com>
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
"docker-compose.yml",
|
"docker-compose.yml",
|
||||||
"dist/",
|
"dist/",
|
||||||
"docs/_layouts/",
|
"docs/_layouts/",
|
||||||
|
"extensions/diffs/assets/viewer-runtime.js",
|
||||||
"**/*.json",
|
"**/*.json",
|
||||||
"node_modules/",
|
"node_modules/",
|
||||||
"patches/",
|
"patches/",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -237,8 +237,8 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
|||||||
deliver: (payload: unknown, info: { kind: "block" | "final" }) => Promise<void> | void;
|
deliver: (payload: unknown, info: { kind: "block" | "final" }) => Promise<void> | void;
|
||||||
onError?: (err: unknown, info: { kind: "block" | "final" }) => void;
|
onError?: (err: unknown, info: { kind: "block" | "final" }) => void;
|
||||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||||
onSettled?: () => Promise<unknown> | unknown;
|
onSettled?: () => unknown;
|
||||||
onFreshSettledDelivery?: () => Promise<unknown> | unknown;
|
onFreshSettledDelivery?: () => unknown;
|
||||||
};
|
};
|
||||||
ctx?: unknown;
|
ctx?: unknown;
|
||||||
replyOptions?: DispatchInboundParams["replyOptions"];
|
replyOptions?: DispatchInboundParams["replyOptions"];
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ describe("sendMessage", () => {
|
|||||||
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", "42abc"));
|
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", "42abc"));
|
||||||
|
|
||||||
const request = vi.mocked(https.request).mock.results[0]?.value as ClientRequest | undefined;
|
const request = vi.mocked(https.request).mock.results[0]?.value as ClientRequest | undefined;
|
||||||
const body = vi.mocked(request?.write).mock.calls[0]?.[0];
|
if (!request) {
|
||||||
|
throw new Error("expected Synology Chat webhook request");
|
||||||
|
}
|
||||||
|
const body = vi.mocked(request.write).mock.calls[0]?.[0];
|
||||||
if (typeof body !== "string") {
|
if (typeof body !== "string") {
|
||||||
throw new Error("expected Synology Chat webhook body");
|
throw new Error("expected Synology Chat webhook body");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,7 +373,9 @@ describe("TwilioProvider", () => {
|
|||||||
|
|
||||||
const event = provider.parseWebhookEvent(ctx).events[0];
|
const event = provider.parseWebhookEvent(ctx).events[0];
|
||||||
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
|
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
|
||||||
expect(parsed.type).toBe("call.speech");
|
if (parsed.type !== "call.speech") {
|
||||||
|
throw new Error("expected speech event from Twilio webhook");
|
||||||
|
}
|
||||||
expect(parsed.confidence).toBe(0.9);
|
expect(parsed.confidence).toBe(0.9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -409,8 +409,8 @@ function buildDynamicModel(
|
|||||||
: lower === "gpt-5.4-mini"
|
: lower === "gpt-5.4-mini"
|
||||||
? ["gpt-5.4-mini"]
|
? ["gpt-5.4-mini"]
|
||||||
: lower === "gpt-5.4-nano"
|
: lower === "gpt-5.4-nano"
|
||||||
? ["gpt-5.4-nano", "gpt-5.4-mini"]
|
? ["gpt-5.4-nano", "gpt-5.4-mini"]
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!templateIds) {
|
if (!templateIds) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
import { loadStatusScanCommandConfig } from "./status.scan.config-shared.js";
|
import { loadStatusScanCommandConfig } from "./status.scan.config-shared.js";
|
||||||
import type { GatewayProbeSnapshot } from "./status.scan.shared.js";
|
import type { GatewayProbeSnapshot } from "./status.scan.shared.js";
|
||||||
|
|
||||||
|
type StatusGatewayProbeTimeoutResolver = (cfg: OpenClawConfig) => number | undefined;
|
||||||
|
|
||||||
const statusScanDepsRuntimeModuleLoader = createLazyImportLoader(
|
const statusScanDepsRuntimeModuleLoader = createLazyImportLoader(
|
||||||
() => import("./status.scan.deps.runtime.js"),
|
() => import("./status.scan.deps.runtime.js"),
|
||||||
);
|
);
|
||||||
@@ -144,6 +146,8 @@ export async function collectStatusScanOverview(params: {
|
|||||||
) => boolean | Promise<boolean>;
|
) => boolean | Promise<boolean>;
|
||||||
includeChannelsData?: boolean;
|
includeChannelsData?: boolean;
|
||||||
includeLiveChannelStatus?: boolean;
|
includeLiveChannelStatus?: boolean;
|
||||||
|
includeLocalStatusRpcFallback?: boolean;
|
||||||
|
gatewayProbeTimeoutMs?: number | StatusGatewayProbeTimeoutResolver;
|
||||||
includeChannelSetupRuntimeFallback?: boolean;
|
includeChannelSetupRuntimeFallback?: boolean;
|
||||||
channelCredentialResolutionSkipped?: boolean;
|
channelCredentialResolutionSkipped?: boolean;
|
||||||
useGatewayCallOverridesForChannelsStatus?: boolean;
|
useGatewayCallOverridesForChannelsStatus?: boolean;
|
||||||
@@ -203,6 +207,10 @@ export async function collectStatusScanOverview(params: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const osSummary = resolveOsSummary();
|
const osSummary = resolveOsSummary();
|
||||||
|
const gatewayProbeTimeoutMs =
|
||||||
|
typeof params.gatewayProbeTimeoutMs === "function"
|
||||||
|
? params.gatewayProbeTimeoutMs(cfg)
|
||||||
|
: params.gatewayProbeTimeoutMs;
|
||||||
const bootstrap = await createStatusScanCoreBootstrap<
|
const bootstrap = await createStatusScanCoreBootstrap<
|
||||||
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>
|
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>
|
||||||
>({
|
>({
|
||||||
@@ -213,6 +221,8 @@ export async function collectStatusScanOverview(params: {
|
|||||||
skipUpdateCheck: params.skipUpdateCheck,
|
skipUpdateCheck: params.skipUpdateCheck,
|
||||||
fetchGitUpdate: params.fetchGitUpdate,
|
fetchGitUpdate: params.fetchGitUpdate,
|
||||||
includeRegistryUpdate: params.includeRegistryUpdate,
|
includeRegistryUpdate: params.includeRegistryUpdate,
|
||||||
|
includeLocalStatusRpcFallback: params.includeLocalStatusRpcFallback,
|
||||||
|
gatewayProbeTimeoutMs,
|
||||||
getTailnetHostname: async (runner) =>
|
getTailnetHostname: async (runner) =>
|
||||||
await loadStatusScanDepsRuntimeModule().then(({ getTailnetHostname }) =>
|
await loadStatusScanDepsRuntimeModule().then(({ getTailnetHostname }) =>
|
||||||
getTailnetHostname(runner),
|
getTailnetHostname(runner),
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ type StatusScanCoreBootstrapParams<TAgentStatus> = {
|
|||||||
skipUpdateCheck?: boolean;
|
skipUpdateCheck?: boolean;
|
||||||
fetchGitUpdate?: boolean;
|
fetchGitUpdate?: boolean;
|
||||||
includeRegistryUpdate?: boolean;
|
includeRegistryUpdate?: boolean;
|
||||||
|
includeLocalStatusRpcFallback?: boolean;
|
||||||
|
gatewayProbeTimeoutMs?: number;
|
||||||
getTailnetHostname: (runner: StatusScanExecRunner) => Promise<string | null>;
|
getTailnetHostname: (runner: StatusScanExecRunner) => Promise<string | null>;
|
||||||
getUpdateCheckResult: (params: {
|
getUpdateCheckResult: (params: {
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
@@ -84,13 +86,15 @@ export async function createStatusScanCoreBootstrap<TAgentStatus>(
|
|||||||
hasConfiguredChannels: params.hasConfiguredChannels,
|
hasConfiguredChannels: params.hasConfiguredChannels,
|
||||||
all: params.opts.all,
|
all: params.opts.all,
|
||||||
});
|
});
|
||||||
const updateTimeoutMs = params.opts.all ? 6500 : 2500;
|
const statusTimeoutMs = params.opts.timeoutMs ?? 10_000;
|
||||||
|
const updateTimeoutMs = Math.min(params.opts.all ? 6500 : 2500, statusTimeoutMs);
|
||||||
|
const tailscaleTimeoutMs = Math.min(1200, statusTimeoutMs);
|
||||||
const tailscaleDnsPromise =
|
const tailscaleDnsPromise =
|
||||||
tailscaleMode === "off"
|
tailscaleMode === "off"
|
||||||
? Promise.resolve<string | null>(null)
|
? Promise.resolve<string | null>(null)
|
||||||
: params
|
: params
|
||||||
.getTailnetHostname((cmd, args) =>
|
.getTailnetHostname((cmd, args) =>
|
||||||
runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }),
|
runExec(cmd, args, { timeoutMs: tailscaleTimeoutMs, maxBuffer: 200_000 }),
|
||||||
)
|
)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const skipNetworkUpdate = skipColdStartNetworkChecks || params.skipUpdateCheck === true;
|
const skipNetworkUpdate = skipColdStartNetworkChecks || params.skipUpdateCheck === true;
|
||||||
@@ -109,7 +113,11 @@ export async function createStatusScanCoreBootstrap<TAgentStatus>(
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
opts: {
|
opts: {
|
||||||
...params.opts,
|
...params.opts,
|
||||||
|
...(params.gatewayProbeTimeoutMs !== undefined
|
||||||
|
? { timeoutMs: params.gatewayProbeTimeoutMs }
|
||||||
|
: {}),
|
||||||
...(skipColdStartNetworkChecks ? { skipProbe: true } : {}),
|
...(skipColdStartNetworkChecks ? { skipProbe: true } : {}),
|
||||||
|
localStatusRpcFallback: params.includeLocalStatusRpcFallback !== false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
...createStatusScanSharedMocks("status-fast-json"),
|
...createStatusScanSharedMocks("status-fast-json"),
|
||||||
|
callGateway: vi.fn(),
|
||||||
getStatusCommandSecretTargetIds: vi.fn(() => []),
|
getStatusCommandSecretTargetIds: vi.fn(() => []),
|
||||||
resolveMemorySearchConfig: vi.fn(),
|
resolveMemorySearchConfig: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -112,6 +113,85 @@ describe("scanStatusJsonFast", () => {
|
|||||||
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
|
expect(mocks.buildPluginCompatibilityNotices).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps default fast JSON update scans local-only", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
|
||||||
|
await scanStatusJsonFast({ timeoutMs: 1234 }, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.getUpdateCheckResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
timeoutMs: 1234,
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores registry-backed update checks and remote git fetches when --all is requested", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
|
||||||
|
await scanStatusJsonFast({ all: true }, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.getUpdateCheckResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
timeoutMs: 6500,
|
||||||
|
fetchGit: true,
|
||||||
|
includeRegistry: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the local status RPC fallback off the default fast JSON path", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
mocks.callGateway.mockResolvedValue({ sessions: 1 });
|
||||||
|
|
||||||
|
await scanStatusJsonFast({}, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.probeGateway).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 1000 }));
|
||||||
|
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors explicit gateway probe timeouts on the lean JSON path", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
|
||||||
|
await scanStatusJsonFast({ timeoutMs: 5000 }, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.probeGateway).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps configured gateway handshake timeouts on the lean JSON path", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
applyStatusScanDefaults(mocks, {
|
||||||
|
resolvedConfig: {
|
||||||
|
...createStatusMemorySearchConfig(),
|
||||||
|
gateway: { handshakeTimeoutMs: 30_000 },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await scanStatusJsonFast({}, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.probeGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
preauthHandshakeTimeoutMs: 30_000,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the local status RPC fallback when --all is requested", async () => {
|
||||||
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
mocks.callGateway.mockResolvedValue({ sessions: 1 });
|
||||||
|
|
||||||
|
await scanStatusJsonFast({ all: true }, {} as never);
|
||||||
|
|
||||||
|
expect(mocks.callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "status",
|
||||||
|
timeoutMs: 2000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the fast JSON summary off the channel plugin summary path", async () => {
|
it("keeps the fast JSON summary off the channel plugin summary path", async () => {
|
||||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||||
|
|
||||||
@@ -171,7 +251,7 @@ describe("scanStatusJsonFast", () => {
|
|||||||
expect(mocks.probeGateway).not.toHaveBeenCalled();
|
expect(mocks.probeGateway).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps cold-start probes when a channel is configured from manifest env vars", async () => {
|
it("keeps cold-start gateway probes with local-only updates when a channel is configured from manifest env vars", async () => {
|
||||||
await withTemporaryEnv(
|
await withTemporaryEnv(
|
||||||
{
|
{
|
||||||
OPENCLAW_TWITCH_ACCESS_TOKEN: "token",
|
OPENCLAW_TWITCH_ACCESS_TOKEN: "token",
|
||||||
@@ -184,7 +264,12 @@ describe("scanStatusJsonFast", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.getUpdateCheckResult).toHaveBeenCalled();
|
expect(mocks.getUpdateCheckResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(mocks.probeGateway).toHaveBeenCalled();
|
expect(mocks.probeGateway).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type StatusJsonScanPolicy = {
|
|||||||
commandName: string;
|
commandName: string;
|
||||||
allowMissingConfigFastPath?: boolean;
|
allowMissingConfigFastPath?: boolean;
|
||||||
includeChannelSummary?: boolean;
|
includeChannelSummary?: boolean;
|
||||||
|
fetchGitUpdate?: boolean;
|
||||||
|
includeRegistryUpdate?: boolean;
|
||||||
|
includeLocalStatusRpcFallback?: boolean;
|
||||||
|
gatewayProbeTimeoutMs?: number | ((cfg: OpenClawConfig) => number | undefined);
|
||||||
resolveHasConfiguredChannels: (
|
resolveHasConfiguredChannels: (
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
sourceConfig: OpenClawConfig,
|
sourceConfig: OpenClawConfig,
|
||||||
@@ -90,6 +94,10 @@ export async function scanStatusJsonWithPolicy(
|
|||||||
includeChannelsData: false,
|
includeChannelsData: false,
|
||||||
includeChannelSecretTargets: false,
|
includeChannelSecretTargets: false,
|
||||||
skipConfigPluginValidation: true,
|
skipConfigPluginValidation: true,
|
||||||
|
fetchGitUpdate: policy.fetchGitUpdate,
|
||||||
|
includeRegistryUpdate: policy.includeRegistryUpdate,
|
||||||
|
includeLocalStatusRpcFallback: policy.includeLocalStatusRpcFallback,
|
||||||
|
gatewayProbeTimeoutMs: policy.gatewayProbeTimeoutMs,
|
||||||
});
|
});
|
||||||
return await executeStatusScanFromOverview({
|
return await executeStatusScanFromOverview({
|
||||||
overview,
|
overview,
|
||||||
@@ -115,6 +123,13 @@ export async function scanStatusJsonFast(
|
|||||||
commandName: "status --json",
|
commandName: "status --json",
|
||||||
allowMissingConfigFastPath: true,
|
allowMissingConfigFastPath: true,
|
||||||
includeChannelSummary: false,
|
includeChannelSummary: false,
|
||||||
|
fetchGitUpdate: opts.all === true,
|
||||||
|
includeRegistryUpdate: opts.all === true,
|
||||||
|
includeLocalStatusRpcFallback: opts.all === true,
|
||||||
|
gatewayProbeTimeoutMs:
|
||||||
|
opts.all === true
|
||||||
|
? undefined
|
||||||
|
: (cfg) => opts.timeoutMs ?? Math.max(1000, cfg.gateway?.handshakeTimeoutMs ?? 0),
|
||||||
resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannelsForStatusJson(cfg),
|
resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannelsForStatusJson(cfg),
|
||||||
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
|
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
|
||||||
opts.all
|
opts.all
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ async function applyLocalStatusRpcFallback(params: {
|
|||||||
};
|
};
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
timeoutMsExplicit: boolean;
|
timeoutMsExplicit: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
}): Promise<GatewayProbeResult | null> {
|
}): Promise<GatewayProbeResult | null> {
|
||||||
|
if (params.enabled === false) {
|
||||||
|
return params.gatewayProbe;
|
||||||
|
}
|
||||||
if (!shouldTryLocalStatusRpcFallback(params)) {
|
if (!shouldTryLocalStatusRpcFallback(params)) {
|
||||||
return params.gatewayProbe;
|
return params.gatewayProbe;
|
||||||
}
|
}
|
||||||
@@ -148,13 +152,17 @@ async function applyLocalStatusRpcFallback(params: {
|
|||||||
...params.gatewayProbe,
|
...params.gatewayProbe,
|
||||||
ok: true,
|
ok: true,
|
||||||
status,
|
status,
|
||||||
auth:
|
...(auth
|
||||||
auth.capability === "unknown"
|
? {
|
||||||
? {
|
auth:
|
||||||
...auth,
|
auth.capability === "unknown"
|
||||||
capability: "read_only",
|
? {
|
||||||
}
|
...auth,
|
||||||
: auth,
|
capability: "read_only",
|
||||||
|
}
|
||||||
|
: auth,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +201,7 @@ export async function resolveGatewayProbeSnapshot(params: {
|
|||||||
probeWhenRemoteUrlMissing?: boolean;
|
probeWhenRemoteUrlMissing?: boolean;
|
||||||
resolveAuthWhenRemoteUrlMissing?: boolean;
|
resolveAuthWhenRemoteUrlMissing?: boolean;
|
||||||
mergeAuthWarningIntoProbeError?: boolean;
|
mergeAuthWarningIntoProbeError?: boolean;
|
||||||
|
localStatusRpcFallback?: boolean;
|
||||||
};
|
};
|
||||||
}): Promise<GatewayProbeSnapshot> {
|
}): Promise<GatewayProbeSnapshot> {
|
||||||
const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ config: params.cfg });
|
const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ config: params.cfg });
|
||||||
@@ -236,6 +245,7 @@ export async function resolveGatewayProbeSnapshot(params: {
|
|||||||
gatewayProbeAuth: gatewayProbeAuthResolution.auth,
|
gatewayProbeAuth: gatewayProbeAuthResolution.auth,
|
||||||
timeoutMs: probeTimeoutMs,
|
timeoutMs: probeTimeoutMs,
|
||||||
timeoutMsExplicit,
|
timeoutMsExplicit,
|
||||||
|
enabled: params.opts.localStatusRpcFallback !== false,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
(params.opts.mergeAuthWarningIntoProbeError ?? true) &&
|
(params.opts.mergeAuthWarningIntoProbeError ?? true) &&
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
|||||||
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
|
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
|
||||||
buildChannelSummary: vi.fn(async () => ["ok"]),
|
buildChannelSummary: vi.fn(async () => ["ok"]),
|
||||||
readSessionStoreReadOnly: vi.fn(() => ({})),
|
readSessionStoreReadOnly: vi.fn(() => ({})),
|
||||||
|
configureTaskRegistryMaintenance: vi.fn(),
|
||||||
taskRegistrySummary: {
|
taskRegistrySummary: {
|
||||||
total: 0,
|
total: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
@@ -27,6 +28,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
|||||||
cron: 0,
|
cron: 0,
|
||||||
},
|
},
|
||||||
} as TaskRegistrySummary,
|
} as TaskRegistrySummary,
|
||||||
|
getInspectableTaskRegistrySummary: vi.fn(() => statusSummaryMocks.taskRegistrySummary),
|
||||||
taskAuditFindings: [
|
taskAuditFindings: [
|
||||||
{
|
{
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
@@ -46,6 +48,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as TaskAuditFinding[],
|
] as TaskAuditFinding[],
|
||||||
|
getInspectableTaskAuditFindings: vi.fn(() => statusSummaryMocks.taskAuditFindings),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/channel-plugin-ids.js", () => ({
|
vi.mock("../plugins/channel-plugin-ids.js", () => ({
|
||||||
@@ -114,22 +117,9 @@ vi.mock("../infra/system-events.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
||||||
configureTaskRegistryMaintenance: vi.fn(),
|
configureTaskRegistryMaintenance: statusSummaryMocks.configureTaskRegistryMaintenance,
|
||||||
getInspectableTaskRegistrySummary: vi.fn(() => statusSummaryMocks.taskRegistrySummary),
|
getInspectableTaskRegistrySummary: statusSummaryMocks.getInspectableTaskRegistrySummary,
|
||||||
getInspectableTaskAuditSummary: vi.fn(() => ({
|
getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings,
|
||||||
total: 1,
|
|
||||||
warnings: 1,
|
|
||||||
errors: 0,
|
|
||||||
byCode: {
|
|
||||||
stale_queued: 0,
|
|
||||||
stale_running: 0,
|
|
||||||
lost: 0,
|
|
||||||
delivery_failed: 1,
|
|
||||||
missing_cleanup: 0,
|
|
||||||
inconsistent_timestamps: 0,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
getInspectableTaskAuditFindings: vi.fn(() => statusSummaryMocks.taskAuditFindings),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../routing/session-key.js", () => ({
|
vi.mock("../routing/session-key.js", () => ({
|
||||||
|
|||||||
@@ -1,177 +1,25 @@
|
|||||||
import "./isolated-agent.mocks.js";
|
import { describe, expect, it } from "vitest";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { resolveCronRunTimeoutOverrideMs } from "./isolated-agent/run-timeout.js";
|
||||||
import { clearAllBootstrapSnapshots } from "../agents/bootstrap-cache.js";
|
|
||||||
import { runEmbeddedAgent } from "../agents/embedded-agent.js";
|
|
||||||
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
|
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
||||||
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
|
|
||||||
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
|
|
||||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
|
||||||
import {
|
|
||||||
makeCfg,
|
|
||||||
makeJob,
|
|
||||||
withTempCronHome,
|
|
||||||
writeSessionStoreEntries,
|
|
||||||
} from "./isolated-agent.test-harness.js";
|
|
||||||
|
|
||||||
function lastEmbeddedCall(): { runTimeoutOverrideMs?: number; timeoutMs?: number } {
|
|
||||||
const calls = vi.mocked(runEmbeddedAgent).mock.calls;
|
|
||||||
expect(calls.length).toBeGreaterThan(0);
|
|
||||||
return calls.at(-1)?.[0] as { runTimeoutOverrideMs?: number; timeoutMs?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTimeoutTestCfg(
|
|
||||||
home: string,
|
|
||||||
storePath: string,
|
|
||||||
timeoutSeconds: number,
|
|
||||||
): OpenClawConfig {
|
|
||||||
return makeCfg(home, storePath, {
|
|
||||||
agents: { defaults: { timeoutSeconds } },
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
openai: {
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
agentRuntime: { id: "openclaw" },
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const envSnapshot = {
|
|
||||||
HOME: process.env.HOME,
|
|
||||||
USERPROFILE: process.env.USERPROFILE,
|
|
||||||
HOMEDRIVE: process.env.HOMEDRIVE,
|
|
||||||
HOMEPATH: process.env.HOMEPATH,
|
|
||||||
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
|
|
||||||
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function restoreSnapshotEnv() {
|
|
||||||
for (const [key, value] of Object.entries(envSnapshot)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("runCronIsolatedAgentTurn — explicit per-run timeout signal", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(runEmbeddedAgent).mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
restoreSnapshotEnv();
|
|
||||||
vi.doUnmock("../agents/embedded-agent.js");
|
|
||||||
vi.doUnmock("../agents/model-catalog.js");
|
|
||||||
vi.doUnmock("../agents/model-selection.js");
|
|
||||||
vi.doUnmock("../agents/subagent-announce.js");
|
|
||||||
vi.doUnmock("../gateway/call.js");
|
|
||||||
clearSessionStoreCacheForTest();
|
|
||||||
resetAgentRunContextForTest();
|
|
||||||
clearAllBootstrapSnapshots();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
describe("resolveCronRunTimeoutOverrideMs", () => {
|
||||||
// Regression: when a cron job's payload `timeoutSeconds` numerically equals
|
// Regression: when a cron job's payload `timeoutSeconds` numerically equals
|
||||||
// `agents.defaults.timeoutSeconds`, the run is still an *explicit* per-run
|
// the configured agent default, `timeoutMs !== defaultTimeoutMs` collapses to
|
||||||
// override. The embedded runner used to detect "explicit" by comparing
|
// `false` in the embedded runner. The cron entry point must carry a separate
|
||||||
// `params.timeoutMs !== resolveAgentTimeoutMs({cfg})` — which collapses to
|
// explicit-timeout signal so the LLM idle watchdog does not fall back to its
|
||||||
// `false` in this case, stripping the runTimeoutMs signal and letting the
|
// implicit 120s cap.
|
||||||
// LLM idle watchdog fall back to the implicit 120s cap.
|
it("preserves explicit payload timeoutSeconds even when it equals the agent default", () => {
|
||||||
// Fix: forward `runTimeoutOverrideMs` from the cron entry point so the
|
expect(resolveCronRunTimeoutOverrideMs(300)).toBe(300_000);
|
||||||
// explicit-vs-default distinction survives the merge into `timeoutMs`.
|
|
||||||
it("forwards runTimeoutOverrideMs when payload.timeoutSeconds equals the agent default", async () => {
|
|
||||||
await withTempCronHome(async (home) => {
|
|
||||||
const storePath = await writeSessionStoreEntries(home, {
|
|
||||||
"agent:main:main": {
|
|
||||||
sessionId: "main-session",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "webchat",
|
|
||||||
lastTo: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockAgentPayloads([{ text: "ok" }]);
|
|
||||||
|
|
||||||
const cfg = makeTimeoutTestCfg(home, storePath, 300);
|
|
||||||
|
|
||||||
await runCronIsolatedAgentTurn({
|
|
||||||
cfg,
|
|
||||||
deps: createCliDeps(),
|
|
||||||
job: {
|
|
||||||
...makeJob({ kind: "agentTurn", message: "do it", timeoutSeconds: 300 }),
|
|
||||||
delivery: { mode: "none" },
|
|
||||||
},
|
|
||||||
message: "do it",
|
|
||||||
sessionKey: "cron:job-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = lastEmbeddedCall();
|
|
||||||
expect(call.runTimeoutOverrideMs).toBe(300_000);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards runTimeoutOverrideMs when payload.timeoutSeconds differs from the agent default", async () => {
|
it("preserves explicit payload timeoutSeconds when it differs from the agent default", () => {
|
||||||
await withTempCronHome(async (home) => {
|
expect(resolveCronRunTimeoutOverrideMs(600)).toBe(600_000);
|
||||||
const storePath = await writeSessionStoreEntries(home, {
|
|
||||||
"agent:main:main": {
|
|
||||||
sessionId: "main-session",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "webchat",
|
|
||||||
lastTo: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockAgentPayloads([{ text: "ok" }]);
|
|
||||||
|
|
||||||
const cfg = makeTimeoutTestCfg(home, storePath, 300);
|
|
||||||
|
|
||||||
await runCronIsolatedAgentTurn({
|
|
||||||
cfg,
|
|
||||||
deps: createCliDeps(),
|
|
||||||
job: {
|
|
||||||
...makeJob({ kind: "agentTurn", message: "do it", timeoutSeconds: 600 }),
|
|
||||||
delivery: { mode: "none" },
|
|
||||||
},
|
|
||||||
message: "do it",
|
|
||||||
sessionKey: "cron:job-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = lastEmbeddedCall();
|
|
||||||
expect(call.runTimeoutOverrideMs).toBe(600_000);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves runTimeoutOverrideMs undefined when payload omits timeoutSeconds", async () => {
|
it("omits the signal when the cron payload has no positive numeric timeout", () => {
|
||||||
await withTempCronHome(async (home) => {
|
expect(resolveCronRunTimeoutOverrideMs(undefined)).toBeUndefined();
|
||||||
const storePath = await writeSessionStoreEntries(home, {
|
expect(resolveCronRunTimeoutOverrideMs(0)).toBeUndefined();
|
||||||
"agent:main:main": {
|
expect(resolveCronRunTimeoutOverrideMs(-1)).toBeUndefined();
|
||||||
sessionId: "main-session",
|
expect(resolveCronRunTimeoutOverrideMs(Number.NaN)).toBeUndefined();
|
||||||
updatedAt: Date.now(),
|
expect(resolveCronRunTimeoutOverrideMs("300")).toBeUndefined();
|
||||||
lastProvider: "webchat",
|
|
||||||
lastTo: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockAgentPayloads([{ text: "ok" }]);
|
|
||||||
|
|
||||||
const cfg = makeTimeoutTestCfg(home, storePath, 300);
|
|
||||||
|
|
||||||
await runCronIsolatedAgentTurn({
|
|
||||||
cfg,
|
|
||||||
deps: createCliDeps(),
|
|
||||||
job: {
|
|
||||||
...makeJob({ kind: "agentTurn", message: "do it" }),
|
|
||||||
delivery: { mode: "none" },
|
|
||||||
},
|
|
||||||
message: "do it",
|
|
||||||
sessionKey: "cron:job-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = lastEmbeddedCall();
|
|
||||||
expect(call.runTimeoutOverrideMs).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
5
src/cron/isolated-agent/run-timeout.ts
Normal file
5
src/cron/isolated-agent/run-timeout.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function resolveCronRunTimeoutOverrideMs(timeoutSeconds: unknown): number | undefined {
|
||||||
|
return typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0
|
||||||
|
? timeoutSeconds * 1000
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
type MutableCronSession,
|
type MutableCronSession,
|
||||||
type PersistCronSessionEntry,
|
type PersistCronSessionEntry,
|
||||||
} from "./run-session-state.js";
|
} from "./run-session-state.js";
|
||||||
|
import { resolveCronRunTimeoutOverrideMs } from "./run-timeout.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONTEXT_TOKENS,
|
DEFAULT_CONTEXT_TOKENS,
|
||||||
deriveSessionTotalTokens,
|
deriveSessionTotalTokens,
|
||||||
@@ -704,12 +705,7 @@ async function prepareCronRunContext(params: {
|
|||||||
// explicit-vs-default distinction without this companion field, which would
|
// explicit-vs-default distinction without this companion field, which would
|
||||||
// otherwise force the implicit 120 s cap whenever the cron payload's
|
// otherwise force the implicit 120 s cap whenever the cron payload's
|
||||||
// `timeoutSeconds` happens to numerically equal `agents.defaults.timeoutSeconds`.
|
// `timeoutSeconds` happens to numerically equal `agents.defaults.timeoutSeconds`.
|
||||||
const runTimeoutOverrideMs =
|
const runTimeoutOverrideMs = resolveCronRunTimeoutOverrideMs(explicitTimeoutSeconds);
|
||||||
typeof explicitTimeoutSeconds === "number" &&
|
|
||||||
Number.isFinite(explicitTimeoutSeconds) &&
|
|
||||||
explicitTimeoutSeconds > 0
|
|
||||||
? explicitTimeoutSeconds * 1000
|
|
||||||
: undefined;
|
|
||||||
const agentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null;
|
const agentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null;
|
||||||
const { deliveryPlan, deliveryRequested, resolvedDelivery, sourceDelivery } =
|
const { deliveryPlan, deliveryRequested, resolvedDelivery, sourceDelivery } =
|
||||||
await resolveCronDeliveryContext({
|
await resolveCronDeliveryContext({
|
||||||
|
|||||||
Reference in New Issue
Block a user