From cd24da031b96719ff167e066b5df2aca8670b4ec Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Tue, 5 May 2026 18:48:06 +1000 Subject: [PATCH] feat(plugin-sdk): expose sessionTarget and agentId on cron_changed hook events (#77641) --- CHANGELOG.md | 1 + src/gateway/server-cron.test.ts | 45 +++++++++++++++++++++++++ src/gateway/server-cron.ts | 11 +++++- src/plugins/hook-types.ts | 6 ++++ src/plugins/wired-hooks-gateway.test.ts | 17 ++++++++-- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619446d5934..c1eb64d9db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -554,6 +554,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/queue: treat reset-triggered `/new` and `/reset` turns as interrupt runs across active-run queue handling, so steer/followup modes cannot delay a fresh session behind existing work. Fixes #74093. (#74144) Thanks @ruji9527 and @yelog. - Cron: persist repaired startup runtime state back to `jobs-state.json` so a valid future `nextRunAtMs` with missing `updatedAtMs` no longer triggers repeated external health-check repairs after Gateway restart. Fixes #76461. Thanks @vincentkoc. - Cron: preserve manual `cron.run` IDs in `cron.runs` history so manual run acknowledgements can be correlated with finished run records. Fixes #76276. +- Plugin SDK/cron: expose `sessionTarget` and `agentId` as top-level fields on `cron_changed` hook events so downstream plugins can route cron completion results without digging into the optional job snapshot. Thanks @amknight. - CLI/devices: request `operator.admin` for `openclaw devices approve ` only when the exact pending device request would mint or inherit admin-scoped operator access, while keeping lower-scope approvals on the pairing scope. - Memory/embedding: broaden the embedding reindex retry classifier to include transient socket-layer errors (`fetch failed`, `ECONNRESET`, `socket hang up`, `UND_ERR_*`, `closed`) so memory reindex survives provider network hiccups instead of aborting mid-run. Related #56815, #44166. (#76311) Thanks @buyitsydney. - Memory/sessions: keep rotated and deleted transcripts (`.jsonl.reset.` / `.jsonl.deleted.`) searchable by indexing archive content, mapping archive hits back to live transcript stems, emitting transcript update events on archive rotation, and bypassing incremental delta thresholds for one-shot archive mutations while keeping backups and compaction checkpoints opaque. Refs #56131. Thanks @buyitsydney. diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index b04c322d24e..233effd5780 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -150,8 +150,10 @@ describe("buildGatewayCronService", () => { expect.objectContaining({ action: "added", jobId: job.id, + sessionTarget: "main", job: expect.objectContaining({ id: job.id, + sessionTarget: "main", state: expect.objectContaining({ nextRunAtMs: job.state.nextRunAtMs }), }), }), @@ -191,9 +193,11 @@ describe("buildGatewayCronService", () => { expect.objectContaining({ action: "removed", jobId: job.id, + sessionTarget: "main", job: expect.objectContaining({ id: job.id, name: "to-be-removed", + sessionTarget: "main", }), }), expect.objectContaining({ @@ -205,6 +209,47 @@ describe("buildGatewayCronService", () => { } }); + it("cron_changed hook event includes agentId from the job", async () => { + const cfg = createCronConfig("server-cron-hook-agentId"); + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const job = await state.cron.add({ + name: "agent-scoped-job", + enabled: true, + agentId: "yinze", + schedule: { kind: "every", everyMs: 60_000, anchorMs: 1_000 }, + sessionTarget: "session:project-alpha", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "agent check" }, + }); + + expect(runCronChangedMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "added", + jobId: job.id, + sessionTarget: "session:project-alpha", + agentId: "yinze", + job: expect.objectContaining({ + id: job.id, + agentId: "yinze", + sessionTarget: "session:project-alpha", + }), + }), + expect.objectContaining({ + config: cfg, + }), + ); + } finally { + state.cron.stop(); + } + }); + it("cron_changed hook context uses runtime config from getRuntimeConfig()", async () => { const startupCfg = createCronConfig("server-cron-hook-runtime-cfg"); const runtimeCfg = { ...startupCfg, _marker: "runtime" }; diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index cfdfc9a5774..0755b016e19 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -64,6 +64,7 @@ function pickDefined>( function toPluginCronJob(job: CronJob): PluginHookGatewayCronJob { return { id: job.id, + agentId: job.agentId, name: job.name, description: job.description, enabled: job.enabled, @@ -357,10 +358,18 @@ export function buildGatewayCronService(params: { // getJob() would return undefined. `delivery` and `usage` are // intentionally omitted — they contain internal channel/token detail // that is not part of the public plugin SDK surface. + // Resolve job snapshot from the event or live service so top-level + // convenience fields (sessionTarget, agentId) are always populated + // when the job is known. + const jobSnapshot = evt.job ?? cron.getJob(evt.jobId); + const pluginJob = jobSnapshot ? toPluginCronJob(jobSnapshot) : undefined; const hookEvt: PluginHookCronChangedEvent = { action: evt.action, jobId: evt.jobId, - ...(evt.job ? { job: toPluginCronJob(evt.job) } : {}), + ...(pluginJob ? { job: pluginJob } : {}), + // Top-level routing fields so plugins don't have to dig into job. + sessionTarget: jobSnapshot?.sessionTarget, + agentId: jobSnapshot?.agentId, ...pickDefined(evt, [ "runAtMs", "durationMs", diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 996e41b88e9..31509a71e59 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -628,6 +628,8 @@ export type PluginHookGatewayCronJobState = { export type PluginHookGatewayCronJob = { id: string; + /** Agent id that owns this cron job. */ + agentId?: string; name?: string; description?: string; enabled?: boolean; @@ -662,6 +664,10 @@ export type PluginHookCronChangedEvent = { action: "added" | "updated" | "removed" | "started" | "finished"; jobId: string; job?: PluginHookGatewayCronJob; + /** Top-level session target for downstream routing (mirrors job.sessionTarget). */ + sessionTarget?: string; + /** Agent id that owns this cron job (mirrors job.agentId). */ + agentId?: string; runAtMs?: number; durationMs?: number; status?: PluginHookGatewayCronRunStatus; diff --git a/src/plugins/wired-hooks-gateway.test.ts b/src/plugins/wired-hooks-gateway.test.ts index c1ea8edbb1f..e21f8ff75c2 100644 --- a/src/plugins/wired-hooks-gateway.test.ts +++ b/src/plugins/wired-hooks-gateway.test.ts @@ -61,8 +61,12 @@ describe("gateway hook runner methods", () => { action: "updated", jobId: "job-1", nextRunAtMs: 123, + sessionTarget: "main", + agentId: "main", job: { id: "job-1", + agentId: "main", + sessionTarget: "main", state: { nextRunAtMs: 123 }, }, }; @@ -78,6 +82,8 @@ describe("gateway hook runner methods", () => { const event: PluginHookCronChangedEvent = { action: "finished", jobId: "job-2", + sessionTarget: "session:ops", + agentId: "reporter", status: "error", error: "timeout", summary: "Job timed out", @@ -91,6 +97,8 @@ describe("gateway hook runner methods", () => { provider: "openai", job: { id: "job-2", + agentId: "reporter", + sessionTarget: "session:ops", state: { lastRunStatus: "error", lastError: "timeout" }, }, }; @@ -106,13 +114,18 @@ describe("gateway hook runner methods", () => { const event: PluginHookCronChangedEvent = { action: "removed", jobId: "job-3", - job: { id: "job-3", name: "deleted-job" }, + sessionTarget: "isolated", + job: { id: "job-3", name: "deleted-job", sessionTarget: "isolated" }, }; await runner.runCronChanged(event, gatewayCtx); expect(handler).toHaveBeenCalledWith(event, gatewayCtx); - expect(handler.mock.calls[0][0].job).toEqual({ id: "job-3", name: "deleted-job" }); + expect(handler.mock.calls[0][0].job).toEqual({ + id: "job-3", + name: "deleted-job", + sessionTarget: "isolated", + }); }); it("hasHooks returns true for registered gateway hooks", () => {