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:
Andy
2026-05-28 09:28:49 +08:00
committed by GitHub
parent 5846878924
commit d2319d718c
15 changed files with 233 additions and 17397 deletions

View File

@@ -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

View File

@@ -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"];

View File

@@ -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");
} }

View File

@@ -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);
}); });

View File

@@ -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;
} }

View File

@@ -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),

View File

@@ -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,
}, },
}); });

View File

@@ -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();
}); });
}); });

View File

@@ -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

View File

@@ -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) &&

View File

@@ -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", () => ({

View File

@@ -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();
});
}); });
}); });

View File

@@ -0,0 +1,5 @@
export function resolveCronRunTimeoutOverrideMs(timeoutSeconds: unknown): number | undefined {
return typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0
? timeoutSeconds * 1000
: undefined;
}

View File

@@ -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({