From eb548e9a31740cf5d2434938b3a6d2c50d1b0ecc Mon Sep 17 00:00:00 2001 From: Moltar Date: Tue, 31 Mar 2026 08:43:22 -0400 Subject: [PATCH 1/3] fix(scheduler): list cron jobs via gateway WS RPC fallback --- api/src/services/openclawGatewayClient.js | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/api/src/services/openclawGatewayClient.js b/api/src/services/openclawGatewayClient.js index 3b92af9..22a3b58 100644 --- a/api/src/services/openclawGatewayClient.js +++ b/api/src/services/openclawGatewayClient.js @@ -664,7 +664,30 @@ async function cronList() { }); } - // Attempt 3: Read the persisted jobs.json from the workspace service + // Attempt 3: Gateway native WS RPC (works even when /tools/invoke doesn't expose cron tools) + try { + const result = await gatewayWsRpc('cron.list', { includeDisabled: true }); + if (result) { + const jobs = extractJobsArray(result); + if (jobs.length > 0) { + logger.info('cron.list returned jobs via WS RPC', { + count: jobs.length, + }); + return jobs; + } + } + } catch (error) { + if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') { + logger.warn('OpenClaw gateway not available for cron.list WS RPC, returning empty array'); + return []; + } + logger.warn('cron.list WS RPC failed, trying jobs.json fallback', { + error: error.message, + code: error.code, + }); + } + + // Attempt 4: Read the persisted jobs.json from the workspace service // OpenClaw stores cron jobs at ~/.openclaw/cron/jobs.json on the gateway host. // In containerized setups this is typically at /home/node/.openclaw/cron/jobs.json // which may be accessible via the workspace service. From 405e62c10cfdc6a80ed1cd8ab34e1aae5cd2cb90 Mon Sep 17 00:00:00 2001 From: Moltar Date: Tue, 31 Mar 2026 09:29:45 -0400 Subject: [PATCH 2/3] fix(scheduler): keep jobs.json fallback when WS cron.list is unavailable --- .../__tests__/openclawGatewayClient.test.js | 2 +- api/src/services/openclawGatewayClient.js | 48 +++++++++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/api/src/services/__tests__/openclawGatewayClient.test.js b/api/src/services/__tests__/openclawGatewayClient.test.js index e7e3dbc..772aa33 100644 --- a/api/src/services/__tests__/openclawGatewayClient.test.js +++ b/api/src/services/__tests__/openclawGatewayClient.test.js @@ -933,7 +933,7 @@ describe('openclawGatewayClient', () => { expect(result).toEqual([{ id: '1' }, { id: '2' }]); expect(getFileContent).toHaveBeenCalledWith('/cron/jobs.json'); expect(logger.warn).toHaveBeenCalledWith( - 'cron.list tool invocation failed, trying jobs.json fallback', + 'cron.list tool invocation failed, trying WS RPC fallback', expect.any(Object), ); }); diff --git a/api/src/services/openclawGatewayClient.js b/api/src/services/openclawGatewayClient.js index 22a3b58..9e6e29e 100644 --- a/api/src/services/openclawGatewayClient.js +++ b/api/src/services/openclawGatewayClient.js @@ -611,8 +611,8 @@ async function sessionsHistory({ sessionKey, limit, includeTools } = {}) { /** * List cron jobs from the OpenClaw Gateway scheduler. - * Tries the cron.list tool first (via /tools/invoke), then falls back to - * reading the persisted jobs.json from the workspace service. + * Tries /tools/invoke first (cron, then cron.list), then native WS RPC + * (cron.list), and finally falls back to persisted jobs.json via workspace. * @returns {Promise} Array of cron job objects */ async function cronList() { @@ -631,13 +631,15 @@ async function cronList() { } } catch (error) { if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') { - logger.warn('OpenClaw gateway not available for cron list, returning empty array'); - return []; + logger.warn('OpenClaw gateway not available for cron tool invoke, trying legacy cron.list', { + code: error.code, + }); + } else { + logger.warn('cron tool invocation failed, trying legacy cron.list', { + error: error.message, + code: error.code, + }); } - logger.warn('cron tool invocation failed, trying legacy cron.list', { - error: error.message, - code: error.code, - }); } // Attempt 2: Backward-compatible legacy tool name @@ -654,14 +656,16 @@ async function cronList() { } } catch (error) { if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') { - logger.warn('OpenClaw gateway not available for cron.list, returning empty array'); - return []; + logger.warn('OpenClaw gateway not available for cron.list invoke, trying WS RPC fallback', { + code: error.code, + }); + } else { + // Log and fall through to WS RPC fallback + logger.warn('cron.list tool invocation failed, trying WS RPC fallback', { + error: error.message, + code: error.code, + }); } - // Log and fall through to fallback - logger.warn('cron.list tool invocation failed, trying jobs.json fallback', { - error: error.message, - code: error.code, - }); } // Attempt 3: Gateway native WS RPC (works even when /tools/invoke doesn't expose cron tools) @@ -678,13 +682,15 @@ async function cronList() { } } catch (error) { if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') { - logger.warn('OpenClaw gateway not available for cron.list WS RPC, returning empty array'); - return []; + logger.warn('OpenClaw gateway not available for cron.list WS RPC, trying jobs.json fallback', { + code: error.code, + }); + } else { + logger.warn('cron.list WS RPC failed, trying jobs.json fallback', { + error: error.message, + code: error.code, + }); } - logger.warn('cron.list WS RPC failed, trying jobs.json fallback', { - error: error.message, - code: error.code, - }); } // Attempt 4: Read the persisted jobs.json from the workspace service From 469f0cb1fce85263c16c11caf8efe8bbc8e1e9b7 Mon Sep 17 00:00:00 2001 From: Moltar Date: Tue, 31 Mar 2026 09:45:08 -0400 Subject: [PATCH 3/3] fix(scheduler): improve WS fallback diagnostics and add WS fallback test --- .../__tests__/openclawGatewayClient.test.js | 58 +++++++++++++++++++ api/src/services/openclawGatewayClient.js | 13 +++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/api/src/services/__tests__/openclawGatewayClient.test.js b/api/src/services/__tests__/openclawGatewayClient.test.js index 772aa33..496ff95 100644 --- a/api/src/services/__tests__/openclawGatewayClient.test.js +++ b/api/src/services/__tests__/openclawGatewayClient.test.js @@ -921,6 +921,64 @@ describe('openclawGatewayClient', () => { expect(result).toEqual([]); }); + it('should return jobs via WS RPC fallback when tool invoke paths are unavailable', async () => { + jest.useRealTimers(); + const deviceAuth = configureDeviceAuth(); + + // Both /tools/invoke attempts return 404 (non-retryable), forcing WS RPC fallback. + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + text: async () => + JSON.stringify({ ok: false, error: { type: 'not_found', message: 'Tool not available' } }), + }); + + mockWebSocket.send.mockImplementation((payload) => { + const message = JSON.parse(payload); + if (message.method === 'connect') { + originalSetTimeout(() => { + emitWs( + 'message', + JSON.stringify({ type: 'res', id: message.id, ok: true, payload: {} }), + ); + }, 0); + return; + } + if (message.method === 'cron.list') { + originalSetTimeout(() => { + emitWs( + 'message', + JSON.stringify({ + type: 'res', + id: message.id, + ok: true, + payload: { jobs: [{ id: 'ws-1' }, { id: 'ws-2' }] }, + }), + ); + }, 0); + } + }); + + const promise = cronList({ wsRpcOptions: { deviceAuth } }); + + // cronList performs two invoke-tool attempts first; emit WS handshake/events after a tick. + originalSetTimeout(() => { + emitWs('open'); + emitWs( + 'message', + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'nonce-123' }, + }), + ); + }, 10); + + await expect(promise).resolves.toEqual([{ id: 'ws-1' }, { id: 'ws-2' }]); + expect(getFileContent).not.toHaveBeenCalled(); + jest.useFakeTimers({ legacyFakeTimers: false }); + }, 10000); + it('should fallback to jobs.json when cron.list fails', async () => { const error = new Error('Tool failed'); global.fetch.mockRejectedValueOnce(error); diff --git a/api/src/services/openclawGatewayClient.js b/api/src/services/openclawGatewayClient.js index 9e6e29e..a3d7ba0 100644 --- a/api/src/services/openclawGatewayClient.js +++ b/api/src/services/openclawGatewayClient.js @@ -615,7 +615,8 @@ async function sessionsHistory({ sessionKey, limit, includeTools } = {}) { * (cron.list), and finally falls back to persisted jobs.json via workspace. * @returns {Promise} Array of cron job objects */ -async function cronList() { +async function cronList(options = {}) { + const wsRpcOptions = options?.wsRpcOptions || {}; // Attempt 1: Try modern cron tool contract via /tools/invoke // Tool schema: tool='cron' with args={ action: 'list', includeDisabled: true } try { @@ -670,7 +671,7 @@ async function cronList() { // Attempt 3: Gateway native WS RPC (works even when /tools/invoke doesn't expose cron tools) try { - const result = await gatewayWsRpc('cron.list', { includeDisabled: true }); + const result = await gatewayWsRpc('cron.list', { includeDisabled: true }, wsRpcOptions); if (result) { const jobs = extractJobsArray(result); if (jobs.length > 0) { @@ -683,12 +684,16 @@ async function cronList() { } catch (error) { if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') { logger.warn('OpenClaw gateway not available for cron.list WS RPC, trying jobs.json fallback', { - code: error.code, + code: error.code ?? error.rpcCode, + rpcCode: error.rpcCode, + rpcDetails: error.rpcDetails, }); } else { logger.warn('cron.list WS RPC failed, trying jobs.json fallback', { error: error.message, - code: error.code, + code: error.code ?? error.rpcCode, + rpcCode: error.rpcCode, + rpcDetails: error.rpcDetails, }); } }