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.
@@ -39,6 +39,7 @@ import {
39
39
  import { getDispatchLivenessPolicy } from './liveness.mjs';
40
40
  import { resolveLabelsPath } from './paths.mjs';
41
41
  import { sendMessage } from '../messages.js';
42
+ import { ensureArtifactsDir, resolveArtifactsDir } from '../paths.js';
42
43
 
43
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
  const INDEX_PATH = process.env.DISPATCH_INDEX_PATH || join(__dirname, 'index.mjs');
@@ -54,12 +55,68 @@ const MAX_GW_RESTART_RETRIES = 2; // Max retries for gateway-restart-kill recove
54
55
 
55
56
  const FLAT_WINDOW_MS = 3 * 60 * 1000; // 3 min flat = genuinely stuck
56
57
  const ACTIVITY_POLL_MS = 30_000;
58
+ const COMPLETION_INLINE_LIMIT_BYTES = parsePositiveEnvInt('DISPATCH_COMPLETION_INLINE_LIMIT_BYTES', 60 * 1024);
57
59
 
58
60
  /** How often the watcher writes lastPing to labels.json (heartbeat signal).
59
61
  * The watchdog guard in index.mjs treats pings older than 3x this as stale,
60
62
  * so PING_INTERVAL_MS must stay well below PING_STALE_MS (3 * 60_000). */
61
63
  const PING_INTERVAL_MS = 60_000; // 60 seconds
62
64
 
65
+ function parsePositiveEnvInt(name, fallback) {
66
+ const value = Number.parseInt(String(process.env[name] ?? ''), 10);
67
+ return Number.isFinite(value) && value > 0 ? value : fallback;
68
+ }
69
+
70
+ function byteLength(text) {
71
+ return Buffer.byteLength(String(text ?? ''), 'utf8');
72
+ }
73
+
74
+ function sliceUtf8Bytes(text, maxBytes) {
75
+ const source = String(text ?? '');
76
+ if (byteLength(source) <= maxBytes) return source;
77
+
78
+ let usedBytes = 0;
79
+ let endIndex = 0;
80
+ for (const char of source) {
81
+ const charBytes = byteLength(char);
82
+ if (usedBytes + charBytes > maxBytes) break;
83
+ usedBytes += charBytes;
84
+ endIndex += char.length;
85
+ }
86
+ return source.slice(0, endIndex).trimEnd();
87
+ }
88
+
89
+ function completionArtifactPath(label) {
90
+ const safeLabel = String(label || 'completion')
91
+ .replace(/[^a-z0-9._-]+/gi, '-')
92
+ .replace(/^-+|-+$/g, '')
93
+ .slice(0, 80) || 'completion';
94
+ const dir = ensureArtifactsDir(join(resolveArtifactsDir({ env: process.env }), 'dispatch-completions'));
95
+ return join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${safeLabel}.txt`);
96
+ }
97
+
98
+ function formatCompletionStdout(label, deliveryText) {
99
+ const header = `🌶️ *dispatch* [${label}] completed:\n\n`;
100
+ const body = String(deliveryText ?? '');
101
+ const bodyBytes = byteLength(body);
102
+
103
+ if (bodyBytes <= COMPLETION_INLINE_LIMIT_BYTES) {
104
+ return `${header}${body}\n`;
105
+ }
106
+
107
+ let artifactNote;
108
+ try {
109
+ const artifactPath = completionArtifactPath(label);
110
+ writeFileSync(artifactPath, body, 'utf8');
111
+ artifactNote = `\n\nFull completion report saved to ${artifactPath} (${bodyBytes} bytes). Inline delivery capped at ${COMPLETION_INLINE_LIMIT_BYTES} bytes to avoid dumping an oversized report.`;
112
+ } catch (err) {
113
+ artifactNote = `\n\nFull completion report was ${bodyBytes} bytes, but saving the oversized report failed: ${err.message}. Inline delivery capped at ${COMPLETION_INLINE_LIMIT_BYTES} bytes.`;
114
+ }
115
+
116
+ const bodyBudget = Math.max(0, COMPLETION_INLINE_LIMIT_BYTES - byteLength(artifactNote));
117
+ const inlineBody = sliceUtf8Bytes(body, bodyBudget);
118
+ return `${header}${inlineBody}${artifactNote}\n`;
119
+ }
63
120
 
64
121
  function getGatewayToken() {
65
122
  if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
@@ -922,11 +979,7 @@ function deliverResult(label, lastReply, fallbackSummary, completionPayload = nu
922
979
  markLabelDone(label, completion.summary);
923
980
 
924
981
  if (completion.deliveryText) {
925
- const maxLen = 3500;
926
- const reply = completion.deliveryText.length > maxLen
927
- ? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
928
- : completion.deliveryText;
929
- process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
982
+ process.stdout.write(formatCompletionStdout(label, completion.deliveryText));
930
983
  process.exit(0);
931
984
  }
932
985
 
@@ -999,6 +1052,22 @@ function hasStructuredCompletion(result) {
999
1052
  return hasCompletionSignal(result?.completion);
1000
1053
  }
1001
1054
 
1055
+ function getCleanTerminalReply(status) {
1056
+ if (!status?.sessionKey) return null;
1057
+ const entry = getSessionStoreEntry(status.sessionKey);
1058
+ const sessionId = entry?.sessionId || null;
1059
+ const sessionAgent = status.sessionKey.split(':')[1] || 'main';
1060
+ const terminalJsonlReply = sessionId ? getSessionTerminalReply(sessionId, sessionAgent) : null;
1061
+ if (!sessionId || !terminalJsonlReply) return null;
1062
+ return isSessionCleanlyFinished(sessionId, sessionAgent) ? terminalJsonlReply : null;
1063
+ }
1064
+
1065
+ function getStrictTerminalReply(result, status) {
1066
+ const terminalJsonlReply = getCleanTerminalReply(status);
1067
+ if (!terminalJsonlReply) return null;
1068
+ return result?.lastReply || terminalJsonlReply;
1069
+ }
1070
+
1002
1071
  if (!label) {
1003
1072
  process.stderr.write('[watcher] --label is required\n');
1004
1073
  process.exit(2);
@@ -1106,28 +1175,33 @@ function runOnceAndExit() {
1106
1175
  }
1107
1176
 
1108
1177
  if (status.sessionKey) {
1109
- const entry = getSessionStoreEntry(status.sessionKey);
1110
- const sessionId = entry?.sessionId || null;
1111
- const sessionAgent = status.sessionKey.split(':')[1] || 'main';
1112
- const terminalJsonlReply = sessionId ? getSessionTerminalReply(sessionId, sessionAgent) : null;
1113
- if (sessionId && terminalJsonlReply && isSessionCleanlyFinished(sessionId, sessionAgent)) {
1178
+ const terminalJsonlReply = getCleanTerminalReply(status);
1179
+ if (terminalJsonlReply) {
1114
1180
  const result = dispatch('result', ['--label', label]);
1115
1181
  if (hasStructuredCompletion(result)) {
1116
1182
  deliverResult(label, result?.lastReply || terminalJsonlReply, 'completed (stop_reason=end_turn)', result?.completion || null);
1117
1183
  }
1118
- process.stderr.write(`[watcher] stop_reason=end_turn observed without completion signal -- continuing to monitor\n`);
1184
+ deliverResult(label, terminalJsonlReply, 'completed (stop_reason=end_turn)', null);
1119
1185
  }
1120
1186
  }
1121
1187
 
1122
1188
  const ageMs = status.liveness?.ageMs;
1123
- const idleResultCheckMs = getCurrentLivenessPolicy().idleProbeMs;
1189
+ const livenessPolicy = getCurrentLivenessPolicy();
1190
+ const idleResultCheckMs = livenessPolicy.idleProbeMs;
1191
+ const idleFailureMs = livenessPolicy.idleFailureMs;
1124
1192
  if (ageMs != null && ageMs >= idleResultCheckMs) {
1125
1193
  const result = dispatch('result', ['--label', label]);
1126
1194
  if (hasStructuredCompletion(result)) {
1127
1195
  deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1128
1196
  }
1197
+ const terminalReply = getStrictTerminalReply(result, status);
1198
+ if (terminalReply) {
1199
+ deliverResult(label, terminalReply, 'completed (stop_reason=end_turn)', null);
1200
+ }
1129
1201
 
1130
- const stallReason = getRunningSessionStallReason(status, idleResultCheckMs);
1202
+ const stallReason = ageMs >= idleFailureMs
1203
+ ? getRunningSessionStallReason(status, idleFailureMs)
1204
+ : null;
1131
1205
  if (stallReason) {
1132
1206
  process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1133
1207
  markLabelError(label, stallReason);
@@ -1493,7 +1567,7 @@ while (Date.now() < deadline) {
1493
1567
  deliverResult(label, result?.lastReply || terminalJsonlReply, 'completed (stop_reason=end_turn)', result?.completion || null);
1494
1568
  // deliverResult exits
1495
1569
  }
1496
- process.stderr.write(`[watcher] stop_reason=end_turn observed without completion signal -- continuing to monitor\n`);
1570
+ deliverResult(label, terminalJsonlReply, 'completed (stop_reason=end_turn)', null);
1497
1571
  }
1498
1572
  }
1499
1573
 
@@ -1504,14 +1578,22 @@ while (Date.now() < deadline) {
1504
1578
  // while this watcher's lastPing heartbeat is fresh (written every 60s);
1505
1579
  // this path handles normal completion before the ping goes stale.
1506
1580
  const ageMs = status.liveness?.ageMs;
1507
- const idleResultCheckMs = getCurrentLivenessPolicy().idleProbeMs;
1581
+ const livenessPolicy = getCurrentLivenessPolicy();
1582
+ const idleResultCheckMs = livenessPolicy.idleProbeMs;
1583
+ const idleFailureMs = livenessPolicy.idleFailureMs;
1508
1584
  if (ageMs != null && ageMs >= idleResultCheckMs) {
1509
1585
  const result = dispatch('result', ['--label', label]);
1510
1586
  if (hasStructuredCompletion(result)) {
1511
1587
  deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1512
1588
  }
1589
+ const terminalReply = getStrictTerminalReply(result, status);
1590
+ if (terminalReply) {
1591
+ deliverResult(label, terminalReply, 'completed (stop_reason=end_turn)', null);
1592
+ }
1513
1593
 
1514
- const stallReason = getRunningSessionStallReason(status, idleResultCheckMs);
1594
+ const stallReason = ageMs >= idleFailureMs
1595
+ ? getRunningSessionStallReason(status, idleFailureMs)
1596
+ : null;
1515
1597
  if (stallReason) {
1516
1598
  process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1517
1599
  markLabelError(label, stallReason);
@@ -1530,6 +1612,14 @@ while (Date.now() < deadline) {
1530
1612
  // Timed out -- try one last result check
1531
1613
  const finalResult = dispatch('result', ['--label', label]);
1532
1614
  const finalStatus = dispatch('status', ['--label', label]);
1615
+ if (hasStructuredCompletion(finalResult)) {
1616
+ deliverResult(
1617
+ label,
1618
+ finalResult?.lastReply || null,
1619
+ finalStatus?.summary || null,
1620
+ finalResult?.completion || finalStatus?.completion || null,
1621
+ );
1622
+ }
1533
1623
  if (finalStatus?.status === 'done') {
1534
1624
  const rc = getRetryCount(label);
1535
1625
  if (rc > 0) setRetryCount(label, 0);
@@ -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