Skip to content

Commit 7877182

Browse files
committed
fix(gateway): defer missed cron agent startup work
1 parent 1a936f2 commit 7877182

9 files changed

Lines changed: 232 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Docs: https://docs.openclaw.ai
1717
### Fixes
1818

1919
- Browser/gateway: ignore Playwright dialog-close races from `Page.handleJavaScriptDialog` so browser automation no longer crashes the Gateway when a dialog disappears before Playwright accepts it. (#40067) Thanks @randyjtw.
20+
- Cron/Gateway: defer missed isolated agent-turn catch-up out of the channel startup window, so overdue cron work cannot starve Discord or Telegram while providers connect after a restart. Thanks @vincentkoc.
21+
- Plugins/runtime-deps: prune stale `openclaw-unknown-*` bundled runtime dependency roots during Gateway startup while keeping recent or locked roots, so old staging debris cannot keep growing across restarts. Thanks @vincentkoc.
2022
- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus.
2123
- Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc.
2224
- CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator.

docs/automation/cron-jobs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
4545
- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
4646
- When `jobs.json` is edited while the Gateway is running or stopped, OpenClaw compares the changed schedule fields with pending runtime slot metadata and clears stale `nextRunAtMs` values. Pure formatting or key-order-only rewrites preserve the pending slot.
4747
- All cron executions create [background task](/automation/tasks) records.
48+
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
4849
- One-shot jobs (`--at`) auto-delete after success by default.
4950
- Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.
5051
- Isolated cron runs also guard against stale acknowledgement replies. If the first result is just an interim status update (`on it`, `pulling everything together`, and similar hints) and no descendant subagent run is still responsible for the final answer, OpenClaw re-prompts once for the actual result before delivery.

src/cron/service.restart-catchup.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,24 @@ describe("CronService restart catch-up", () => {
2323
enqueueSystemEvent: ReturnType<typeof vi.fn>;
2424
requestHeartbeatNow: ReturnType<typeof vi.fn>;
2525
onEvent?: ReturnType<typeof vi.fn>;
26+
nowMs?: () => number;
27+
runIsolatedAgentJob?: ReturnType<typeof vi.fn>;
28+
startupDeferredMissedAgentJobDelayMs?: number;
2629
}) {
2730
return new CronService({
2831
storePath: params.storePath,
2932
cronEnabled: true,
3033
log: noopLogger,
34+
...(params.nowMs ? { nowMs: params.nowMs } : {}),
3135
enqueueSystemEvent: params.enqueueSystemEvent as never,
3236
requestHeartbeatNow: params.requestHeartbeatNow as never,
33-
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })) as never,
37+
runIsolatedAgentJob:
38+
(params.runIsolatedAgentJob as never) ??
39+
(vi.fn(async () => ({ status: "ok" as const })) as never),
3440
onEvent: params.onEvent as ((evt: CronEvent) => void) | undefined,
41+
...(params.startupDeferredMissedAgentJobDelayMs !== undefined
42+
? { startupDeferredMissedAgentJobDelayMs: params.startupDeferredMissedAgentJobDelayMs }
43+
: {}),
3544
});
3645
}
3746

@@ -121,6 +130,55 @@ describe("CronService restart catch-up", () => {
121130
);
122131
});
123132

