From 102f18446ff0df6104211ca8a156f4940dfcdd86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 16:22:32 -0700 Subject: [PATCH] refactor(cron): centralize automation mutation options (#128373) * refactor(cron): centralize automation mutation options * chore(cron): keep release notes out of root changelog * test(node-host): synchronize MCP refresh notifications explicitly * test(node-host): preserve refresh synchronization tuple types --- docs/automation/cron-jobs.md | 2 + src/cli/cron-cli/register.cron-add.ts | 103 ++---------------- src/cli/cron-cli/register.cron-edit.test.ts | 3 + src/cli/cron-cli/register.cron-edit.ts | 91 ++-------------- .../cron-cli/register.cron-options.test.ts | 81 ++++++++++++++ src/cli/cron-cli/register.cron-options.ts | 84 ++++++++++++++ src/node-host/mcp.recovery.test.ts | 8 +- 7 files changed, 193 insertions(+), 179 deletions(-) create mode 100644 src/cli/cron-cli/register.cron-options.test.ts create mode 100644 src/cli/cron-cli/register.cron-options.ts diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 4dde7f1fe924..e58cae384cf1 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -75,6 +75,8 @@ Manage automations with the `openclaw automations` CLI; `openclaw cron` remains | `on-exit` | `--on-exit` | Fire once when a watched command exits (event trigger; survives turn teardown; optional `--on-exit-cwd`) | | `stream` | `--stream-command` | Fire from batched lines produced by a supervised long-lived command | +These schedule flags work with both `openclaw automations add` and `openclaw automations edit `. For example, `openclaw automations edit --on-exit "./watch.sh" --on-exit-cwd /srv/app` converts an existing job to an exit-triggered schedule. + Timestamps without a timezone are treated as UTC. Add `--tz America/New_York` to interpret an offset-less `--at` datetime, or to evaluate a cron expression, in that IANA timezone. Cron expressions without `--tz` use the Gateway host timezone. `--tz` is not valid with `--every` or `--on-exit`. Recurring top-of-hour expressions (minute `0` with a wildcard hour field) are automatically staggered by up to 5 minutes to reduce load spikes. Use `--exact` to force precise timing, or `--stagger 30s` for an explicit window (cron schedules only). diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index e5ce6439ca04..370f93b45ad1 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -6,7 +6,6 @@ import { } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { theme } from "../../../packages/terminal-core/src/theme.js"; -import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js"; import type { CronJob } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; @@ -15,9 +14,9 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { listCronJobsFromGateway } from "./list-jobs.js"; import { createCronOutputCommand } from "./output-mode.js"; +import { registerCronMutationOptions } from "./register.cron-options.js"; import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js"; import { - getCronChannelOptions, coerceCronDeliveryPreviews, enrichCronJsonWithStatus, handleCronCliError, @@ -84,83 +83,15 @@ export function registerCronListCommand(cron: Command) { export function registerCronAddCommand(cron: Command) { addGatewayClientOptions( - createCronOutputCommand(cron, "add") - .description("Add an automation") - .argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron") - .argument("[message]", "Agent message when using a positional schedule") - .option("--name ", "Job name") + registerCronMutationOptions( + createCronOutputCommand(cron, "add") + .description("Add an automation") + .argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron") + .argument("[message]", "Agent message when using a positional schedule"), + "add", + ) .option("--declaration-key ", "Idempotent declaration identity key") - .option("--display-name ", "Human-readable declarative job label") - .option("--description ", "Optional description") .option("--disabled", "Create job disabled", false) - .option("--delete-after-run", "Delete one-shot job after it succeeds", false) - .option("--keep-after-run", "Keep one-shot job after it succeeds", false) - .option("--agent ", "Agent id for this job") - .option("--session ", "Session target (main|isolated|current|session:)") - .option("--session-key ", "Session key for job routing (e.g. agent:my-agent:my-session)") - .option("--wake ", "Wake mode (now|next-heartbeat)", "now") - .option( - "--at ", - "Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes", - ) - .option("--every ", "Run every duration (e.g. 10m, 1h)") - .option("--pacing-min ", "Minimum delay accepted from a dynamic next check") - .option("--pacing-max ", "Maximum delay accepted from a dynamic next check") - .option("--cron ", "Cron expression (5-field or 6-field with seconds)") - .option( - "--on-exit ", - "Fire once when this watched command exits (event trigger; survives turn teardown)", - ) - .option("--on-exit-cwd ", "Working directory for the --on-exit watched command") - .option("--stream-command ", "Stream source argv as a JSON array of strings") - .option("--stream-cwd ", "Working directory for the stream source") - .option("--stream-mode ", "Stream line selection mode (line|match)") - .option("--stream-match ", "Regex source required for stream match mode") - .option("--stream-batch-ms ", "Quiet-window batch delay in milliseconds") - .option("--stream-max-batch-bytes ", "Maximum UTF-8 bytes per stream batch") - .option( - "--tz ", - "Timezone for cron expressions (IANA; cron default: Gateway host local timezone)", - "", - ) - .option("--stagger ", "Cron stagger window (e.g. 30s, 5m)") - .option("--exact", "Disable cron staggering (set stagger to 0)", false) - .option("--trigger-script ", "Condition script file, or - for stdin") - .option("--trigger-once", "Disable after the first successful triggered run", false) - .option("--system-event ", "System event payload (main session)") - .option("--message ", "Agent message payload") - .option("--script ", "Headless script payload file, or - for stdin") - .option("--script-timeout-seconds ", "Script wall-clock timeout seconds") - .option("--script-tool-budget ", "Maximum script tool calls") - .option("--command ", "Command payload run as sh -lc on the Gateway") - .option("--command-argv ", "Command payload argv as JSON array of strings") - .option("--command-cwd ", "Working directory for command payloads") - .option( - "--command-env ", - "Environment override for command payloads (repeatable)", - (value: string, previous: string[] | undefined) => [...(previous ?? []), value], - ) - .option("--command-input ", "stdin for command payloads") - .option("--thinking ", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`) - .option("--model ", "Model override for agent jobs (provider/model or alias)") - .option("--fallbacks ", "Fallback model list for agent jobs") - .option("--timeout-seconds ", "Timeout seconds for agent or command jobs") - .option("--no-output-timeout-seconds ", "No-output timeout seconds for command jobs") - .option("--output-max-bytes ", "Maximum captured stdout/stderr bytes for command jobs") - .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) - .option("--tools ", "Tool allow-list (e.g. exec,read,write or exec read write)") - .option("--announce", "Fallback-deliver final text to a chat", false) - .option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.") - .option("--no-deliver", "Disable runner fallback delivery") - .option("--webhook ", "POST the finished payload to a webhook URL") - .option("--channel ", `Delivery channel (${getCronChannelOptions()})`, "last") - .option( - "--to ", - "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", - ) - .option("--thread-id ", "Telegram forum topic thread id") - .option("--account ", "Channel account id for delivery (multi-account setups)") - .option("--best-effort-deliver", "Do not fail the job if delivery fails", false) .action( async ( nameArg: string | undefined, @@ -176,23 +107,7 @@ export function registerCronAddCommand(cron: Command) { typeof opts.onExit === "string" || typeof opts.streamCommand === "string"; const positionalSchedule = hasScheduleFlag ? undefined : nameArg; - const schedule = resolveCronCreateScheduleFromArgs({ - at: opts.at, - cron: opts.cron, - every: opts.every, - onExit: opts.onExit, - onExitCwd: opts.onExitCwd, - streamCommand: opts.streamCommand, - streamCwd: opts.streamCwd, - streamMode: opts.streamMode, - streamMatch: opts.streamMatch, - streamBatchMs: opts.streamBatchMs, - streamMaxBatchBytes: opts.streamMaxBatchBytes, - exact: opts.exact, - positionalSchedule, - stagger: opts.stagger, - tz: opts.tz, - }); + const schedule = resolveCronCreateScheduleFromArgs({ ...opts, positionalSchedule }); const wakeMode = normalizeOptionalString(opts.wake) ?? "now"; if (wakeMode !== "now" && wakeMode !== "next-heartbeat") { diff --git a/src/cli/cron-cli/register.cron-edit.test.ts b/src/cli/cron-cli/register.cron-edit.test.ts index 58e5b63ba1dd..aa4eb2a75374 100644 --- a/src/cli/cron-cli/register.cron-edit.test.ts +++ b/src/cli/cron-cli/register.cron-edit.test.ts @@ -55,6 +55,9 @@ describe("cron edit command", () => { expect(help).toContain("--best-effort-deliver"); expect(help).toContain("--display-name "); expect(help).toContain("--clear-display-name"); + expect(help).toContain("--on-exit "); + expect(help).toContain("--on-exit-cwd "); + expect(help).toContain("main|isolated|current|session:"); expect(help).toMatch(/also\s+implies --announce when used alone/); }); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 7e1d6fddbf02..dea2ebd46c75 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -5,7 +5,6 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; -import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js"; import type { CronJob } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { danger } from "../../globals.js"; @@ -21,6 +20,7 @@ import { parseDurationMs } from "../parse-duration.js"; import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js"; import { createCronOutputCommand } from "./output-mode.js"; import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js"; +import { registerCronMutationOptions } from "./register.cron-options.js"; import { applyExistingCronSchedulePatch, applyExistingStreamSchedulePatch, @@ -57,98 +57,36 @@ async function readCronJobForEdit(opts: GatewayRpcOpts, id: string): Promise", "Job id") - .option("--name ", "Set name") - .option("--display-name ", "Set human-readable display name") + registerCronMutationOptions( + createCronOutputCommand(cron, "edit") + .description("Edit an automation (patch fields)") + .argument("", "Job id"), + "edit", + ) .option("--clear-display-name", "Restore the stable name in list and detail views", false) - .option("--description ", "Set description") .option("--enable", "Enable job", false) .option("--disable", "Disable job", false) - .option("--delete-after-run", "Delete one-shot job after it succeeds", false) - .option("--keep-after-run", "Keep one-shot job after it succeeds", false) - .option("--session ", "Session target (main|isolated)") - .option("--agent ", "Set agent id") .option("--clear-agent", "Unset agent and use default", false) - .option("--session-key ", "Set session key for job routing") .option("--clear-session-key", "Unset session key", false) - .option("--wake ", "Wake mode (now|next-heartbeat)") - .option("--at ", "Set one-shot time (ISO, offset-less uses --tz) or duration like 20m") - .option("--every ", "Set interval duration like 10m") - .option("--pacing-min ", "Set minimum delay for a dynamic next check") - .option("--pacing-max ", "Set maximum delay for a dynamic next check") .option("--clear-pacing", "Remove dynamic-cadence bounds", false) - .option("--cron ", "Set cron expression") - .option("--stream-command ", "Set stream source argv as a JSON array of strings") - .option("--stream-cwd ", "Set stream source working directory") - .option("--stream-mode ", "Set stream selection mode (line|match)") - .option("--stream-match ", "Set stream match regex source") - .option("--stream-batch-ms ", "Set stream quiet-window delay in milliseconds") - .option("--stream-max-batch-bytes ", "Set maximum UTF-8 bytes per stream batch") - .option( - "--tz ", - "Timezone for cron expressions (IANA; cron default: Gateway host local timezone)", - ) - .option("--stagger ", "Cron stagger window (e.g. 30s, 5m)") - .option("--exact", "Disable cron staggering (set stagger to 0)") - .option("--trigger-script ", "Set condition script from file, or - for stdin") - .option("--trigger-once", "Disable after the first successful triggered run", false) .option("--clear-trigger", "Remove the condition trigger", false) - .option("--system-event ", "Set systemEvent payload") - .option("--message ", "Set agentTurn payload message") - .option("--script ", "Set headless script payload from file, or - for stdin") - .option("--script-timeout-seconds ", "Set script wall-clock timeout seconds") - .option("--script-tool-budget ", "Set maximum script tool calls") - .option("--command ", "Set command payload run as sh -lc on the Gateway") - .option("--command-argv ", "Set command payload argv as JSON array of strings") - .option("--command-cwd ", "Set command payload working directory") - .option( - "--command-env ", - "Set command payload environment overrides (repeatable)", - (value: string, previous: string[] | undefined) => [...(previous ?? []), value], - ) - .option("--command-input ", "Set command payload stdin") - .option("--thinking ", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`) .option( "--clear-thinking", "Remove the per-job thinking override (restore normal cron thinking precedence)", false, ) - .option("--model ", "Model override for agent jobs") - .option("--fallbacks ", "Fallback model list for agent jobs") .option("--clear-fallbacks", "Remove per-job fallback override", false) .option( "--clear-model", "Remove the per-job model override (restore normal cron model precedence)", false, ) - .option("--timeout-seconds ", "Timeout seconds for agent or command jobs") - .option("--no-output-timeout-seconds ", "No-output timeout seconds for command jobs") - .option("--output-max-bytes ", "Maximum captured stdout/stderr bytes for command jobs") - .option("--light-context", "Enable lightweight bootstrap context for agent jobs") .option("--no-light-context", "Disable lightweight bootstrap context for agent jobs") - .option("--tools ", "Tool allow-list (e.g. exec,read,write or exec read write)") .option("--clear-tools", "Remove tool allow-list (use all tools)", false) - .option("--announce", "Fallback-deliver final text to a chat") - .option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.") - .option("--no-deliver", "Disable runner fallback delivery") - .option("--webhook ", "POST the finished payload to a webhook URL") - .option("--channel ", `Delivery channel (${getCronChannelOptions()})`) - .option( - "--to ", - "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", - ) - .option("--thread-id ", "Telegram forum topic thread id") - .option("--account ", "Channel account id for delivery (multi-account setups)") .option("--clear-channel", "Unset the delivery channel", false) .option("--clear-to", "Unset the delivery destination", false) .option("--clear-thread-id", "Unset the Telegram forum topic thread id", false) .option("--clear-account", "Unset the per-job delivery account override", false) - .option( - "--best-effort-deliver", - "Do not fail job if delivery fails (also implies --announce when used alone)", - ) .option("--no-best-effort-deliver", "Fail job when delivery fails") .option("--failure-alert", "Enable failure alerts for this job") .option("--no-failure-alert", "Disable failure alerts for this job") @@ -369,20 +307,7 @@ export function registerCronEditCommand(cron: Command) { patch.trigger = { ...existing.trigger, once: true }; } - const scheduleRequest = resolveCronEditScheduleRequest({ - at: opts.at, - cron: opts.cron, - every: opts.every, - streamCommand: opts.streamCommand, - streamCwd: opts.streamCwd, - streamMode: opts.streamMode, - streamMatch: opts.streamMatch, - streamBatchMs: opts.streamBatchMs, - streamMaxBatchBytes: opts.streamMaxBatchBytes, - exact: opts.exact, - stagger: opts.stagger, - tz: opts.tz, - }); + const scheduleRequest = resolveCronEditScheduleRequest(opts); if (scheduleRequest.kind === "direct") { if (scheduleRequest.schedule.kind === "stream") { const existing = await readExistingCronJob(); diff --git a/src/cli/cron-cli/register.cron-options.test.ts b/src/cli/cron-cli/register.cron-options.test.ts new file mode 100644 index 000000000000..b2d9c1009fb5 --- /dev/null +++ b/src/cli/cron-cli/register.cron-options.test.ts @@ -0,0 +1,81 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../../runtime.js"; + +const callGatewayFromCli = vi.fn(); + +vi.mock("../gateway-rpc.js", async () => { + const actual = await vi.importActual("../gateway-rpc.js"); + return { + ...actual, + callGatewayFromCli: (...args: Parameters) => + callGatewayFromCli(...args), + }; +}); + +const { registerCronAddCommand } = await import("./register.cron-add.js"); +const { registerCronEditCommand } = await import("./register.cron-edit.js"); + +function createMutationProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerCronAddCommand(program); + registerCronEditCommand(program); + return program; +} + +describe("shared automation mutation options", () => { + beforeEach(() => { + callGatewayFromCli.mockReset(); + callGatewayFromCli.mockResolvedValue({ ok: true }); + }); + + it("updates an existing automation to an exit-triggered schedule", async () => { + await createMutationProgram().parseAsync( + ["edit", "job-1", "--on-exit", "./watch.sh", "--on-exit-cwd", "/repo"], + { from: "user" }, + ); + + expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { + id: "job-1", + patch: { schedule: { kind: "on-exit", command: "./watch.sh", cwd: "/repo" } }, + }); + }); + + it.each([ + [["--on-exit-cwd", "/repo"], "--on-exit-cwd requires --on-exit"], + [["--on-exit", "./watch.sh", "--every", "5m"], "Choose at most one schedule change"], + ])("rejects invalid exit-triggered schedule options", async (args, message) => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined); + try { + await createMutationProgram().parseAsync(["edit", "job-1", ...args], { from: "user" }); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message)); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(callGatewayFromCli).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); + + it("keeps creation defaults out of automation edit patches", () => { + const program = createMutationProgram(); + const add = program.commands.find((command) => command.name() === "add")!; + const edit = program.commands.find((command) => command.name() === "edit")!; + const creationDefaults: Array<[string, string | boolean]> = [ + ["wake", "now"], + ["tz", ""], + ["exact", false], + ["lightContext", false], + ["announce", false], + ["channel", "last"], + ["bestEffortDeliver", false], + ]; + + for (const [name, value] of creationDefaults) { + expect(add.getOptionValue(name)).toBe(value); + expect(edit.getOptionValue(name)).toBeUndefined(); + } + }); +}); diff --git a/src/cli/cron-cli/register.cron-options.ts b/src/cli/cron-cli/register.cron-options.ts new file mode 100644 index 000000000000..2983658eb4ef --- /dev/null +++ b/src/cli/cron-cli/register.cron-options.ts @@ -0,0 +1,84 @@ +import type { Command } from "commander"; +import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js"; +import { getCronChannelOptions } from "./shared.js"; + +export function registerCronMutationOptions(command: Command, mode: "add" | "edit"): Command { + const create = mode === "add"; + return command + .option("--name ", "Job name") + .option("--display-name ", "Human-readable job label") + .option("--description ", "Job description") + .option("--delete-after-run", "Delete one-shot job after it succeeds", false) + .option("--keep-after-run", "Keep one-shot job after it succeeds", false) + .option("--agent ", "Agent id for this job") + .option("--session ", "Session target (main|isolated|current|session:)") + .option("--session-key ", "Session key for job routing") + .option("--wake ", "Wake mode (now|next-heartbeat)", create ? "now" : undefined) + .option("--at ", "One-shot time (ISO, offset-less uses --tz) or duration like 20m") + .option("--every ", "Interval duration (e.g. 10m, 1h)") + .option("--pacing-min ", "Minimum delay for a dynamic next check") + .option("--pacing-max ", "Maximum delay for a dynamic next check") + .option("--cron ", "Cron expression (5-field or 6-field with seconds)") + .option("--on-exit ", "Fire once when the watched command exits") + .option("--on-exit-cwd ", "Working directory for the --on-exit watched command") + .option("--stream-command ", "Stream source argv as a JSON array of strings") + .option("--stream-cwd ", "Working directory for the stream source") + .option("--stream-mode ", "Stream line selection mode (line|match)") + .option("--stream-match ", "Regex source required for stream match mode") + .option("--stream-batch-ms ", "Quiet-window batch delay in milliseconds") + .option("--stream-max-batch-bytes ", "Maximum UTF-8 bytes per stream batch") + .option( + "--tz ", + "Timezone for cron expressions (IANA; cron default: Gateway host local timezone)", + create ? "" : undefined, + ) + .option("--stagger ", "Cron stagger window (e.g. 30s, 5m)") + .option("--exact", "Disable cron staggering (set stagger to 0)", create ? false : undefined) + .option("--trigger-script ", "Condition script file, or - for stdin") + .option("--trigger-once", "Disable after the first successful triggered run", false) + .option("--system-event ", "System event payload (main session)") + .option("--message ", "Agent message payload") + .option("--script ", "Headless script payload file, or - for stdin") + .option("--script-timeout-seconds ", "Script wall-clock timeout seconds") + .option("--script-tool-budget ", "Maximum script tool calls") + .option("--command ", "Command payload run as sh -lc on the Gateway") + .option("--command-argv ", "Command payload argv as JSON array of strings") + .option("--command-cwd ", "Working directory for command payloads") + .option( + "--command-env ", + "Environment override for command payloads (repeatable)", + (value: string, previous: string[] | undefined) => [...(previous ?? []), value], + ) + .option("--command-input ", "stdin for command payloads") + .option("--thinking ", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`) + .option("--model ", "Model override for agent jobs (provider/model or alias)") + .option("--fallbacks ", "Fallback model list for agent jobs") + .option("--timeout-seconds ", "Timeout seconds for agent or command jobs") + .option("--no-output-timeout-seconds ", "No-output timeout seconds for command jobs") + .option("--output-max-bytes ", "Maximum captured stdout/stderr bytes for command jobs") + .option( + "--light-context", + "Use lightweight bootstrap context for agent jobs", + create ? false : undefined, + ) + .option("--tools ", "Tool allow-list (e.g. exec,read,write or exec read write)") + .option("--announce", "Fallback-deliver final text to a chat", create ? false : undefined) + .option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.") + .option("--no-deliver", "Disable runner fallback delivery") + .option("--webhook ", "POST the finished payload to a webhook URL") + .option( + "--channel ", + `Delivery channel (${getCronChannelOptions()})`, + create ? "last" : undefined, + ) + .option("--to ", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)") + .option("--thread-id ", "Telegram forum topic thread id") + .option("--account ", "Channel account id for delivery (multi-account setups)") + .option( + "--best-effort-deliver", + create + ? "Do not fail the job if delivery fails" + : "Do not fail job if delivery fails (also implies --announce when used alone)", + create ? false : undefined, + ); +} diff --git a/src/node-host/mcp.recovery.test.ts b/src/node-host/mcp.recovery.test.ts index ab1a4ee361e8..2816224f51a2 100644 --- a/src/node-host/mcp.recovery.test.ts +++ b/src/node-host/mcp.recovery.test.ts @@ -274,6 +274,7 @@ describe("node host MCP live lifecycle", () => { let maxActiveLists = 0; let listCount = 0; const pending: Array<(value: { tools: Tool[] }) => void> = []; + const refreshStarted = [createDeferred(), createDeferred()] as const; const client = createClient({ list: async () => { listCount += 1; @@ -285,6 +286,7 @@ describe("node host MCP live lifecycle", () => { try { return await new Promise<{ tools: Tool[] }>((resolve) => { pending.push(resolve); + refreshStarted[listCount - 2]?.resolve(); }); } finally { activeLists -= 1; @@ -304,13 +306,15 @@ describe("node host MCP live lifecycle", () => { ); notifyToolsChanged?.(); - await vi.waitFor(() => expect(activeLists).toBe(1)); + await refreshStarted[0].promise; + expect(activeLists).toBe(1); for (let index = 0; index < 20; index += 1) { notifyToolsChanged?.(); } expect(client.request).toHaveBeenCalledTimes(2); pending.shift()?.({ tools: [tool("middle")] }); - await vi.waitFor(() => expect(client.request).toHaveBeenCalledTimes(3)); + await refreshStarted[1].promise; + expect(client.request).toHaveBeenCalledTimes(3); expect(maxActiveLists).toBe(1); pending.shift()?.({ tools: [tool("final")] }); await vi.waitFor(() =>