openclaw-scheduler 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/INSTALL-ADDITIONAL-HOST.md +1 -1
- package/INSTALL-LINUX.md +1 -1
- package/INSTALL-WINDOWS.md +1 -1
- package/INSTALL.md +1 -1
- package/JOB-QUICK-REF.md +2 -0
- package/README.md +5 -5
- package/cli.js +9 -1
- package/dispatch/529-recovery.mjs +21 -2
- package/dispatch/completion.mjs +49 -0
- package/dispatch/index.mjs +179 -11
- package/dispatch/watcher.mjs +78 -9
- package/dispatcher-strategies.js +121 -72
- package/dispatcher.js +4 -2
- package/docs/gateway-contract.md +21 -0
- package/gateway.js +140 -30
- package/index.d.ts +5 -0
- package/jobs.js +23 -8
- package/migrate-consolidate.js +6 -2
- package/package.json +3 -3
- package/paths.js +43 -1
- package/scheduler-schema.js +2 -0
- package/schema.sql +6 -1
- package/setup.mjs +24 -22
package/migrate-consolidate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* migrate-consolidate.js -- Single idempotent migration for existing databases
|
|
3
3
|
*
|
|
4
|
-
* Brings any DB from any prior version up to the current schema (
|
|
4
|
+
* Brings any DB from any prior version up to the current schema (v24).
|
|
5
5
|
* Fresh installs get everything from schema.sql directly -- this only
|
|
6
6
|
* runs ALTER TABLEs needed for DBs created before the current schema.
|
|
7
7
|
*
|
|
@@ -58,6 +58,7 @@ export default function migrateConsolidate() {
|
|
|
58
58
|
'max_trigger_fanout', 'output_store_limit_bytes',
|
|
59
59
|
'output_excerpt_limit_bytes', 'output_summary_limit_bytes',
|
|
60
60
|
'output_offload_threshold_bytes', 'ttl_hours', 'auth_profile',
|
|
61
|
+
'payload_model_fallback', 'auth_profile_fallback',
|
|
61
62
|
'schedule_kind', 'schedule_at', 'delivery_channel', 'delivery_to',
|
|
62
63
|
'delivery_opt_out_reason', 'origin', 'parent_id', 'created_at',
|
|
63
64
|
'updated_at', 'delete_after_run', 'next_run_at', 'last_run_at',
|
|
@@ -137,7 +138,7 @@ export default function migrateConsolidate() {
|
|
|
137
138
|
`).get()?.cnt ?? 0)
|
|
138
139
|
: 0;
|
|
139
140
|
if (
|
|
140
|
-
current >=
|
|
141
|
+
current >= 24
|
|
141
142
|
&& hasLatestColumns
|
|
142
143
|
&& legacyAtIsoCount === 0
|
|
143
144
|
&& legacyPayloadMismatchCount === 0
|
|
@@ -345,6 +346,9 @@ export default function migrateConsolidate() {
|
|
|
345
346
|
`ALTER TABLE runs ADD COLUMN credential_handoff_summary TEXT DEFAULT NULL`,
|
|
346
347
|
// v23: child credential policy
|
|
347
348
|
`ALTER TABLE jobs ADD COLUMN child_credential_policy TEXT DEFAULT NULL`,
|
|
349
|
+
// v24: explicit fallback model/auth selection
|
|
350
|
+
`ALTER TABLE jobs ADD COLUMN payload_model_fallback TEXT`,
|
|
351
|
+
`ALTER TABLE jobs ADD COLUMN auth_profile_fallback TEXT DEFAULT NULL`,
|
|
348
352
|
];
|
|
349
353
|
|
|
350
354
|
for (const sql of alters) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-scheduler",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"./package.json": "./package.json"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": "
|
|
20
|
+
"node": "22.x || 24.x || 26.x"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"start": "node dispatcher.js",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
"homepage": "https://github.com/amittell/openclaw-scheduler#readme",
|
|
125
125
|
"dependencies": {
|
|
126
|
-
"better-sqlite3": "^
|
|
126
|
+
"better-sqlite3": "^12.10.0",
|
|
127
127
|
"croner": "^10.0.1"
|
|
128
128
|
},
|
|
129
129
|
"devDependencies": {
|
package/paths.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, mkdirSync } from 'fs';
|
|
2
|
-
import { homedir } from 'os';
|
|
2
|
+
import { homedir, tmpdir } from 'os';
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
|
|
@@ -25,6 +25,17 @@ function isNodeModulesInstall(moduleDir) {
|
|
|
25
25
|
return /[\\/]node_modules[\\/](?:@[^\\/]+[\\/])?openclaw-scheduler(?:[\\/]|$)/.test(moduleDir);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function isUsableWorkingDirectory(dirPath) {
|
|
29
|
+
const candidate = firstNonEmpty(dirPath);
|
|
30
|
+
if (!candidate) return false;
|
|
31
|
+
try {
|
|
32
|
+
accessSync(candidate, constants.R_OK | constants.X_OK);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
export function resolveSchedulerHome(env = process.env) {
|
|
29
40
|
const explicitHome = firstNonEmpty(env.SCHEDULER_HOME);
|
|
30
41
|
if (explicitHome) return explicitHome;
|
|
@@ -62,6 +73,37 @@ export function resolveBackupStagingDir(env = process.env) {
|
|
|
62
73
|
return join(resolveSchedulerHome(env), '.backup-staging');
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
export function resolveServiceWorkingDirectory(params = {}) {
|
|
77
|
+
const env = params.env || process.env;
|
|
78
|
+
const explicitPath = firstNonEmpty(params.explicitPath);
|
|
79
|
+
if (explicitPath) {
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(explicitPath, { recursive: true });
|
|
82
|
+
if (isUsableWorkingDirectory(explicitPath)) return explicitPath;
|
|
83
|
+
} catch {
|
|
84
|
+
// Fall through to install-root/scheduler-home heuristics.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const moduleDir = firstNonEmpty(params.moduleDir) || __dirname;
|
|
89
|
+
if (!isNodeModulesInstall(moduleDir) && isUsableWorkingDirectory(moduleDir)) {
|
|
90
|
+
return moduleDir;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const schedulerHome = resolveSchedulerHome(env);
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(schedulerHome, { recursive: true });
|
|
96
|
+
if (isUsableWorkingDirectory(schedulerHome)) return schedulerHome;
|
|
97
|
+
} catch {
|
|
98
|
+
// Fall through to other safe directories.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const home = firstNonEmpty(env.HOME) || homedir();
|
|
102
|
+
if (isUsableWorkingDirectory(home)) return home;
|
|
103
|
+
|
|
104
|
+
return tmpdir();
|
|
105
|
+
}
|
|
106
|
+
|
|
65
107
|
export function resolveArtifactsDir(params = {}) {
|
|
66
108
|
const env = params.env || process.env;
|
|
67
109
|
const explicit = firstNonEmpty(params.explicitPath) || firstNonEmpty(env.SCHEDULER_ARTIFACTS_DIR);
|
package/scheduler-schema.js
CHANGED
|
@@ -11,6 +11,7 @@ export const SCHEDULER_SCHEMAS = {
|
|
|
11
11
|
payload_kind: { type: 'string', enum: ['systemEvent', 'agentTurn', 'shellCommand'] },
|
|
12
12
|
payload_message: { type: 'string', maxLength: 100000 },
|
|
13
13
|
payload_model: { type: 'string', nullable: true },
|
|
14
|
+
payload_model_fallback: { type: 'string', nullable: true, description: 'Optional fallback model override for a same-run retry after primary selection failure' },
|
|
14
15
|
payload_thinking: { type: 'string', nullable: true },
|
|
15
16
|
payload_timeout_seconds: { type: 'integer', min: 1, default: 120 },
|
|
16
17
|
execution_intent: { type: 'string', enum: ['execute', 'plan'], default: 'execute' },
|
|
@@ -45,6 +46,7 @@ export const SCHEDULER_SCHEMAS = {
|
|
|
45
46
|
output_offload_threshold_bytes: { type: 'integer', min: 128, default: 65536 },
|
|
46
47
|
preferred_session_key: { type: 'string', nullable: true },
|
|
47
48
|
auth_profile: { type: 'string', nullable: true, description: 'Auth profile override: null=default, "inherit"=main session profile, or "provider:label"' },
|
|
49
|
+
auth_profile_fallback: { type: 'string', nullable: true, description: 'Optional fallback auth profile for a same-run retry after primary selection failure' },
|
|
48
50
|
delivery_opt_out_reason: { type: 'string', nullable: true, maxLength: 256 },
|
|
49
51
|
delete_after_run: { type: 'boolean', default: false },
|
|
50
52
|
run_now: { type: 'boolean', default: false, note: 'create-time convenience flag' },
|
package/schema.sql
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
-- OpenClaw Scheduler Schema (current: v1.7.0, schema version:
|
|
1
|
+
-- OpenClaw Scheduler Schema (current: v1.7.0, schema version: 24)
|
|
2
2
|
-- Full standalone scheduler + message router
|
|
3
3
|
|
|
4
4
|
-- ============================================================
|
|
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
23
23
|
payload_kind TEXT NOT NULL, -- 'systemEvent' | 'agentTurn' | 'shellCommand'
|
|
24
24
|
payload_message TEXT NOT NULL,
|
|
25
25
|
payload_model TEXT,
|
|
26
|
+
payload_model_fallback TEXT,
|
|
26
27
|
payload_thinking TEXT,
|
|
27
28
|
payload_timeout_seconds INTEGER DEFAULT 120,
|
|
28
29
|
execution_intent TEXT NOT NULL DEFAULT 'execute', -- 'execute' | 'plan'
|
|
@@ -91,6 +92,9 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
91
92
|
-- Auth profile override (v16)
|
|
92
93
|
auth_profile TEXT DEFAULT NULL, -- null=default, 'inherit'=main session profile, or 'provider:label'
|
|
93
94
|
|
|
95
|
+
-- Fallback selection overrides (v24)
|
|
96
|
+
auth_profile_fallback TEXT DEFAULT NULL, -- optional fallback auth profile used after primary selection failure
|
|
97
|
+
|
|
94
98
|
-- Delivery opt-out (v19)
|
|
95
99
|
delivery_opt_out_reason TEXT DEFAULT NULL, -- set when delivery_mode='none' to explicitly skip delivery
|
|
96
100
|
|
|
@@ -478,3 +482,4 @@ INSERT OR IGNORE INTO schema_migrations (version) VALUES (20);
|
|
|
478
482
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (21);
|
|
479
483
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (22);
|
|
480
484
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (23);
|
|
485
|
+
INSERT OR IGNORE INTO schema_migrations (version) VALUES (24);
|
package/setup.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import os from 'os';
|
|
|
19
19
|
import { execSync } from 'child_process';
|
|
20
20
|
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
|
-
import { ensureSchedulerDbParent, resolveSchedulerDbPath } from './paths.js';
|
|
22
|
+
import { ensureSchedulerDbParent, resolveSchedulerDbPath, resolveServiceWorkingDirectory } from './paths.js';
|
|
23
23
|
import { createJob } from './jobs.js';
|
|
24
24
|
import { initDb } from './db.js';
|
|
25
25
|
|
|
@@ -152,7 +152,8 @@ print();
|
|
|
152
152
|
// --- Step 1: Paths ------------------------------------------------------------
|
|
153
153
|
|
|
154
154
|
print('-- Step 1: Paths ---------------------------------------');
|
|
155
|
-
const
|
|
155
|
+
const schedulerInstallRoot = __dirname;
|
|
156
|
+
const serviceWorkingDirectory = resolveServiceWorkingDirectory({ env: process.env, moduleDir: schedulerInstallRoot });
|
|
156
157
|
const defaultWorkspace = path.join(os.homedir(), '.openclaw', 'workspace');
|
|
157
158
|
const workspacePath = await ask('Workspace path', defaultWorkspace);
|
|
158
159
|
const defaultGateway = 'http://127.0.0.1:18789';
|
|
@@ -162,10 +163,11 @@ const schedulerDbPath = resolveSchedulerDbPath({ env: process.env });
|
|
|
162
163
|
if (schedulerDbPath !== ':memory:') ensureSchedulerDbParent(schedulerDbPath);
|
|
163
164
|
|
|
164
165
|
print();
|
|
165
|
-
print(` Scheduler:
|
|
166
|
-
print(`
|
|
167
|
-
print(`
|
|
168
|
-
print(`
|
|
166
|
+
print(` Scheduler install root: ${schedulerInstallRoot}`);
|
|
167
|
+
print(` Service working dir: ${serviceWorkingDirectory}`);
|
|
168
|
+
print(` Workspace: ${workspacePath}`);
|
|
169
|
+
print(` Gateway: ${gatewayUrl}`);
|
|
170
|
+
print(` Deliver to: ${deliverTo || '(none -- skipping job creation)'}`);
|
|
169
171
|
print();
|
|
170
172
|
|
|
171
173
|
// --- Preflight: npm install behavior -----------------------------------------
|
|
@@ -193,9 +195,9 @@ print();
|
|
|
193
195
|
|
|
194
196
|
print('-- Step 2: Database migrations -------------------------');
|
|
195
197
|
try {
|
|
196
|
-
const { setDbPath } = await import(path.join(
|
|
198
|
+
const { setDbPath } = await import(path.join(schedulerInstallRoot, 'db.js'));
|
|
197
199
|
setDbPath(schedulerDbPath);
|
|
198
|
-
const migrate = (await import(path.join(
|
|
200
|
+
const migrate = (await import(path.join(schedulerInstallRoot, 'migrate-consolidate.js'))).default;
|
|
199
201
|
const ran = migrate();
|
|
200
202
|
if (ran) {
|
|
201
203
|
ok(`Migrations applied -> ${schedulerDbPath}`);
|
|
@@ -213,9 +215,9 @@ print();
|
|
|
213
215
|
print('-- Step 3: Agent memory files --------------------------');
|
|
214
216
|
|
|
215
217
|
const memoryMd = path.join(workspacePath, 'MEMORY.md');
|
|
216
|
-
const memoryEntry = `- **Scheduler Queue Pattern:** Use \`node ${
|
|
217
|
-
Inbox Consumer (\`${
|
|
218
|
-
Stuck Run Detector (\`${
|
|
218
|
+
const memoryEntry = `- **Scheduler Queue Pattern:** Use \`node ${schedulerInstallRoot}/cli.js msg send <from> <to> "body"\` for signal-only queue entries.
|
|
219
|
+
Inbox Consumer (\`${schedulerInstallRoot}/scripts/inbox-consumer.mjs\`) drains pending queue messages to Telegram.
|
|
220
|
+
Stuck Run Detector (\`${schedulerInstallRoot}/scripts/stuck-run-detector.mjs\`) alerts on stale \`running\` runs.`;
|
|
219
221
|
|
|
220
222
|
const memResult = appendIfMissing(memoryMd, 'Scheduler Queue Pattern', memoryEntry);
|
|
221
223
|
if (memResult === true) ok('Appended scheduler queue entry -> MEMORY.md');
|
|
@@ -228,10 +230,10 @@ const indexSection = `### Scheduler & Dispatch
|
|
|
228
230
|
|
|
229
231
|
| File | Covers | Load |
|
|
230
232
|
|------|--------|------|
|
|
231
|
-
| \`${
|
|
232
|
-
| \`${
|
|
233
|
-
| \`${
|
|
234
|
-
| \`${
|
|
233
|
+
| \`${schedulerInstallRoot}/\` | Standalone SQLite scheduler. CLI: \`node cli.js\`. launchd service: \`ai.openclaw.scheduler\`. | Any scheduler/cron work |
|
|
234
|
+
| \`${schedulerInstallRoot}/cli.js\` | Queue + run operations: \`msg send\`, \`msg inbox\`, \`runs running\`, \`runs stale\`. | Day-to-day scheduler operations |
|
|
235
|
+
| \`${schedulerInstallRoot}/scripts/inbox-consumer.mjs\` | Drains queue messages for one agent and delivers to Telegram. | Queue/inbox consumption |
|
|
236
|
+
| \`${schedulerInstallRoot}/scripts/stuck-run-detector.mjs\` | Detects stale \`running\` runs and exits non-zero for alerts. | Run health monitoring |`;
|
|
235
237
|
|
|
236
238
|
// Try inserting before a common section header, fall back to append.
|
|
237
239
|
// NOTE: the link emoji anchors must match the actual markdown heading in
|
|
@@ -278,7 +280,7 @@ if (!deliverTo) {
|
|
|
278
280
|
const existingNames = listJobs().map(r => r.name);
|
|
279
281
|
|
|
280
282
|
// Inbox Consumer
|
|
281
|
-
const icScript = path.join(
|
|
283
|
+
const icScript = path.join(schedulerInstallRoot, 'scripts', 'inbox-consumer.mjs');
|
|
282
284
|
const icName = 'Inbox Consumer';
|
|
283
285
|
if (existingNames.includes(icName)) {
|
|
284
286
|
skip(`"${icName}" job already exists`);
|
|
@@ -304,7 +306,7 @@ if (!deliverTo) {
|
|
|
304
306
|
|
|
305
307
|
// Stuck Run Detector
|
|
306
308
|
const srdName = 'Stuck Run Detector';
|
|
307
|
-
const srdScript = path.join(
|
|
309
|
+
const srdScript = path.join(schedulerInstallRoot, 'scripts', 'stuck-run-detector.mjs');
|
|
308
310
|
const srdCmd = `node ${srdScript} --threshold-min 45`; // coding tasks regularly take 30m+
|
|
309
311
|
if (existingNames.includes(srdName)) {
|
|
310
312
|
skip(`"${srdName}" job already exists`);
|
|
@@ -338,7 +340,7 @@ print();
|
|
|
338
340
|
|
|
339
341
|
const platform = process.platform;
|
|
340
342
|
const nodePath = process.execPath;
|
|
341
|
-
const indexPath = path.join(
|
|
343
|
+
const indexPath = path.join(schedulerInstallRoot, 'dispatcher.js');
|
|
342
344
|
const logPath = platform === 'win32'
|
|
343
345
|
? path.join(os.tmpdir(), 'openclaw-scheduler.log')
|
|
344
346
|
: '/tmp/openclaw-scheduler.log';
|
|
@@ -457,7 +459,7 @@ if (platform === 'darwin') {
|
|
|
457
459
|
<string>${xmlEscape(indexPath)}</string>
|
|
458
460
|
</array>
|
|
459
461
|
${userXml} <key>WorkingDirectory</key>
|
|
460
|
-
<string>${xmlEscape(
|
|
462
|
+
<string>${xmlEscape(serviceWorkingDirectory)}</string>
|
|
461
463
|
<key>EnvironmentVariables</key>
|
|
462
464
|
<dict>
|
|
463
465
|
<key>HOME</key>
|
|
@@ -565,7 +567,7 @@ After=network.target
|
|
|
565
567
|
|
|
566
568
|
[Service]
|
|
567
569
|
Type=simple
|
|
568
|
-
WorkingDirectory=${
|
|
570
|
+
WorkingDirectory=${serviceWorkingDirectory}
|
|
569
571
|
ExecStart=${nodePath} --no-warnings ${indexPath}
|
|
570
572
|
Environment=OPENCLAW_GATEWAY_URL=${gatewayUrl}${gatewayToken ? `\nEnvironment="OPENCLAW_GATEWAY_TOKEN=${gatewayToken.replace(/"/g, '\\"')}"` : ''}
|
|
571
573
|
Environment=SCHEDULER_DB=${schedulerDbPath}
|
|
@@ -612,7 +614,7 @@ WantedBy=default.target
|
|
|
612
614
|
if (install) {
|
|
613
615
|
try {
|
|
614
616
|
execSync(
|
|
615
|
-
`pm2 start "${indexPath}" --name "${pm2Name}" --cwd "${
|
|
617
|
+
`pm2 start "${indexPath}" --name "${pm2Name}" --cwd "${serviceWorkingDirectory}" ` +
|
|
616
618
|
`--log "${logPath}"`,
|
|
617
619
|
{
|
|
618
620
|
stdio: 'inherit',
|
|
@@ -652,7 +654,7 @@ WantedBy=default.target
|
|
|
652
654
|
print(' Setup steps:');
|
|
653
655
|
print(' 1. Install WSL2: wsl --install (in PowerShell as Admin)');
|
|
654
656
|
print(' 2. Open your WSL terminal and run this wizard again from there:');
|
|
655
|
-
print(` cd ${
|
|
657
|
+
print(` cd ${schedulerInstallRoot.replace(/\\/g, '/')}`);
|
|
656
658
|
print(' node setup.mjs');
|
|
657
659
|
print();
|
|
658
660
|
print(' WSL2 with systemd enabled gives the best experience (auto-start on login).');
|