133+
it("defers overdue isolated agent-turn jobs during gateway startup", async () => {
134+
const store = await makeStorePath();
135+
const startNow = Date.parse("2025-12-13T17:00:00.000Z");
136+
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const }));
137+
const enqueueSystemEvent = vi.fn();
138+
const requestHeartbeatNow = vi.fn();
139+
140+
await writeStoreJobs(store.storePath, [
141+
{
142+
id: "startup-isolated-agent",
143+
name: "startup isolated agent",
144+
enabled: true,
145+
createdAtMs: startNow - 120_000,
146+
updatedAtMs: startNow - 120_000,
147+
schedule: { kind: "every", everyMs: 60_000, anchorMs: startNow - 120_000 },
148+
sessionTarget: "isolated",
149+
wakeMode: "next-heartbeat",
150+
payload: { kind: "agentTurn", message: "do work" },
151+
state: { nextRunAtMs: startNow - 60_000 },
152+
},
153+
]);
154+
155+
const cron = createRestartCronService({
156+
storePath: store.storePath,
157+
enqueueSystemEvent,
158+
requestHeartbeatNow,
159+
runIsolatedAgentJob,
160+
nowMs: () => startNow,
161+
startupDeferredMissedAgentJobDelayMs: 120_000,
162+
});
163+
164+
try {
165+
await cron.start();
166+
167+
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
168+
expect(enqueueSystemEvent).not.toHaveBeenCalled();
169+
expect(requestHeartbeatNow).not.toHaveBeenCalled();
170+
171+
const listedJobs = await cron.list({ includeDisabled: true });
172+
const updated = listedJobs.find((job) => job.id === "startup-isolated-agent");
173+
expect(updated?.state.lastStatus).toBeUndefined();
174+
expect(updated?.state.runningAtMs).toBeUndefined();
175+
expect(updated?.state.nextRunAtMs).toBe(startNow + 120_000);
176+
} finally {
177+
cron.stop();
178+
await store.cleanup();
179+
}
180+
});
181+
124182
it("marks interrupted recurring jobs failed instead of replaying them on startup", async () => {
125183
const dueAt = Date.parse("2025-12-13T16:00:00.000Z");
126184
const staleRunningAt = Date.parse("2025-12-13T16:30:00.000Z");

src/cron/service/ops.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export async function start(state: CronServiceState) {
167167

168168
await runMissedJobs(state, {
169169
skipJobIds: interruptedJobIds.size > 0 ? interruptedJobIds : undefined,
170+
deferAgentTurnJobs: true,
170171
});
171172

172173
await locked(state, async () => {

src/cron/service/state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export type CronServiceDeps = {
6464
* See: https://github.com/openclaw/openclaw/issues/18892
6565
*/
6666
maxMissedJobsPerRestart?: number;
67+
/**
68+
* Delay before replaying missed agent-turn jobs found during gateway startup.
69+
* Keeps model/tool bootstrap work out of the channel connect window.
70+
*/
71+
startupDeferredMissedAgentJobDelayMs?: number;
6772
enqueueSystemEvent: (
6873
text: string,
6974
opts?: { agentId?: string; sessionKey?: string; contextKey?: string; trusted?: boolean },

src/cron/service/timer.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const MIN_REFIRE_GAP_MS = 2_000;
5353

5454
const DEFAULT_MISSED_JOB_STAGGER_MS = 5_000;
5555
const DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5;
56+
const DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS = 2 * 60_000;
5657
const DEFAULT_FAILURE_ALERT_AFTER = 2;
5758
const DEFAULT_FAILURE_ALERT_COOLDOWN_MS = 60 * 60_000; // 1 hour
5859

@@ -82,9 +83,14 @@ type StartupCatchupCandidate = {
8283
job: CronJob;
8384
};
8485

86+
type StartupDeferredJob = {
87+
jobId: string;
88+
delayMs?: number;
89+
};
90+
8591
type StartupCatchupPlan = {
8692
candidates: StartupCatchupCandidate[];
87-
deferredJobIds: string[];
93+
deferredJobs: StartupDeferredJob[];
8894
};
8995

9096
export async function executeJobCoreWithTimeout(
@@ -1038,10 +1044,10 @@ function collectRunnableJobs(
10381044

10391045
export async function runMissedJobs(
10401046
state: CronServiceState,
1041-
opts?: { skipJobIds?: ReadonlySet<string> },
1047+
opts?: { skipJobIds?: ReadonlySet<string>; deferAgentTurnJobs?: boolean },
10421048
) {
10431049
const plan = await planStartupCatchup(state, opts);
1044-
if (plan.candidates.length === 0 && plan.deferredJobIds.length === 0) {
1050+
if (plan.candidates.length === 0 && plan.deferredJobs.length === 0) {
10451051
return;
10461052
}
10471053

@@ -1051,7 +1057,7 @@ export async function runMissedJobs(
10511057

10521058
async function planStartupCatchup(
10531059
state: CronServiceState,
1054-
opts?: { skipJobIds?: ReadonlySet<string> },
1060+
opts?: { skipJobIds?: ReadonlySet<string>; deferAgentTurnJobs?: boolean },
10551061
): Promise<StartupCatchupPlan> {
10561062
const maxImmediate = Math.max(
10571063
0,
@@ -1060,7 +1066,7 @@ async function planStartupCatchup(
10601066
return locked(state, async () => {
10611067
await ensureLoaded(state, { skipRecompute: true });
10621068
if (!state.store) {
1063-
return { candidates: [], deferredJobIds: [] };
1069+
return { candidates: [], deferredJobs: [] };
10641070
}
10651071

10661072
const now = state.deps.nowMs();
@@ -1070,13 +1076,28 @@ async function planStartupCatchup(
10701076
allowCronMissedRunByLastRun: true,
10711077
});
10721078
if (missed.length === 0) {
1073-
return { candidates: [], deferredJobIds: [] };
1079+
return { candidates: [], deferredJobs: [] };
10741080
}
10751081
const sorted = missed.toSorted(
10761082
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
10771083
);
1078-
const startupCandidates = sorted.slice(0, maxImmediate);
1079-
const deferred = sorted.slice(maxImmediate);
1084+
const deferredAgentJobs = opts?.deferAgentTurnJobs
1085+
? sorted.filter((job) => job.payload.kind === "agentTurn")
1086+
: [];
1087+
const startupEligible = opts?.deferAgentTurnJobs
1088+
? sorted.filter((job) => job.payload.kind !== "agentTurn")
1089+
: sorted;
1090+
const startupCandidates = startupEligible.slice(0, maxImmediate);
1091+
const deferredOverflow = startupEligible.slice(maxImmediate);
1092+
const deferredAgentDelayMs = Math.max(
1093+
0,
1094+
state.deps.startupDeferredMissedAgentJobDelayMs ??
1095+
DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS,
1096+
);
1097+
const deferred: StartupDeferredJob[] = [
1098+
...deferredOverflow.map((job) => ({ jobId: job.id })),
1099+
...deferredAgentJobs.map((job) => ({ jobId: job.id, delayMs: deferredAgentDelayMs })),
1100+
];
10801101
if (deferred.length > 0) {
10811102
state.deps.log.info(
10821103
{
@@ -1087,6 +1108,16 @@ async function planStartupCatchup(
10871108
"cron: staggering missed jobs to prevent gateway overload",
10881109
);
10891110
}
1111+
if (deferredAgentJobs.length > 0) {
1112+
state.deps.log.info(
1113+
{
1114+
count: deferredAgentJobs.length,
1115+
jobIds: deferredAgentJobs.map((job) => job.id),
1116+
delayMs: deferredAgentDelayMs,
1117+
},
1118+
"cron: deferring missed agent jobs until after gateway startup",
1119+
);
1120+
}
10901121
if (startupCandidates.length > 0) {
10911122
state.deps.log.info(
10921123
{ count: startupCandidates.length, jobIds: startupCandidates.map((j) => j.id) },
@@ -1101,7 +1132,7 @@ async function planStartupCatchup(
11011132

11021133
return {
11031134
candidates: startupCandidates.map((job) => ({ jobId: job.id, job })),
1104-
deferredJobIds: deferred.map((job) => job.id),
1135+
deferredJobs: deferred,
11051136
};
11061137
});
11071138
}
@@ -1182,14 +1213,20 @@ async function applyStartupCatchupOutcomes(
11821213
applyOutcomeToStoredJob(state, result);
11831214
}
11841215

1185-
if (plan.deferredJobIds.length > 0) {
1216+
if (plan.deferredJobs.length > 0) {
11861217
const baseNow = state.deps.nowMs();
11871218
let offset = staggerMs;
1188-
for (const jobId of plan.deferredJobIds) {
1219+
for (const deferred of plan.deferredJobs) {
1220+
const jobId = deferred.jobId;
11891221
const job = state.store.jobs.find((entry) => entry.id === jobId);
11901222
if (!job || !isJobEnabled(job)) {
11911223
continue;
11921224
}
1225+
if (typeof deferred.delayMs === "number") {
1226+
job.state.nextRunAtMs = baseNow + deferred.delayMs + offset - staggerMs;
1227+
offset += staggerMs;
1228+
continue;
1229+
}
11931230
job.state.nextRunAtMs = baseNow + offset;
11941231
offset += staggerMs;
11951232
}

src/gateway/server-startup-plugins.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
77
import {
8+
pruneUnknownBundledRuntimeDepsRoots,
89
repairBundledRuntimeDepsInstallRootAsync,
910
resolveBundledRuntimeDependencyPackageInstallRoot,
1011
scanBundledPluginRuntimeDeps,
@@ -54,6 +55,15 @@ async function prestageGatewayBundledRuntimeDeps(params: {
5455
if (!packageRoot) {
5556
return;
5657
}
58+
const pruned = pruneUnknownBundledRuntimeDepsRoots({
59+
env: process.env,
60+
warn: (message) => params.log.warn(`[plugins] ${message}`),
61+
});
62+
if (pruned.removed > 0) {
63+
params.log.info(
64+
`[plugins] pruned stale bundled runtime deps roots (${pruned.removed} removed, ${pruned.skippedLocked} locked, ${pruned.scanned} scanned)`,
65+
);
66+
}
5767
let scanResult: ReturnType<typeof scanBundledPluginRuntimeDeps>;
5868
try {
5969
scanResult = scanBundledPluginRuntimeDeps({

src/plugins/bundled-runtime-deps.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
installBundledRuntimeDepsAsync,
2121
isWritableDirectory,
2222
materializeBundledRuntimeMirrorDistFile,
23+
pruneUnknownBundledRuntimeDepsRoots,
2324
repairBundledRuntimeDepsInstallRootAsync,
2425
resolveBundledRuntimeDependencyInstallRoot,
2526
resolveBundledRuntimeDependencyInstallRootPlan,
@@ -1887,6 +1888,44 @@ describe("ensureBundledPluginRuntimeDeps", () => {
18871888
expect(path.basename(resolved).startsWith("openclaw-unknown-")).toBe(false);
18881889
});
18891890

1891+
it("prunes stale unknown external runtime roots while keeping newest and locked roots", () => {
1892+
const stageDir = makeTempDir();
1893+
const nowMs = Date.parse("2026-04-29T08:00:00.000Z");
1894+
const makeRoot = (name: string, ageMs: number, locked = false) => {
1895+
const root = path.join(stageDir, name);
1896+
fs.mkdirSync(root, { recursive: true });
1897+
fs.writeFileSync(path.join(root, "marker"), "ok\n");
1898+
if (locked) {
1899+
const lockDir = path.join(root, ".openclaw-runtime-deps.lock");
1900+
fs.mkdirSync(lockDir, { recursive: true });
1901+
fs.writeFileSync(
1902+
path.join(lockDir, "owner.json"),
1903+
JSON.stringify({ pid: process.pid, createdAtMs: nowMs }),
1904+
);
1905+
}
1906+
const mtime = new Date(nowMs - ageMs);
1907+
fs.utimesSync(root, mtime, mtime);
1908+
return root;
1909+
};
1910+
const newest = makeRoot("openclaw-unknown-newest", 1_000);
1911+
const stale = makeRoot("openclaw-unknown-stale", 120_000);
1912+
const locked = makeRoot("openclaw-unknown-locked", 120_000, true);
1913+
const versioned = makeRoot("openclaw-2026.4.25-versioned", 120_000);
1914+
1915+
const result = pruneUnknownBundledRuntimeDepsRoots({
1916+
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
1917+
nowMs,
1918+
maxRootsToKeep: 1,
1919+
minAgeMs: 60_000,
1920+
});
1921+
1922+
expect(result).toEqual({ scanned: 3, removed: 1, skippedLocked: 1 });
1923+
expect(fs.existsSync(newest)).toBe(true);
1924+
expect(fs.existsSync(stale)).toBe(false);
1925+
expect(fs.existsSync(locked)).toBe(true);
1926+
expect(fs.existsSync(versioned)).toBe(true);
1927+
});
1928+
18901929
it("links source-checkout runtime deps from the cache instead of copying them", () => {
18911930
const packageRoot = makeTempDir();
18921931
fs.writeFileSync(

0 commit comments

Comments
 (0)