openclaw-scheduler 0.2.9 → 0.2.11

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/gateway.js CHANGED
@@ -144,6 +144,7 @@ export async function runAgentTurn(opts) {
144
144
  * @param {number} opts.pollIntervalMs - How often to poll session activity (default: 60000)
145
145
  * @param {number} opts.absoluteTimeoutMs - Hard ceiling regardless of activity (default: 300000)
146
146
  * @param {string} opts.authProfile - Auth profile override (null, 'inherit', or 'provider:label')
147
+ * @param {string[]} [opts.sessionKinds] - Optional session kinds to track for activity polling
147
148
  */
148
149
  export async function runAgentTurnWithActivityTimeout(opts) {
149
150
  const {
@@ -155,10 +156,46 @@ export async function runAgentTurnWithActivityTimeout(opts) {
155
156
  idleTimeoutMs = 120000, // per-check idle threshold (from payload_timeout_seconds)
156
157
  pollIntervalMs = 60000, // check activity every 60s
157
158
  absoluteTimeoutMs = 300000, // hard ceiling (run_timeout_ms)
159
+ sessionKinds,
158
160
  } = opts;
159
161
 
160
162
  const controller = new AbortController();
161
163
  let abortReason = null;
164
+ const normalizedAgentId = (agentId || 'main').toLowerCase();
165
+ const normalizedSessionKey = String(sessionKey || '').toLowerCase();
166
+
167
+ const inferSessionKinds = () => {
168
+ if (Array.isArray(sessionKinds) && sessionKinds.length > 0) {
169
+ return [...new Set(sessionKinds.map(k => String(k).toLowerCase()).filter(Boolean))];
170
+ }
171
+
172
+ // Explicitly isolated/subagent sessions should not be pinned to main session
173
+ // so they can report idleness based on their own active session records.
174
+ if (
175
+ normalizedSessionKey === 'isolated' ||
176
+ normalizedSessionKey.startsWith('isolated:') ||
177
+ normalizedSessionKey.endsWith(':isolated') ||
178
+ normalizedSessionKey.includes(':isolated:') ||
179
+ normalizedAgentId === 'subagent'
180
+ ) {
181
+ return ['subagent', 'isolated'];
182
+ }
183
+
184
+ // Default to including main unless we can clearly infer this is an isolated run.
185
+ if (
186
+ normalizedAgentId === 'main' ||
187
+ normalizedSessionKey === 'main' ||
188
+ normalizedSessionKey.startsWith('main:') ||
189
+ normalizedSessionKey.includes(':main:') ||
190
+ normalizedSessionKey.endsWith(':main')
191
+ ) {
192
+ return ['main', 'subagent', 'isolated'];
193
+ }
194
+
195
+ return ['main', 'subagent', 'isolated'];
196
+ };
197
+
198
+ const resolvedSessionKinds = inferSessionKinds();
162
199
 
163
200
  // Hard absolute ceiling -- always fires regardless of activity
164
201
  const absoluteTimer = setTimeout(() => {
@@ -171,7 +208,7 @@ export async function runAgentTurnWithActivityTimeout(opts) {
171
208
 
172
209
  const checkActivity = async () => {
173
210
  try {
174
- const result = await listSessions({ kinds: ['subagent', 'isolated'], activeMinutes: 60 });
211
+ const result = await listSessions({ kinds: resolvedSessionKinds, activeMinutes: 60 });
175
212
  // Normalise: gateway wraps result in several layers
176
213
  const sessions =
177
214
  result?.result?.details?.sessions ||
@@ -551,20 +588,55 @@ export async function waitForGateway(timeoutMs = 30000, intervalMs = 2000) {
551
588
  * @param {string} [agentId='main'] - Agent ID for store path resolution
552
589
  * @returns {{ ok: boolean, error?: string }}
553
590
  */
554
- export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId = 'main') {
555
- if (!sessionKey || !authProfile) {
556
- return { ok: false, error: 'sessionKey and authProfile are required' };
557
- }
558
-
559
- // The gateway may persist session state under either the canonical agent-scoped
560
- // key or the flat transport key, depending on which path created the session.
561
- // Keep both aliases in sync so isolated scheduler jobs cannot miss the override.
591
+ function resolveSessionKeyAliases(sessionKey, agentId = 'main') {
562
592
  const canonicalMatch = sessionKey.match(/^agent:[^:]+:(.+)$/);
563
593
  const canonicalKey = sessionKey.startsWith('agent:')
564
594
  ? sessionKey
565
595
  : `agent:${agentId}:${sessionKey}`;
566
596
  const flatSessionKey = canonicalMatch?.[1] || sessionKey;
567
- const keyAliases = Array.from(new Set([canonicalKey, flatSessionKey]));
597
+ return Array.from(new Set([canonicalKey, flatSessionKey]));
598
+ }
599
+
600
+ function parseSessionModelRef(modelRef) {
601
+ const trimmed = typeof modelRef === 'string' ? modelRef.trim() : '';
602
+ if (!trimmed) {
603
+ return { providerOverride: undefined, modelOverride: undefined };
604
+ }
605
+ const slashIndex = trimmed.indexOf('/');
606
+ if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
607
+ return { providerOverride: undefined, modelOverride: trimmed };
608
+ }
609
+ const providerOverride = trimmed.slice(0, slashIndex).trim();
610
+ const modelOverride = trimmed.slice(slashIndex + 1).trim();
611
+ return {
612
+ providerOverride: providerOverride || undefined,
613
+ modelOverride: modelOverride || undefined,
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Write scheduler-managed session overrides directly to the gateway's sessions.json store.
619
+ *
620
+ * The gateway reads sessions.json on each agent turn (with mtime-based cache
621
+ * invalidation), so writing here before dispatch ensures the embedded runner
622
+ * picks up the correct auth profile and model selection.
623
+ *
624
+ * @param {string} sessionKey - Session key as used in the HTTP request (e.g. 'scheduler:<jobId>')
625
+ * @param {{ authProfile?: string | null, modelRef?: string | null }} overrides - Desired session overrides
626
+ * @param {string} [agentId='main'] - Agent ID for store path resolution
627
+ * @returns {{ ok: boolean, error?: string }}
628
+ */
629
+ export function applySessionOverridesToSessionStore(sessionKey, overrides = {}, agentId = 'main') {
630
+ if (!sessionKey) {
631
+ return { ok: false, error: 'sessionKey is required' };
632
+ }
633
+
634
+ const authProfile = typeof overrides.authProfile === 'string' ? overrides.authProfile.trim() : '';
635
+ const shouldSetAuthProfile = Boolean(authProfile) && authProfile !== 'inherit';
636
+ const { providerOverride, modelOverride } = parseSessionModelRef(overrides.modelRef);
637
+ const shouldSetModelOverride = Boolean(modelOverride);
638
+
639
+ const keyAliases = resolveSessionKeyAliases(sessionKey, agentId);
568
640
  const sessionsPath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'sessions', 'sessions.json');
569
641
 
570
642
  try {
@@ -579,28 +651,59 @@ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId
579
651
  let changed = false;
580
652
 
581
653
  for (const key of keyAliases) {
582
- const entry = store[key];
583
- if (!entry) {
584
- // Session doesn't exist yet -- create a minimal entry.
585
- // The gateway will populate the rest on the first agent turn.
586
- store[key] = {
587
- updatedAt: now,
588
- authProfileOverride: authProfile,
589
- authProfileOverrideSource: 'user',
590
- };
591
- changed = true;
654
+ const existingEntry = store[key];
655
+ if (!existingEntry && !shouldSetAuthProfile && !shouldSetModelOverride) {
592
656
  continue;
593
657
  }
594
658
 
595
- if (entry.authProfileOverride !== authProfile || entry.authProfileOverrideSource !== 'user') {
596
- // Update existing entry
597
- entry.authProfileOverride = authProfile;
598
- entry.authProfileOverrideSource = 'user';
599
- entry.updatedAt = now;
600
- // Clear compaction count so the override sticks across compactions
659
+ const entry = existingEntry || { updatedAt: now };
660
+ let entryChanged = false;
661
+
662
+ if (shouldSetAuthProfile) {
663
+ if (entry.authProfileOverride !== authProfile || entry.authProfileOverrideSource !== 'user') {
664
+ entry.authProfileOverride = authProfile;
665
+ entry.authProfileOverrideSource = 'user';
666
+ delete entry.authProfileOverrideCompactionCount;
667
+ entryChanged = true;
668
+ }
669
+ } else if (
670
+ entry.authProfileOverride !== undefined ||
671
+ entry.authProfileOverrideSource !== undefined ||
672
+ entry.authProfileOverrideCompactionCount !== undefined
673
+ ) {
674
+ delete entry.authProfileOverride;
675
+ delete entry.authProfileOverrideSource;
601
676
  delete entry.authProfileOverrideCompactionCount;
602
- changed = true;
677
+ entryChanged = true;
603
678
  }
679
+
680
+ if (shouldSetModelOverride) {
681
+ if (entry.modelOverride !== modelOverride) {
682
+ entry.modelOverride = modelOverride;
683
+ entryChanged = true;
684
+ }
685
+ if (providerOverride) {
686
+ if (entry.providerOverride !== providerOverride) {
687
+ entry.providerOverride = providerOverride;
688
+ entryChanged = true;
689
+ }
690
+ } else if (entry.providerOverride !== undefined) {
691
+ delete entry.providerOverride;
692
+ entryChanged = true;
693
+ }
694
+ } else if (entry.modelOverride !== undefined || entry.providerOverride !== undefined) {
695
+ delete entry.modelOverride;
696
+ delete entry.providerOverride;
697
+ entryChanged = true;
698
+ }
699
+
700
+ if (!entryChanged) {
701
+ continue;
702
+ }
703
+
704
+ entry.updatedAt = now;
705
+ store[key] = entry;
706
+ changed = true;
604
707
  }
605
708
 
606
709
  if (!changed) {
@@ -614,9 +717,16 @@ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId
614
717
  }
615
718
  }
616
719
 
720
+ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId = 'main') {
721
+ if (!sessionKey || !authProfile) {
722
+ return { ok: false, error: 'sessionKey and authProfile are required' };
723
+ }
724
+ return applySessionOverridesToSessionStore(sessionKey, { authProfile }, agentId);
725
+ }
726
+
617
727
  /**
618
- * Sync the live auth-profiles.json from ~/.openclaw/credentials/ to the agent's
619
- * auth store at ~/.openclaw/agents/<agentId>/agent/auth-profiles.json.
728
+ * Sync the live auth-profiles.json from the main agent store to the target
729
+ * agent store at ~/.openclaw/agents/<agentId>/agent/auth-profiles.json.
620
730
  *
621
731
  * This ensures scheduler sessions always use fresh credentials (tokens, order,
622
732
  * default profile) even when no explicit auth_profile is set on the job.
@@ -630,7 +740,7 @@ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId
630
740
  * @returns {{ ok: boolean, error?: string }}
631
741
  */
632
742
  export function syncAuthStoreToSession(agentId = 'main') {
633
- const livePath = join(HOME_DIR, '.openclaw', 'credentials', 'auth-profiles.json');
743
+ const livePath = join(HOME_DIR, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json');
634
744
  const agentStorePath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
635
745
 
636
746
  try {
package/index.d.ts CHANGED
@@ -33,6 +33,7 @@ export interface JobSpec {
33
33
  payload_kind?: 'systemEvent' | 'agentTurn' | 'shellCommand';
34
34
  payload_message: string;
35
35
  payload_model?: string | null;
36
+ payload_model_fallback?: string | null;
36
37
  payload_thinking?: string | null;
37
38
  payload_timeout_seconds?: number;
38
39
  payload_scope?: 'own' | 'global';
@@ -87,6 +88,7 @@ export interface JobSpec {
87
88
 
88
89
  // Auth profile override
89
90
  auth_profile?: string | null;
91
+ auth_profile_fallback?: string | null;
90
92
 
91
93
  // Delivery opt-out
92
94
  delivery_opt_out_reason?: string | null;
@@ -150,6 +152,8 @@ export interface JobRecord extends JobSpec {
150
152
  schedule_cron: string | null;
151
153
  schedule_at: string | null;
152
154
  schedule_tz: string;
155
+ payload_model_fallback?: string | null;
156
+ auth_profile_fallback?: string | null;
153
157
  payload_kind: 'systemEvent' | 'agentTurn' | 'shellCommand';
154
158
  payload_message: string;
155
159
  ttl_hours: number | null;
@@ -455,6 +459,7 @@ export interface AgentTurnWithTimeoutOpts {
455
459
  sessionKey?: string;
456
460
  model?: string;
457
461
  authProfile?: string | null;
462
+ sessionKinds?: string[];
458
463
  idleTimeoutMs?: number;
459
464
  pollIntervalMs?: number;
460
465
  absoluteTimeoutMs?: number;
package/jobs.js CHANGED
@@ -35,11 +35,11 @@ function sqliteNow(offsetMs = 0) {
35
35
 
36
36
  const PATCHABLE_COLUMNS = new Set([
37
37
  'enabled', 'name', 'schedule_cron', 'schedule_tz', 'schedule_at', 'schedule_kind',
38
- 'next_run_at', 'last_run_at', 'last_status', 'payload_message', 'payload_model',
38
+ 'next_run_at', 'last_run_at', 'last_status', 'payload_message', 'payload_model', 'payload_model_fallback',
39
39
  'payload_thinking', 'payload_timeout_seconds', 'session_target', 'run_timeout_ms',
40
40
  'max_retries', 'consecutive_errors',
41
41
  'delivery_mode', 'delivery_channel', 'delivery_to', 'delivery_opt_out_reason',
42
- 'delete_after_run', 'ttl_hours', 'auth_profile', 'origin',
42
+ 'delete_after_run', 'ttl_hours', 'auth_profile', 'auth_profile_fallback', 'origin',
43
43
  'output_excerpt_limit_bytes', 'output_summary_limit_bytes',
44
44
  'watchdog_check_cmd', 'watchdog_timeout_min', 'watchdog_started_at',
45
45
  'watchdog_target_label', 'watchdog_alert_channel', 'watchdog_alert_target',
@@ -192,9 +192,11 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
192
192
  'resource_pool',
193
193
  'preferred_session_key',
194
194
  'payload_model',
195
+ 'payload_model_fallback',
195
196
  'payload_thinking',
196
197
  'trigger_condition',
197
198
  'auth_profile',
199
+ 'auth_profile_fallback',
198
200
  'delivery_opt_out_reason',
199
201
  'origin',
200
202
  // v0.2 nullable strings
@@ -397,6 +399,9 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
397
399
  if (mode === 'create' || 'payload_model' in normalized) {
398
400
  assertSafeString('payload_model', merged.payload_model, { allowEmpty: false, maxLength: 256 });
399
401
  }
402
+ if (mode === 'create' || 'payload_model_fallback' in normalized) {
403
+ assertSafeString('payload_model_fallback', merged.payload_model_fallback, { allowEmpty: false, maxLength: 256 });
404
+ }
400
405
  if (mode === 'create' || 'payload_thinking' in normalized) {
401
406
  assertSafeString('payload_thinking', merged.payload_thinking, { allowEmpty: false, maxLength: 64 });
402
407
  }
@@ -408,6 +413,14 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
408
413
  assertSafeString('auth_profile', merged.auth_profile, { allowEmpty: false, maxLength: 256 });
409
414
  }
410
415
  }
416
+ if (mode === 'create' || 'auth_profile_fallback' in normalized) {
417
+ if (merged.auth_profile_fallback != null) {
418
+ if (typeof merged.auth_profile_fallback !== 'string') {
419
+ throw new Error('auth_profile_fallback must be a string or null');
420
+ }
421
+ assertSafeString('auth_profile_fallback', merged.auth_profile_fallback, { allowEmpty: false, maxLength: 256 });
422
+ }
423
+ }
411
424
 
412
425
  // Origin tracking (v20): required on creation for root (non-child) jobs.
413
426
  // Format convention: "<channel>:<id>" e.g. "telegram:<your-user-id>", "telegram:<your-group-id>", or "system" for automated jobs.
@@ -651,7 +664,7 @@ export function createJob(opts) {
651
664
  INSERT INTO jobs (
652
665
  id, name, enabled, schedule_kind, schedule_at, schedule_cron, schedule_tz,
653
666
  session_target, agent_id, payload_kind, payload_message,
654
- payload_model, payload_thinking, payload_timeout_seconds,
667
+ payload_model, payload_model_fallback, payload_thinking, payload_timeout_seconds,
655
668
  execution_intent, execution_read_only,
656
669
  overlap_policy, run_timeout_ms, max_queued_dispatches, max_pending_approvals, max_trigger_fanout,
657
670
  delivery_mode, delivery_channel, delivery_to,
@@ -668,7 +681,7 @@ export function createJob(opts) {
668
681
  watchdog_timeout_min, watchdog_alert_channel, watchdog_alert_target,
669
682
  watchdog_self_destruct, watchdog_started_at,
670
683
  ttl_hours,
671
- auth_profile,
684
+ auth_profile, auth_profile_fallback,
672
685
  delivery_opt_out_reason,
673
686
  origin,
674
687
  identity_principal, identity_run_as, identity_attestation, identity_ref,
@@ -684,7 +697,7 @@ export function createJob(opts) {
684
697
  ) VALUES (
685
698
  ?, ?, ?, ?, ?, ?, ?,
686
699
  ?, ?, ?, ?,
687
- ?, ?, ?,
700
+ ?, ?, ?, ?,
688
701
  ?, ?,
689
702
  ?, ?, ?, ?, ?,
690
703
  ?, ?, ?,
@@ -698,7 +711,7 @@ export function createJob(opts) {
698
711
  ?, ?, ?,
699
712
  ?, ?, ?,
700
713
  ?, ?,
701
- ?,
714
+ ?, ?,
702
715
  ?,
703
716
  ?,
704
717
  ?,
@@ -728,6 +741,7 @@ export function createJob(opts) {
728
741
  finalKind,
729
742
  normalized.payload_message,
730
743
  normalized.payload_model || null,
744
+ normalized.payload_model_fallback || null,
731
745
  normalized.payload_thinking || null,
732
746
  normalized.payload_timeout_seconds ?? 120,
733
747
  normalized.execution_intent || 'execute',
@@ -771,6 +785,7 @@ export function createJob(opts) {
771
785
  normalized.watchdog_started_at || null,
772
786
  normalized.ttl_hours || null,
773
787
  normalized.auth_profile || null,
788
+ normalized.auth_profile_fallback || null,
774
789
  normalized.delivery_opt_out_reason || null,
775
790
  normalized.origin || null,
776
791
  normalized.identity_principal || null,
@@ -830,7 +845,7 @@ export function updateJob(id, patch) {
830
845
  const allowed = [
831
846
  'name', 'enabled', 'schedule_kind', 'schedule_at', 'schedule_cron', 'schedule_tz',
832
847
  'session_target', 'agent_id', 'payload_kind', 'payload_message',
833
- 'payload_model', 'payload_thinking', 'payload_timeout_seconds',
848
+ 'payload_model', 'payload_model_fallback', 'payload_thinking', 'payload_timeout_seconds',
834
849
  'execution_intent', 'execution_read_only',
835
850
  'overlap_policy', 'run_timeout_ms', 'max_queued_dispatches', 'max_pending_approvals', 'max_trigger_fanout',
836
851
  'delivery_mode', 'delivery_channel', 'delivery_to',
@@ -846,7 +861,7 @@ export function updateJob(id, patch) {
846
861
  'watchdog_timeout_min', 'watchdog_alert_channel', 'watchdog_alert_target',
847
862
  'watchdog_self_destruct', 'watchdog_started_at',
848
863
  'ttl_hours',
849
- 'auth_profile',
864
+ 'auth_profile', 'auth_profile_fallback',
850
865
  'delivery_opt_out_reason',
851
866
  'origin',
852
867
  // v0.2 fields
@@ -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 (v23).
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 >= 23
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.9",
3
+ "version": "0.2.11",
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": ">=22"
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": "^11.10.0",
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);
@@ -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: 23)
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);