feat(plugin-sdk): expose sessionTarget and agentId on cron_changed hook events (#77641)

This commit is contained in:
Alex Knight
2026-05-05 18:48:06 +10:00
committed by GitHub
parent d862e90793
commit cd24da031b
5 changed files with 77 additions and 3 deletions

View File

@@ -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 <requestId>` 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.<iso>` / `.jsonl.deleted.<iso>`) 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.

View File

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

View File

@@ -64,6 +64,7 @@ function pickDefined<T extends Record<string, unknown>>(
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",

View File

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

View File

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