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.
@@ -1214,6 +1214,93 @@ export async function executeShell(job, ctx, deps) {
1214
1214
 
1215
1215
  // -- Strategy: Agent (isolated session) ----------------------
1216
1216
 
1217
+ function describeAgentSelection(selection) {
1218
+ return {
1219
+ model: selection?.model || null,
1220
+ auth_profile: selection?.authProfile || null,
1221
+ };
1222
+ }
1223
+
1224
+ function sameAgentSelection(left, right) {
1225
+ return (left?.model || undefined) === (right?.model || undefined)
1226
+ && (left?.authProfile || undefined) === (right?.authProfile || undefined);
1227
+ }
1228
+
1229
+ async function resolveConfiguredAuthProfile(authProfile, deps, jobId, fieldName = 'auth_profile') {
1230
+ const { listSessions, log } = deps;
1231
+ let resolvedAuthProfile = authProfile || undefined;
1232
+ if (resolvedAuthProfile !== 'inherit') return resolvedAuthProfile;
1233
+
1234
+ try {
1235
+ const sessions = await listSessions({ kinds: ['main'], activeMinutes: 120, limit: 10 });
1236
+ const sessionList = sessions?.result?.details?.sessions || sessions?.result?.sessions || sessions?.sessions || sessions || [];
1237
+ const mainSession = Array.isArray(sessionList)
1238
+ ? sessionList.find(s => {
1239
+ const key = s.key || s.sessionKey || '';
1240
+ return key.includes(':main:') || key.endsWith(':main') || key === 'main';
1241
+ })
1242
+ : null;
1243
+ const profileId = mainSession?.authProfileOverride || mainSession?.authProfile || mainSession?.profile;
1244
+ if (profileId) {
1245
+ resolvedAuthProfile = profileId;
1246
+ log('debug', `Resolved ${fieldName} 'inherit' -> '${profileId}'`, { jobId });
1247
+ } else {
1248
+ log('debug', `${fieldName} 'inherit' -- no main session profile found, passing 'inherit' as-is`, { jobId });
1249
+ }
1250
+ } catch (err) {
1251
+ log('warn', `Failed to resolve ${fieldName} 'inherit': ${err.message}`, { jobId });
1252
+ // Fall through with 'inherit' -- gateway may handle it.
1253
+ }
1254
+
1255
+ return resolvedAuthProfile;
1256
+ }
1257
+
1258
+ async function runAgentTurnForSelection(job, deps, prompt, sessionKey, selection, dispatchAgentTurn) {
1259
+ const { log } = deps;
1260
+ const { syncAuthStoreToSession: syncAuth, applySessionOverridesToSessionStore: applySessionOverrides } = deps;
1261
+
1262
+ // Always sync the live auth store before each attempt so refreshed credentials
1263
+ // are visible to any embedded/isolated runner startup.
1264
+ if (typeof syncAuth === 'function') {
1265
+ const syncResult = syncAuth(job.agent_id || 'main');
1266
+ if (syncResult.ok) {
1267
+ log('debug', `Synced live auth store to agent '${job.agent_id || 'main'}'`, { jobId: job.id });
1268
+ } else {
1269
+ log('warn', `Failed to sync auth store: ${syncResult.error}`, { jobId: job.id });
1270
+ }
1271
+ }
1272
+
1273
+ if (typeof applySessionOverrides === 'function') {
1274
+ const applyResult = applySessionOverrides(
1275
+ sessionKey,
1276
+ {
1277
+ authProfile: selection.authProfile,
1278
+ modelRef: selection.model || null,
1279
+ },
1280
+ job.agent_id || 'main',
1281
+ );
1282
+ if (applyResult.ok) {
1283
+ log('debug', `Applied session overrides for ${sessionKey}`, {
1284
+ jobId: job.id,
1285
+ authProfile: selection.authProfile || null,
1286
+ modelRef: selection.model || null,
1287
+ });
1288
+ } else {
1289
+ log('warn', `Failed to apply session overrides: ${applyResult.error}`, { jobId: job.id, sessionKey });
1290
+ }
1291
+ }
1292
+
1293
+ return dispatchAgentTurn({
1294
+ message: prompt,
1295
+ agentId: job.agent_id || 'main',
1296
+ sessionKey,
1297
+ authProfile: selection.authProfile,
1298
+ idleTimeoutMs: (job.payload_timeout_seconds || 120) * 1000,
1299
+ pollIntervalMs: 60000,
1300
+ absoluteTimeoutMs: job.run_timeout_ms || 300000,
1301
+ });
1302
+ }
1303
+
1217
1304
  export async function executeAgent(job, ctx, deps) {
1218
1305
  const {
1219
1306
  waitForGateway, updateRunSession, setAgentStatus,
@@ -1224,7 +1311,6 @@ export async function executeAgent(job, ctx, deps) {
1224
1311
  runIsolatedAgentTurn,
1225
1312
  updateContextSummary, releaseDispatch, releaseIdempotencyKey,
1226
1313
  updateJob, matchesSentinel, detectTransientError,
1227
- listSessions,
1228
1314
  sqliteNow, log,
1229
1315
  } = deps;
1230
1316
  const dispatchAgentTurn = runIsolatedAgentTurn || runAgentTurnWithActivityTimeout;
@@ -1264,82 +1350,45 @@ export async function executeAgent(job, ctx, deps) {
1264
1350
  const { prompt, contextMeta } = buildJobPrompt(job, ctx.run);
1265
1351
  try { updateContextSummary(ctx.run.id, contextMeta); } catch (_e) { /* column may not exist yet */ }
1266
1352
 
1267
- // Resolve auth_profile: use effective profile from child credential policy
1268
- // if available (set by 'inherit' policy), otherwise fall back to the job's own.
1269
- let resolvedAuthProfile = ctx.v02Outcomes?.effective_auth_profile || job.auth_profile || undefined;
1270
- if (resolvedAuthProfile === 'inherit') {
1271
- try {
1272
- const sessions = await listSessions({ kinds: ['main'], activeMinutes: 120, limit: 10 });
1273
- const sessionList = sessions?.result?.details?.sessions || sessions?.result?.sessions || sessions?.sessions || sessions || [];
1274
- const mainSession = Array.isArray(sessionList)
1275
- ? sessionList.find(s => {
1276
- const key = s.key || s.sessionKey || '';
1277
- return key.includes(':main:') || key.endsWith(':main') || key === 'main';
1278
- })
1279
- : null;
1280
- const profileId = mainSession?.authProfileOverride || mainSession?.authProfile || mainSession?.profile;
1281
- if (profileId) {
1282
- resolvedAuthProfile = profileId;
1283
- log('debug', `Resolved auth_profile 'inherit' -> '${profileId}'`, { jobId: job.id });
1284
- } else {
1285
- log('debug', `auth_profile 'inherit' -- no main session profile found, passing 'inherit' as-is`, { jobId: job.id });
1286
- }
1287
- } catch (err) {
1288
- log('warn', `Failed to resolve 'inherit' auth_profile: ${err.message}`, { jobId: job.id });
1289
- // Fall through with 'inherit' -- gateway may handle it
1290
- }
1291
- }
1353
+ const primarySelection = {
1354
+ model: job.payload_model || undefined,
1355
+ authProfile: await resolveConfiguredAuthProfile(
1356
+ ctx.v02Outcomes?.effective_auth_profile || job.auth_profile || undefined,
1357
+ deps,
1358
+ job.id,
1359
+ ctx.v02Outcomes?.effective_auth_profile ? 'effective_auth_profile' : 'auth_profile'
1360
+ ),
1361
+ };
1362
+ const hasConfiguredFallback = job.payload_model_fallback != null || job.auth_profile_fallback != null;
1363
+ const fallbackSelection = hasConfiguredFallback ? {
1364
+ model: job.payload_model_fallback || primarySelection.model || undefined,
1365
+ authProfile: job.auth_profile_fallback != null
1366
+ ? await resolveConfiguredAuthProfile(job.auth_profile_fallback, deps, job.id, 'auth_profile_fallback')
1367
+ : primarySelection.authProfile,
1368
+ } : null;
1369
+
1370
+ let turnResult;
1371
+ try {
1372
+ turnResult = await runAgentTurnForSelection(job, deps, prompt, sessionKey, primarySelection, dispatchAgentTurn);
1373
+ } catch (primaryError) {
1374
+ const canTryConfiguredFallback = fallbackSelection && !sameAgentSelection(primarySelection, fallbackSelection);
1375
+ if (!canTryConfiguredFallback) throw primaryError;
1292
1376
 
1293
- // Always sync the live auth store to the agent's auth-profiles.json BEFORE
1294
- // every agent turn. This ensures sessions that reuse a stable key (scheduler:<jobId>)
1295
- // always have fresh credentials -- token refreshes, order changes, and new
1296
- // profiles are picked up automatically without requiring an explicit auth_profile
1297
- // on every job.
1298
- const { syncAuthStoreToSession: syncAuth } = deps;
1299
- if (typeof syncAuth === 'function') {
1300
- const syncResult = syncAuth(job.agent_id || 'main');
1301
- if (syncResult.ok) {
1302
- log('debug', `Synced live auth store to agent '${job.agent_id || 'main'}'`, { jobId: job.id });
1303
- } else {
1304
- log('warn', `Failed to sync auth store: ${syncResult.error}`, { jobId: job.id });
1305
- }
1306
- }
1377
+ log('warn', 'Primary agent selection failed; retrying with configured fallback', {
1378
+ jobId: job.id,
1379
+ primary: describeAgentSelection(primarySelection),
1380
+ fallback: describeAgentSelection(fallbackSelection),
1381
+ error: primaryError.message,
1382
+ });
1307
1383
 
1308
- // Apply auth profile to session store BEFORE the agent turn.
1309
- // The x-openclaw-auth-profile HTTP header is not read by the gateway (dead header).
1310
- // Writing authProfileOverride directly to sessions.json is the effective mechanism
1311
- // for auth profile propagation to isolated/embedded sessions.
1312
- if (resolvedAuthProfile && resolvedAuthProfile !== 'inherit') {
1313
- const { applyAuthProfileToSessionStore: applyAuthProfile } = deps;
1314
- if (typeof applyAuthProfile === 'function') {
1315
- const applyResult = applyAuthProfile(sessionKey, resolvedAuthProfile, job.agent_id || 'main');
1316
- if (applyResult.ok) {
1317
- log('debug', `Applied auth profile '${resolvedAuthProfile}' to session store for ${sessionKey}`, { jobId: job.id });
1318
- } else {
1319
- log('warn', `Failed to apply auth profile to session store: ${applyResult.error}`, { jobId: job.id, sessionKey });
1320
- }
1384
+ try {
1385
+ turnResult = await runAgentTurnForSelection(job, deps, prompt, sessionKey, fallbackSelection, dispatchAgentTurn);
1386
+ log('info', 'Configured agent fallback succeeded', { jobId: job.id, fallback: describeAgentSelection(fallbackSelection) });
1387
+ } catch (fallbackError) {
1388
+ throw new Error(`Primary agent selection failed: ${primaryError.message}; configured fallback also failed: ${fallbackError.message}`, { cause: fallbackError });
1321
1389
  }
1322
1390
  }
1323
1391
 
1324
- // Isolated dispatch primitive: HTTP-only chat completions call. The
1325
- // scheduler must never fork a sibling `openclaw` process to spawn an
1326
- // isolated session -- that variant has historically SIGTERM'd the
1327
- // launchd-tracked gateway parent and orphaned a node process on port
1328
- // 18789 (see ISOLATED_DISPATCH_PRIMITIVE in gateway.js).
1329
- const turnResult = await dispatchAgentTurn({
1330
- message: prompt,
1331
- agentId: job.agent_id || 'main',
1332
- sessionKey,
1333
- model: job.payload_model || undefined,
1334
- authProfile: resolvedAuthProfile,
1335
- // materializedEnv deferred: the x-openclaw-env-inject header is not sent
1336
- // until the OpenClaw gateway implements the receiver side. See
1337
- // openclaw/docs/env-inject-proposal.md for the gateway spec.
1338
- idleTimeoutMs: (job.payload_timeout_seconds || 120) * 1000,
1339
- pollIntervalMs: 60000,
1340
- absoluteTimeoutMs: job.run_timeout_ms || 300000,
1341
- });
1342
-
1343
1392
  const content = turnResult.content || '';
1344
1393
  const trimmed = content.trim();
1345
1394
 
package/dispatcher.js CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  runAgentTurnWithActivityTimeout, runIsolatedAgentTurn,
55
55
  sendSystemEvent, getAllSubAgentSessions, listSessions,
56
56
  deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
57
- applyAuthProfileToSessionStore,
57
+ applySessionOverridesToSessionStore,
58
58
  syncAuthStoreToSession,
59
59
  } from './gateway.js';
60
60
  import { normalizeShellResult } from './shell-result.js';
@@ -314,7 +314,7 @@ function buildDispatchDeps() {
314
314
  updateContextSummary, releaseIdempotencyKey,
315
315
  matchesSentinel, detectTransientError,
316
316
  listSessions,
317
- applyAuthProfileToSessionStore,
317
+ applySessionOverridesToSessionStore,
318
318
  syncAuthStoreToSession,
319
319
  // Finalize
320
320
  updateIdempotencyResultHash,
@@ -430,8 +430,10 @@ function buildJobPrompt(job, run) {
430
430
  execution_intent: job.execution_intent || 'execute',
431
431
  execution_read_only: Boolean(job.execution_read_only),
432
432
  payload_model: job.payload_model || null,
433
+ payload_model_fallback: job.payload_model_fallback || null,
433
434
  payload_thinking: job.payload_thinking || null,
434
435
  auth_profile: job.auth_profile || null,
436
+ auth_profile_fallback: job.auth_profile_fallback || null,
435
437
  };
436
438
 
437
439
  const triggerContext = buildTriggeredRunContext(run);
@@ -90,6 +90,10 @@ single user message to an agent and receives the complete assistant response.
90
90
  The `model` field defaults to `openclaw:<agentId>` but can be overridden via
91
91
  `job.payload_model`.
92
92
 
93
+ If `job.payload_model_fallback` and/or `job.auth_profile_fallback` are set, the
94
+ scheduler retries once in the same run with the configured fallback selection
95
+ after a primary selection error.
96
+
93
97
  **Response body** (expected):
94
98
 
95
99
  ```json
@@ -653,6 +657,23 @@ directly as the `x-openclaw-auth-profile` header value without resolution.
653
657
 
654
658
  ---
655
659
 
660
+ ## Fallback Model / Auth Selection
661
+
662
+ Jobs can optionally persist `payload_model_fallback` and `auth_profile_fallback`
663
+ alongside the primary `payload_model` / `auth_profile` fields.
664
+
665
+ Runtime behavior:
666
+
667
+ - The scheduler attempts the primary selection first.
668
+ - If the primary chat-completions request errors before a usable assistant
669
+ reply is returned, `executeAgent()` retries once in the same run using the
670
+ configured fallback overrides.
671
+ - Any fallback dimension left unset keeps the primary effective value.
672
+ - Existing jobs remain backward-compatible because both fallback fields default
673
+ to `NULL` and no retry is attempted unless a fallback override is configured.
674
+
675
+ ---
676
+
656
677
  ## Env-Inject Forwarding
657
678
 
658
679
  When credential materialization for an agent task produces a non-empty plain
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