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",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/diffs/assets/viewer-runtime.js",
|
||||
"**/*.json",
|
||||
"node_modules/",
|
||||
"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;
|
||||
onError?: (err: unknown, info: { kind: "block" | "final" }) => void;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
onSettled?: () => Promise<unknown> | unknown;
|
||||
onFreshSettledDelivery?: () => Promise<unknown> | unknown;
|
||||
onSettled?: () => unknown;
|
||||
onFreshSettledDelivery?: () => unknown;
|
||||
};
|
||||
ctx?: unknown;
|
||||
replyOptions?: DispatchInboundParams["replyOptions"];
|
||||
|
||||
@@ -177,7 +177,10 @@ describe("sendMessage", () => {
|
||||
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", "42abc"));
|
||||
|
||||
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") {
|
||||
throw new Error("expected Synology Chat webhook body");
|
||||
}
|
||||
|
||||
@@ -373,7 +373,9 @@ describe("TwilioProvider", () => {
|
||||
|
||||
const event = provider.parseWebhookEvent(ctx).events[0];
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -409,8 +409,8 @@ function buildDynamicModel(
|
||||
: lower === "gpt-5.4-mini"
|
||||
? ["gpt-5.4-mini"]
|
||||
: lower === "gpt-5.4-nano"
|
||||
? ["gpt-5.4-nano", "gpt-5.4-mini"]
|
||||
: undefined;
|
||||
? ["gpt-5.4-nano", "gpt-5.4-mini"]
|
||||
: undefined;
|
||||
if (!templateIds) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { loadStatusScanCommandConfig } from "./status.scan.config-shared.js";
|
||||
import type { GatewayProbeSnapshot } from "./status.scan.shared.js";
|
||||
|
||||
type StatusGatewayProbeTimeoutResolver = (cfg: OpenClawConfig) => number | undefined;
|
||||
|
||||
const statusScanDepsRuntimeModuleLoader = createLazyImportLoader(
|
||||
() => import("./status.scan.deps.runtime.js"),
|
||||
);
|
||||
@@ -144,6 +146,8 @@ export async function collectStatusScanOverview(params: {
|
||||
) => boolean | Promise<boolean>;
|
||||
includeChannelsData?: boolean;
|
||||
includeLiveChannelStatus?: boolean;
|
||||
includeLocalStatusRpcFallback?: boolean;
|
||||
gatewayProbeTimeoutMs?: number | StatusGatewayProbeTimeoutResolver;
|
||||
includeChannelSetupRuntimeFallback?: boolean;
|
||||
channelCredentialResolutionSkipped?: boolean;
|
||||
useGatewayCallOverridesForChannelsStatus?: boolean;
|
||||
@@ -203,6 +207,10 @@ export async function collectStatusScanOverview(params: {
|
||||
}),
|
||||
);
|
||||
const osSummary = resolveOsSummary();
|
||||
const gatewayProbeTimeoutMs =
|
||||
typeof params.gatewayProbeTimeoutMs === "function"
|
||||
? params.gatewayProbeTimeoutMs(cfg)
|
||||
: params.gatewayProbeTimeoutMs;
|
||||
const bootstrap = await createStatusScanCoreBootstrap<
|
||||
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>
|
||||
>({
|
||||
@@ -213,6 +221,8 @@ export async function collectStatusScanOverview(params: {
|
||||
skipUpdateCheck: params.skipUpdateCheck,
|
||||
fetchGitUpdate: params.fetchGitUpdate,
|
||||
includeRegistryUpdate: params.includeRegistryUpdate,
|
||||
includeLocalStatusRpcFallback: params.includeLocalStatusRpcFallback,
|
||||
gatewayProbeTimeoutMs,
|
||||
getTailnetHostname: async (runner) =>
|
||||
await loadStatusScanDepsRuntimeModule().then(({ getTailnetHostname }) =>
|
||||
getTailnetHostname(runner),
|
||||
|
||||
@@ -65,6 +65,8 @@ type StatusScanCoreBootstrapParams<TAgentStatus> = {
|
||||
skipUpdateCheck?: boolean;
|
||||
fetchGitUpdate?: boolean;
|
||||
includeRegistryUpdate?: boolean;
|
||||
includeLocalStatusRpcFallback?: boolean;
|
||||
gatewayProbeTimeoutMs?: number;
|
||||
getTailnetHostname: (runner: StatusScanExecRunner) => Promise<string | null>;
|
||||
getUpdateCheckResult: (params: {
|
||||
timeoutMs: number;
|
||||
@@ -84,13 +86,15 @@ export async function createStatusScanCoreBootstrap<TAgentStatus>(
|
||||
hasConfiguredChannels: params.hasConfiguredChannels,
|
||||
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 =
|
||||
tailscaleMode === "off"
|
||||
? Promise.resolve<string | null>(null)
|
||||
: params
|
||||
.getTailnetHostname((cmd, args) =>
|
||||
runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }),
|
||||
runExec(cmd, args, { timeoutMs: tailscaleTimeoutMs, maxBuffer: 200_000 }),
|
||||
)
|
||||
.catch(() => null);
|
||||
const skipNetworkUpdate = skipColdStartNetworkChecks || params.skipUpdateCheck === true;
|
||||
@@ -109,7 +113,11 @@ export async function createStatusScanCoreBootstrap<TAgentStatus>(
|
||||
cfg: params.cfg,
|
||||
opts: {
|
||||
...params.opts,
|
||||
...(params.gatewayProbeTimeoutMs !== undefined
|
||||
? { timeoutMs: params.gatewayProbeTimeoutMs }
|
||||
: {}),
|
||||
...(skipColdStartNetworkChecks ? { skipProbe: true } : {}),
|
||||
localStatusRpcFallback: params.includeLocalStatusRpcFallback !== false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
const mocks = {
|
||||
...createStatusScanSharedMocks("status-fast-json"),
|
||||
callGateway: vi.fn(),
|
||||
getStatusCommandSecretTargetIds: vi.fn(() => []),
|
||||
resolveMemorySearchConfig: vi.fn(),
|
||||
};
|
||||
@@ -112,6 +113,85 @@ describe("scanStatusJsonFast", () => {
|
||||
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 () => {
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
|
||||
@@ -171,7 +251,7 @@ describe("scanStatusJsonFast", () => {
|
||||
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(
|
||||
{
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ type StatusJsonScanPolicy = {
|
||||
commandName: string;
|
||||
allowMissingConfigFastPath?: boolean;
|
||||
includeChannelSummary?: boolean;
|
||||
fetchGitUpdate?: boolean;
|
||||
includeRegistryUpdate?: boolean;
|
||||
includeLocalStatusRpcFallback?: boolean;
|
||||
gatewayProbeTimeoutMs?: number | ((cfg: OpenClawConfig) => number | undefined);
|
||||
resolveHasConfiguredChannels: (
|
||||
cfg: OpenClawConfig,
|
||||
sourceConfig: OpenClawConfig,
|
||||
@@ -90,6 +94,10 @@ export async function scanStatusJsonWithPolicy(
|
||||
includeChannelsData: false,
|
||||
includeChannelSecretTargets: false,
|
||||
skipConfigPluginValidation: true,
|
||||
fetchGitUpdate: policy.fetchGitUpdate,
|
||||
includeRegistryUpdate: policy.includeRegistryUpdate,
|
||||
includeLocalStatusRpcFallback: policy.includeLocalStatusRpcFallback,
|
||||
gatewayProbeTimeoutMs: policy.gatewayProbeTimeoutMs,
|
||||
});
|
||||
return await executeStatusScanFromOverview({
|
||||
overview,
|
||||
@@ -115,6 +123,13 @@ export async function scanStatusJsonFast(
|
||||
commandName: "status --json",
|
||||
allowMissingConfigFastPath: true,
|
||||
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),
|
||||
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
|
||||
opts.all
|
||||
|
||||
@@ -120,7 +120,11 @@ async function applyLocalStatusRpcFallback(params: {
|
||||
};
|
||||
timeoutMs: number;
|
||||
timeoutMsExplicit: boolean;
|
||||
enabled?: boolean;
|
||||
}): Promise<GatewayProbeResult | null> {
|
||||
if (params.enabled === false) {
|
||||
return params.gatewayProbe;
|
||||
}
|
||||
if (!shouldTryLocalStatusRpcFallback(params)) {
|
||||
return params.gatewayProbe;
|
||||
}
|
||||
@@ -148,13 +152,17 @@ async function applyLocalStatusRpcFallback(params: {
|
||||
...params.gatewayProbe,
|
||||
ok: true,
|
||||
status,
|
||||
auth:
|
||||
auth.capability === "unknown"
|
||||
? {
|
||||
...auth,
|
||||
capability: "read_only",
|
||||
}
|
||||
: auth,
|
||||
...(auth
|
||||
? {
|
||||
auth:
|
||||
auth.capability === "unknown"
|
||||
? {
|
||||
...auth,
|
||||
capability: "read_only",
|
||||
}
|
||||
: auth,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +201,7 @@ export async function resolveGatewayProbeSnapshot(params: {
|
||||
probeWhenRemoteUrlMissing?: boolean;
|
||||
resolveAuthWhenRemoteUrlMissing?: boolean;
|
||||
mergeAuthWarningIntoProbeError?: boolean;
|
||||
localStatusRpcFallback?: boolean;
|
||||
};
|
||||
}): Promise<GatewayProbeSnapshot> {
|
||||
const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ config: params.cfg });
|
||||
@@ -236,6 +245,7 @@ export async function resolveGatewayProbeSnapshot(params: {
|
||||
gatewayProbeAuth: gatewayProbeAuthResolution.auth,
|
||||
timeoutMs: probeTimeoutMs,
|
||||
timeoutMsExplicit,
|
||||
enabled: params.opts.localStatusRpcFallback !== false,
|
||||
});
|
||||
if (
|
||||
(params.opts.mergeAuthWarningIntoProbeError ?? true) &&
|
||||
|
||||
@@ -6,6 +6,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
||||
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
|
||||
buildChannelSummary: vi.fn(async () => ["ok"]),
|
||||
readSessionStoreReadOnly: vi.fn(() => ({})),
|
||||
configureTaskRegistryMaintenance: vi.fn(),
|
||||
taskRegistrySummary: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
@@ -27,6 +28,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
||||
cron: 0,
|
||||
},
|
||||
} as TaskRegistrySummary,
|
||||
getInspectableTaskRegistrySummary: vi.fn(() => statusSummaryMocks.taskRegistrySummary),
|
||||
taskAuditFindings: [
|
||||
{
|
||||
severity: "warn",
|
||||
@@ -46,6 +48,7 @@ const statusSummaryMocks = vi.hoisted(() => ({
|
||||
},
|
||||
},
|
||||
] as TaskAuditFinding[],
|
||||
getInspectableTaskAuditFindings: vi.fn(() => statusSummaryMocks.taskAuditFindings),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/channel-plugin-ids.js", () => ({
|
||||
@@ -114,22 +117,9 @@ vi.mock("../infra/system-events.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
||||
configureTaskRegistryMaintenance: vi.fn(),
|
||||
getInspectableTaskRegistrySummary: vi.fn(() => statusSummaryMocks.taskRegistrySummary),
|
||||
getInspectableTaskAuditSummary: vi.fn(() => ({
|
||||
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),
|
||||
configureTaskRegistryMaintenance: statusSummaryMocks.configureTaskRegistryMaintenance,
|
||||
getInspectableTaskRegistrySummary: statusSummaryMocks.getInspectableTaskRegistrySummary,
|
||||
getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings,
|
||||
}));
|
||||
|
||||
vi.mock("../routing/session-key.js", () => ({
|
||||
|
||||
@@ -1,177 +1,25 @@
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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();
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCronRunTimeoutOverrideMs } from "./isolated-agent/run-timeout.js";
|
||||
|
||||
describe("resolveCronRunTimeoutOverrideMs", () => {
|
||||
// Regression: when a cron job's payload `timeoutSeconds` numerically equals
|
||||
// `agents.defaults.timeoutSeconds`, the run is still an *explicit* per-run
|
||||
// override. The embedded runner used to detect "explicit" by comparing
|
||||
// `params.timeoutMs !== resolveAgentTimeoutMs({cfg})` — which collapses to
|
||||
// `false` in this case, stripping the runTimeoutMs signal and letting the
|
||||
// LLM idle watchdog fall back to the implicit 120s cap.
|
||||
// Fix: forward `runTimeoutOverrideMs` from the cron entry point so the
|
||||
// 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);
|
||||
});
|
||||
// the configured agent default, `timeoutMs !== defaultTimeoutMs` collapses to
|
||||
// `false` in the embedded runner. The cron entry point must carry a separate
|
||||
// explicit-timeout signal so the LLM idle watchdog does not fall back to its
|
||||
// implicit 120s cap.
|
||||
it("preserves explicit payload timeoutSeconds even when it equals the agent default", () => {
|
||||
expect(resolveCronRunTimeoutOverrideMs(300)).toBe(300_000);
|
||||
});
|
||||
|
||||
it("forwards runTimeoutOverrideMs when payload.timeoutSeconds differs from 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: 600 }),
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
});
|
||||
|
||||
const call = lastEmbeddedCall();
|
||||
expect(call.runTimeoutOverrideMs).toBe(600_000);
|
||||
});
|
||||
it("preserves explicit payload timeoutSeconds when it differs from the agent default", () => {
|
||||
expect(resolveCronRunTimeoutOverrideMs(600)).toBe(600_000);
|
||||
});
|
||||
|
||||
it("leaves runTimeoutOverrideMs undefined when payload omits timeoutSeconds", 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" }),
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
});
|
||||
|
||||
const call = lastEmbeddedCall();
|
||||
expect(call.runTimeoutOverrideMs).toBeUndefined();
|
||||
});
|
||||
it("omits the signal when the cron payload has no positive numeric timeout", () => {
|
||||
expect(resolveCronRunTimeoutOverrideMs(undefined)).toBeUndefined();
|
||||
expect(resolveCronRunTimeoutOverrideMs(0)).toBeUndefined();
|
||||
expect(resolveCronRunTimeoutOverrideMs(-1)).toBeUndefined();
|
||||
expect(resolveCronRunTimeoutOverrideMs(Number.NaN)).toBeUndefined();
|
||||
expect(resolveCronRunTimeoutOverrideMs("300")).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 PersistCronSessionEntry,
|
||||
} from "./run-session-state.js";
|
||||
import { resolveCronRunTimeoutOverrideMs } from "./run-timeout.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
deriveSessionTotalTokens,
|
||||
@@ -704,12 +705,7 @@ async function prepareCronRunContext(params: {
|
||||
// explicit-vs-default distinction without this companion field, which would
|
||||
// otherwise force the implicit 120 s cap whenever the cron payload's
|
||||
// `timeoutSeconds` happens to numerically equal `agents.defaults.timeoutSeconds`.
|
||||
const runTimeoutOverrideMs =
|
||||
typeof explicitTimeoutSeconds === "number" &&
|
||||
Number.isFinite(explicitTimeoutSeconds) &&
|
||||
explicitTimeoutSeconds > 0
|
||||
? explicitTimeoutSeconds * 1000
|
||||
: undefined;
|
||||
const runTimeoutOverrideMs = resolveCronRunTimeoutOverrideMs(explicitTimeoutSeconds);
|
||||
const agentPayload = input.job.payload.kind === "agentTurn" ? input.job.payload : null;
|
||||
const { deliveryPlan, deliveryRequested, resolvedDelivery, sourceDelivery } =
|
||||
await resolveCronDeliveryContext({
|
||||
|
||||
Reference in New Issue
Block a user