diff --git a/CHANGELOG.md b/CHANGELOG.md index 332270812ca..5e01f4e7e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -600,6 +600,7 @@ Docs: https://docs.openclaw.ai - Status/Claude CLI: show `oauth (claude-cli)` for working Claude CLI OAuth runtime sessions instead of `unknown` when no local auth profile exists. Fixes #78632. Thanks @gorkem2020. - Memory search: preserve keyword-only hybrid FTS matches when vector scoring is unavailable or below the configured minimum score, so exact lexical hits are not dropped by weighted min-score filtering. - Heartbeat/async exec: remap cron-run session keys to agent-main (or `"global"` under `session.scope=global`) at the bash exec, ACP, gateway node-event, and CLI watchdog enqueue sites, and treat cron-run descendants as ephemeral for retention pruning, so async exec completion events land in the same queue the heartbeat drains instead of being stranded under the ephemeral cron-run key. Refs #52305. Thanks @Kaspre. +- Wake protocol/system event CLI: type an optional `sessionKey` on `WakeParamsSchema` and add `--session-key` to `openclaw system event` so callers can target a specific session for async-task completion relays instead of always hitting the agent's main session. Refs #52305. - Exec approvals/node: let trusted backend node invokes complete no-device Control UI approvals after the original request connection changes, while keeping node, command, cwd, env, and allow-once replay bindings enforced. Fixes #78569. Thanks @naturedogdog. - Agents/subagents: keep background completion delivery on the requester-agent handoff/queue-retry path instead of raw-sending child results directly, and strip child-result wrapper or OpenClaw runtime-context scaffolding from queued outbound retries. Fixes #78531. Thanks @EthanSK. - Sandbox: recreate cached browser bridges when JavaScript-evaluation permission changes, keep failed prune removals tracked for retry, and make cross-device directory moves copy-then-commit without partially emptying the source on failure. diff --git a/docs/cli/system.md b/docs/cli/system.md index 90b7956dda2..50a0bc23f74 100644 --- a/docs/cli/system.md +++ b/docs/cli/system.md @@ -31,14 +31,20 @@ openclaw system presence ## `system event` -Enqueue a system event on the **main** session. The next heartbeat will inject -it as a `System:` line in the prompt. Use `--mode now` to trigger the heartbeat -immediately; `next-heartbeat` waits for the next scheduled tick. +Enqueue a system event on the **main** session by default. The next heartbeat +will inject it as a `System:` line in the prompt. Use `--mode now` to trigger +the heartbeat immediately; `next-heartbeat` waits for the next scheduled tick. + +Pass `--session-key` to target a specific session (for example to relay an +async-task completion back to the channel that started it). Flags: - `--text `: required system event text. - `--mode `: `now` or `next-heartbeat` (default). +- `--session-key `: optional; target a specific agent session + instead of the agent's main session. Keys that do not belong to the + resolved agent fall back to the agent's main session. - `--json`: machine-readable output. - `--url`, `--token`, `--timeout`, `--expect-final`: shared Gateway RPC flags. diff --git a/src/cli/system-cli.test.ts b/src/cli/system-cli.test.ts index 5e915756134..b3a410adb2e 100644 --- a/src/cli/system-cli.test.ts +++ b/src/cli/system-cli.test.ts @@ -67,6 +67,38 @@ describe("system-cli", () => { expect(runtimeErrors[0]).toContain("--mode must be now or next-heartbeat"); }); + it("forwards --session-key on system event", async () => { + await runCli([ + "system", + "event", + "--text", + "ping", + "--session-key", + "agent:main:telegram:dm:42", + ]); + + expect(callGatewayFromCli).toHaveBeenCalledWith( + "wake", + expect.any(Object), + { mode: "next-heartbeat", text: "ping", sessionKey: "agent:main:telegram:dm:42" }, + { expectFinal: false }, + ); + }); + + it("omits sessionKey from payload when --session-key not provided", async () => { + await runCli(["system", "event", "--text", "ping"]); + + const [, , params] = callGatewayFromCli.mock.calls[0]!; + expect(params).not.toHaveProperty("sessionKey"); + }); + + it("treats empty --session-key as omitted", async () => { + await runCli(["system", "event", "--text", "ping", "--session-key", " "]); + + const [, , params] = callGatewayFromCli.mock.calls[0]!; + expect(params).not.toHaveProperty("sessionKey"); + }); + it.each([ { args: ["system", "heartbeat", "last"], method: "last-heartbeat", params: undefined }, { diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index 50895218651..bf8bd61cbd7 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -8,7 +8,12 @@ import { formatCliCommand } from "./command-format.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; -type SystemEventOpts = GatewayRpcOpts & { text?: string; mode?: string; json?: boolean }; +type SystemEventOpts = GatewayRpcOpts & { + text?: string; + mode?: string; + sessionKey?: string; + json?: boolean; +}; type SystemGatewayOpts = GatewayRpcOpts & { json?: boolean }; const normalizeWakeMode = (raw: unknown) => { @@ -56,6 +61,10 @@ export function registerSystemCli(program: Command) { .description("Enqueue a system event and optionally trigger a heartbeat") .requiredOption("--text ", "System event text") .option("--mode ", "Wake mode (now|next-heartbeat)", "next-heartbeat") + .option( + "--session-key ", + "Target a specific session for the event (defaults to the agent's main session)", + ) .option("--json", "Output JSON", false), ).action(async (opts: SystemEventOpts) => { await runSystemGatewayCommand( @@ -68,7 +77,13 @@ export function registerSystemCli(program: Command) { ); } const mode = normalizeWakeMode(opts.mode); - return await callGatewayFromCli("wake", opts, { mode, text }, { expectFinal: false }); + const sessionKey = normalizeOptionalString(opts.sessionKey); + return await callGatewayFromCli( + "wake", + opts, + sessionKey ? { mode, text, sessionKey } : { mode, text }, + { expectFinal: false }, + ); }, "ok", ); diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts index 437a9829844..7ccac13d852 100644 --- a/src/cron/service-contract.ts +++ b/src/cron/service-contract.ts @@ -30,5 +30,5 @@ export interface CronServiceContract { enqueueRun(id: string, mode?: CronRunMode): Promise; getJob(id: string): CronJob | undefined; getDefaultAgentId(): string | undefined; - wake(opts: { mode: CronWakeMode; text: string }): CronWakeResult; + wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult; } diff --git a/src/cron/service.ts b/src/cron/service.ts index 14f43661a33..9d74bb574d1 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -64,7 +64,7 @@ export class CronService implements CronServiceContract { return this.state.deps.defaultAgentId; } - wake(opts: { mode: "now" | "next-heartbeat"; text: string }) { + wake(opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string }) { return ops.wakeNow(this.state, opts); } } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 6c857ad870c..26fad6d43e1 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -857,7 +857,7 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du export function wakeNow( state: CronServiceState, - opts: { mode: "now" | "next-heartbeat"; text: string }, + opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string }, ) { return wake(state, opts); } diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index e452d69b5fd..244ff5dcd4c 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1781,15 +1781,21 @@ function emitJobFinished( export function wake( state: CronServiceState, - opts: { mode: "now" | "next-heartbeat"; text: string }, + opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string }, ) { const text = opts.text.trim(); if (!text) { return { ok: false } as const; } - state.deps.enqueueSystemEvent(text); + const sessionKey = opts.sessionKey?.trim() || undefined; + state.deps.enqueueSystemEvent(text, sessionKey ? { sessionKey } : undefined); if (opts.mode === "now") { - state.deps.requestHeartbeat({ source: "manual", intent: "immediate", reason: "wake" }); + state.deps.requestHeartbeat({ + source: "manual", + intent: "immediate", + reason: "wake", + ...(sessionKey ? { sessionKey } : {}), + }); } return { ok: true } as const; } diff --git a/src/cron/service/wake.test.ts b/src/cron/service/wake.test.ts new file mode 100644 index 00000000000..19e65b8a940 --- /dev/null +++ b/src/cron/service/wake.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { wake } from "./timer.js"; + +function createState() { + const enqueueSystemEvent = vi.fn(); + const requestHeartbeat = vi.fn(); + return { + state: { + deps: { + enqueueSystemEvent, + requestHeartbeat, + }, + } as unknown as Parameters[0], + enqueueSystemEvent, + requestHeartbeat, + }; +} + +describe("wake (cron timer)", () => { + it("returns ok:false on empty text without enqueueing or waking", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + expect(wake(state, { mode: "now", text: " " })).toEqual({ ok: false }); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeat).not.toHaveBeenCalled(); + }); + + it("enqueues without sessionKey when omitted", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + expect(wake(state, { mode: "now", text: "ping" })).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", undefined); + expect(requestHeartbeat).toHaveBeenCalledWith({ + source: "manual", + intent: "immediate", + reason: "wake", + }); + }); + + it("threads sessionKey to both enqueue and heartbeat on mode=now", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + expect( + wake(state, { + mode: "now", + text: "ping", + sessionKey: "agent:main:telegram:dm:42", + }), + ).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", { + sessionKey: "agent:main:telegram:dm:42", + }); + expect(requestHeartbeat).toHaveBeenCalledWith({ + source: "manual", + intent: "immediate", + reason: "wake", + sessionKey: "agent:main:telegram:dm:42", + }); + }); + + it("threads sessionKey to enqueue only on mode=next-heartbeat", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + expect( + wake(state, { + mode: "next-heartbeat", + text: "ping", + sessionKey: "agent:main:slack:42", + }), + ).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", { + sessionKey: "agent:main:slack:42", + }); + expect(requestHeartbeat).not.toHaveBeenCalled(); + }); + + it("treats whitespace-only sessionKey as omitted", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + wake(state, { mode: "now", text: "ping", sessionKey: " " }); + expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", undefined); + expect(requestHeartbeat).toHaveBeenCalledWith({ + source: "manual", + intent: "immediate", + reason: "wake", + }); + }); +}); diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 3bc19753bd6..28fdd62311d 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -218,6 +218,7 @@ export const WakeParamsSchema = Type.Object( { mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), text: NonEmptyString, + sessionKey: Type.Optional(NonEmptyString), }, { additionalProperties: true }, // external wake senders may attach opaque metadata ); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 1893694a1c4..affd9f7cb02 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -279,7 +279,13 @@ export function buildGatewayCronService(params: { resolveSessionStorePath, sessionStorePath, enqueueSystemEvent: (text, opts) => { - const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); + // When the caller passes only a sessionKey (e.g. `system event --session-key`), + // derive agentId from that key so a non-default agent's session is not silently + // rejected as foreign by resolveCronSessionKey and rerouted to the default agent. + const derivedAgentId = + opts?.agentId ?? + (opts?.sessionKey ? resolveAgentIdFromSessionKey(opts.sessionKey) : undefined); + const { agentId, cfg: runtimeConfig } = resolveCronAgent(derivedAgentId); const sessionKey = resolveCronSessionKey({ runtimeConfig, agentId, diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 5893a647caa..ab2f1e1d816 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -175,8 +175,13 @@ export const cronHandlers: GatewayRequestHandlers = { const p = params as { mode: "now" | "next-heartbeat"; text: string; + sessionKey?: string; }; - const result = context.cron.wake({ mode: p.mode, text: p.text }); + const result = context.cron.wake({ + mode: p.mode, + text: p.text, + ...(p.sessionKey ? { sessionKey: p.sessionKey } : {}), + }); respond(true, result, undefined); }, "cron.list": async ({ params, respond, context }) => { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 8167d03cd47..69a7266df7f 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -77,6 +77,7 @@ function createCronContext(currentJob?: CronJob) { update: vi.fn(async () => ({ id: "cron-1" })), getDefaultAgentId: vi.fn(() => "main"), getJob: vi.fn(() => currentJob), + wake: vi.fn(() => ({ ok: true }) as const), }, logGateway: { info: vi.fn(), @@ -647,4 +648,66 @@ describe("cron method validation", () => { ).rejects.toThrow("DB write failed"); expect(respond).not.toHaveBeenCalled(); }); + + describe("wake", () => { + async function invokeWake(params: Record) { + const context = createCronContext(); + const respond = vi.fn(); + await cronHandlers.wake({ + req: {} as never, + params: params as never, + respond: respond as never, + context: context as never, + client: null, + isWebchatConnect: () => false, + }); + return { context, respond }; + } + + it("forwards sessionKey to context.cron.wake when provided", async () => { + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: "agent:main:telegram:dm:42", + }); + expect(context.cron.wake).toHaveBeenCalledWith({ + mode: "now", + text: "ping", + sessionKey: "agent:main:telegram:dm:42", + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + + it("omits sessionKey when not provided", async () => { + const { context, respond } = await invokeWake({ + mode: "next-heartbeat", + text: "ping", + }); + expect(context.cron.wake).toHaveBeenCalledWith({ + mode: "next-heartbeat", + text: "ping", + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + + it("omits sessionKey when explicitly empty string", async () => { + const { context } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: "", + }); + // empty-string sessionKey is rejected at schema (NonEmptyString) + expect(context.cron.wake).not.toHaveBeenCalled(); + }); + + it("rejects non-string sessionKey at schema", async () => { + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: 42, + }); + expect(context.cron.wake).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + }); + }); });