openclaw-scheduler 0.2.0 → 0.2.2

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.
@@ -31,6 +31,7 @@ import { readFileSync, writeFileSync, renameSync, statSync } from 'fs';
31
31
  import { dirname, join } from 'path';
32
32
  import { homedir } from 'os';
33
33
  import { fileURLToPath } from 'url';
34
+ import { resolveCompletionDelivery } from './completion.mjs';
34
35
  import { sendMessage } from '../messages.js';
35
36
 
36
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -53,6 +54,7 @@ const ACTIVITY_POLL_MS = 30_000;
53
54
  * so PING_INTERVAL_MS must stay well below PING_STALE_MS (3 * 60_000). */
54
55
  const PING_INTERVAL_MS = 60_000; // 60 seconds
55
56
 
57
+
56
58
  function getGatewayToken() {
57
59
  if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
58
60
  try {
@@ -656,6 +658,49 @@ function getJsonlMidTurnReason(sessionId, agentDir = 'main') {
656
658
  return null; // Last assistant entry appears to be a complete text reply -- safe to proceed
657
659
  }
658
660
 
661
+ /**
662
+ * Read the last assistant entry's stop_reason from the session JSONL.
663
+ * Returns the stop_reason string (e.g. 'end_turn', 'tool_use') or null if unavailable.
664
+ *
665
+ * Uses readJsonlLastLines with n=10 to scan enough history to find the last
666
+ * assistant message even if several tool_result entries follow it.
667
+ *
668
+ * @param {string} sessionId - Internal session UUID
669
+ * @param {string} agentDir - Agent directory (default: 'main')
670
+ * @returns {string|null} stop_reason string or null
671
+ */
672
+ function getSessionStopReason(sessionId, agentDir = 'main') {
673
+ const lastLines = readJsonlLastLines(sessionId, agentDir, 10);
674
+ if (!lastLines) return null;
675
+ // Walk backwards to find last role=assistant entry
676
+ for (let i = lastLines.length - 1; i >= 0; i--) {
677
+ const entry = lastLines[i];
678
+ if (entry?.role === 'assistant') {
679
+ return entry?.stop_reason ?? null;
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+
685
+ /**
686
+ * Returns true if the session has cleanly finished with stop_reason=end_turn.
687
+ * Requires:
688
+ * - stop_reason === 'end_turn' on the last assistant entry
689
+ * - getJsonlMidTurnReason() returns null (no in-flight tool calls or pending results)
690
+ *
691
+ * Used for Path 2a early delivery: skip FLAT_WINDOW_MS wait when session is
692
+ * verifiably done via JSONL stop_reason signal.
693
+ *
694
+ * @param {string} sessionId - Internal session UUID
695
+ * @param {string} agentDir - Agent directory (default: 'main')
696
+ * @returns {boolean}
697
+ */
698
+ function isSessionCleanlyFinished(sessionId, agentDir = 'main') {
699
+ if (getJsonlMidTurnReason(sessionId, agentDir) !== null) return false;
700
+ const stopReason = getSessionStopReason(sessionId, agentDir);
701
+ return stopReason === 'end_turn';
702
+ }
703
+
659
704
  /**
660
705
  * Update labels.json to mark the watched label as done (best-effort, atomic write).
661
706
  * Called before exit to ensure labels.json is reconciled even if sync fails.
@@ -698,7 +743,7 @@ function markLabelError(label, errorSummary) {
698
743
  * If the verify command exits non-zero, the job is marked as error and
699
744
  * an alert is written to stdout (delivery target receives the failure notice).
700
745
  */
701
- function deliverResult(label, lastReply, fallbackSummary) {
746
+ function deliverResult(label, lastReply, fallbackSummary, completionPayload = null) {
702
747
  // -- verify-cmd check -----------------------------------------------------
703
748
  // Run the stored verify-cmd (if any) before declaring the job done.
704
749
  // A non-zero exit flips the job to error state and sends an alert instead.
@@ -732,20 +777,21 @@ function deliverResult(label, lastReply, fallbackSummary) {
732
777
  }
733
778
 
734
779
  // Update labels.json before exiting -- prevents stuck detector false positives
735
- const summary = fallbackSummary || (lastReply ? lastReply.slice(0, 500) : null);
736
- markLabelDone(label, summary);
780
+ const completion = resolveCompletionDelivery({
781
+ lastReply,
782
+ completion: completionPayload,
783
+ fallbackSummary,
784
+ });
785
+ markLabelDone(label, completion.summary);
737
786
 
738
- if (lastReply) {
787
+ if (completion.deliveryText) {
739
788
  const maxLen = 3500;
740
- const reply = lastReply.length > maxLen
741
- ? lastReply.slice(0, maxLen) + '\n\n..[truncated]'
742
- : lastReply;
789
+ const reply = completion.deliveryText.length > maxLen
790
+ ? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
791
+ : completion.deliveryText;
743
792
  process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
744
793
  } else {
745
- process.stdout.write(
746
- `🌶️ *dispatch* [${label}] completed (no reply captured)\n` +
747
- `Summary: ${fallbackSummary || 'none'}\n`
748
- );
794
+ process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
749
795
  }
750
796
  process.exit(0);
751
797
  }
@@ -821,6 +867,7 @@ let recoverySessionKey = null; // captured during polling for steer/kill
821
867
 
822
868
  // Module-level state accessible by SIGTERM handler
823
869
  let lastKnownReply = null;
870
+ let lastKnownCompletion = null;
824
871
 
825
872
  // -- SIGTERM handler (scheduler kills watcher with SIGTERM before SIGKILL) --
826
873
  // Ensures labels.json is updated and a delivery attempt is made even when killed.
@@ -830,9 +877,10 @@ process.on('SIGTERM', () => {
830
877
  try {
831
878
  const result = dispatch('result', ['--label', label]);
832
879
  if (result?.lastReply) lastKnownReply = result.lastReply;
880
+ if (result?.completion) lastKnownCompletion = result.completion;
833
881
  } catch {}
834
882
  // deliverResult calls process.exit(0) internally
835
- deliverResult(label, lastKnownReply, 'interrupted by watcher timeout');
883
+ deliverResult(label, lastKnownReply, 'interrupted by watcher timeout', lastKnownCompletion);
836
884
  });
837
885
 
838
886
  // -- Rolling deadline vars ------------------------------------
@@ -1007,7 +1055,7 @@ while (Date.now() < deadline) {
1007
1055
  // If the session DID produce a lastReply before being killed, deliver it normally.
1008
1056
  if (sessionEverFound && isGatewayRestartKill(status.summary)) {
1009
1057
  const gwCheckResult = dispatch('result', ['--label', label]);
1010
- if (!gwCheckResult?.lastReply) {
1058
+ if (!gwCheckResult?.lastReply && !gwCheckResult?.completion?.deliveryText) {
1011
1059
  // No result captured -- session was killed before completing
1012
1060
  const retryCount = getGwRestartRetryCount(label);
1013
1061
  if (retryCount >= MAX_GW_RESTART_RETRIES) {
@@ -1046,7 +1094,7 @@ while (Date.now() < deadline) {
1046
1094
  process.exit(1);
1047
1095
  }
1048
1096
  }
1049
- // lastReply present -- session completed before/during kill; fall through to normal delivery
1097
+ // lastReply or completion payload present -- session completed before/during kill; fall through to normal delivery
1050
1098
  }
1051
1099
 
1052
1100
  // Reset gw-restart retry count on successful completion
@@ -1082,7 +1130,23 @@ while (Date.now() < deadline) {
1082
1130
  }
1083
1131
  }
1084
1132
  const result = dispatch('result', ['--label', label]);
1085
- deliverResult(label, result?.lastReply, status.summary);
1133
+ deliverResult(label, result?.lastReply, status.summary, result?.completion || status?.completion || null);
1134
+ }
1135
+
1136
+ // -- Path 2a: stop_reason early delivery (clean end_turn) --
1137
+ // If the last assistant message has stop_reason=end_turn and no tool calls
1138
+ // are in flight, deliver immediately without waiting for FLAT_WINDOW_MS.
1139
+ // This is the fast path for sessions that write stop_reason to JSONL.
1140
+ if (status.sessionKey) {
1141
+ const _e2a = getSessionStoreEntry(status.sessionKey);
1142
+ const _sid2a = _e2a?.sessionId || null;
1143
+ const _adir2a = (status.sessionKey.split(':')[1]) || 'main';
1144
+ if (_sid2a && isSessionCleanlyFinished(_sid2a, _adir2a)) {
1145
+ process.stderr.write(`[watcher] stop_reason=end_turn detected -- delivering early\n`);
1146
+ const result = dispatch('result', ['--label', label]);
1147
+ deliverResult(label, result?.lastReply, 'completed (stop_reason=end_turn)', result?.completion || null);
1148
+ // deliverResult exits
1149
+ }
1086
1150
  }
1087
1151
 
1088
1152
  // -- Path 2: status says 'running' but session may be idle -
@@ -1094,8 +1158,8 @@ while (Date.now() < deadline) {
1094
1158
  const ageMs = status.liveness?.ageMs;
1095
1159
  if (ageMs != null && ageMs >= IDLE_RESULT_CHECK_MS) {
1096
1160
  const result = dispatch('result', ['--label', label]);
1097
- if (result?.lastReply) {
1098
- deliverResult(label, result.lastReply, null);
1161
+ if (result?.lastReply || result?.completion?.deliveryText) {
1162
+ deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1099
1163
  }
1100
1164
  }
1101
1165
 
@@ -1106,16 +1170,15 @@ while (Date.now() < deadline) {
1106
1170
  // Timed out -- try one last result check
1107
1171
  const finalResult = dispatch('result', ['--label', label]);
1108
1172
  const finalStatus = dispatch('status', ['--label', label]);
1109
- if (finalResult?.lastReply) {
1173
+ if (finalStatus?.status === 'done') {
1110
1174
  const rc = getRetryCount(label);
1111
1175
  if (rc > 0) setRetryCount(label, 0);
1112
- deliverResult(label, finalResult.lastReply, finalStatus?.summary || null);
1113
- }
1114
- // If status is explicitly done, exit cleanly even without lastReply
1115
- if (finalStatus?.status === 'done') {
1116
- markDoneSync(finalStatus?.summary || 'completed');
1117
- process.stdout.write(`✅ dispatch [${label}] completed (status=done, no lastReply captured)\n`);
1118
- process.exit(0);
1176
+ deliverResult(
1177
+ label,
1178
+ finalResult?.lastReply || null,
1179
+ finalStatus?.summary || null,
1180
+ finalResult?.completion || finalStatus?.completion || null,
1181
+ );
1119
1182
  }
1120
1183
  // If status is interrupted (auto-resolved as incomplete), exit non-zero
1121
1184
  if (finalStatus?.status === 'interrupted') {
@@ -1174,9 +1237,9 @@ if (sessionInternalId) {
1174
1237
  // If the session already completed (gateway pruned it -> null tokens), exit cleanly.
1175
1238
  if (statusAtDeadline?.status === 'done' || baselineTokens === null) {
1176
1239
  const r = dispatch('result', ['--label', label]);
1177
- if (r?.lastReply) {
1240
+ if (r?.lastReply || r?.completion?.deliveryText) {
1178
1241
  // deliverResult calls process.exit(0) internally
1179
- deliverResult(label, r.lastReply, statusAtDeadline?.summary || null);
1242
+ deliverResult(label, r?.lastReply || null, statusAtDeadline?.summary || null, r?.completion || null);
1180
1243
  }
1181
1244
  // Status is explicitly done -- exit cleanly, no timeout noise
1182
1245
  if (statusAtDeadline?.status === 'done') {
@@ -1211,12 +1274,12 @@ while (Date.now() - flatSince < FLAT_WINDOW_MS) {
1211
1274
  if (st?.status === 'done') {
1212
1275
  const r = dispatch('result', ['--label', label]);
1213
1276
  // deliverResult calls process.exit(0) internally
1214
- deliverResult(label, r?.lastReply, st.summary);
1277
+ deliverResult(label, r?.lastReply || null, st.summary, r?.completion || st?.completion || null);
1215
1278
  }
1216
1279
  const r2 = dispatch('result', ['--label', label]);
1217
- if (r2?.lastReply) {
1280
+ if (r2?.lastReply || r2?.completion?.deliveryText) {
1218
1281
  // deliverResult calls process.exit(0) internally
1219
- deliverResult(label, r2.lastReply, null);
1282
+ deliverResult(label, r2?.lastReply || null, null, r2?.completion || null);
1220
1283
  }
1221
1284
 
1222
1285
  // Token growth?
@@ -1305,12 +1368,12 @@ if (sessionInternalId) {
1305
1368
  if (stExt?.status === 'done') {
1306
1369
  const rExt = dispatch('result', ['--label', label]);
1307
1370
  // deliverResult calls process.exit(0) internally
1308
- deliverResult(label, rExt?.lastReply, stExt.summary);
1371
+ deliverResult(label, rExt?.lastReply || null, stExt.summary, rExt?.completion || stExt?.completion || null);
1309
1372
  }
1310
1373
  const rExt2 = dispatch('result', ['--label', label]);
1311
- if (rExt2?.lastReply) {
1374
+ if (rExt2?.lastReply || rExt2?.completion?.deliveryText) {
1312
1375
  // deliverResult calls process.exit(0) internally
1313
- deliverResult(label, rExt2.lastReply, null);
1376
+ deliverResult(label, rExt2?.lastReply || null, null, rExt2?.completion || null);
1314
1377
  }
1315
1378
 
1316
1379
  // JSONL mtime check during extended wait
@@ -1362,12 +1425,12 @@ for (const round of steerRounds) {
1362
1425
  if (st2?.status === 'done') {
1363
1426
  const r3 = dispatch('result', ['--label', label]);
1364
1427
  // deliverResult calls process.exit(0) internally
1365
- deliverResult(label, r3?.lastReply, st2.summary);
1428
+ deliverResult(label, r3?.lastReply || null, st2.summary, r3?.completion || st2?.completion || null);
1366
1429
  }
1367
1430
  const r3 = dispatch('result', ['--label', label]);
1368
- if (r3?.lastReply) {
1431
+ if (r3?.lastReply || r3?.completion?.deliveryText) {
1369
1432
  // deliverResult calls process.exit(0) internally
1370
- deliverResult(label, r3.lastReply, null);
1433
+ deliverResult(label, r3?.lastReply || null, null, r3?.completion || null);
1371
1434
  }
1372
1435
 
1373
1436
  if (!round.msg && steerSessionKey) {
@@ -1380,8 +1443,8 @@ for (const round of steerRounds) {
1380
1443
  if (st3?.status === 'done') {
1381
1444
  // Check if a result was captured before marking as error
1382
1445
  const r4 = dispatch('result', ['--label', label]);
1383
- if (r4?.lastReply) {
1384
- deliverResult(label, r4.lastReply, st3.summary); // deliverResult calls process.exit(0)
1446
+ if (r4?.lastReply || r4?.completion?.deliveryText) {
1447
+ deliverResult(label, r4?.lastReply || null, st3.summary, r4?.completion || st3?.completion || null); // deliverResult calls process.exit(0)
1385
1448
  }
1386
1449
  markLabelError(label, 'timed out -- killed after steer attempts (no result captured)');
1387
1450
  process.stdout.write(`⏱ dispatch [${label}] killed after steer attempts -- no result captured\n`);
@@ -6,7 +6,7 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
6
6
  return resolveDeliveryAlias(target);
7
7
  }
8
8
 
9
- async function handleDelivery(job, content) {
9
+ async function handleDelivery(job, content, opts = {}) {
10
10
  if (!['announce', 'announce-always'].includes(job.delivery_mode)) return;
11
11
  if (!job.delivery_channel && !job.delivery_to) return;
12
12
 
@@ -24,7 +24,7 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
24
24
 
25
25
  try {
26
26
  const subject = (job.name || '').slice(0, 100);
27
- sendMessage({
27
+ const msg = {
28
28
  from_agent: 'scheduler',
29
29
  to_agent: 'main',
30
30
  kind: 'result',
@@ -32,8 +32,15 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
32
32
  body: content,
33
33
  channel,
34
34
  delivery_to: target,
35
- });
36
- log('info', `Enqueued: ${job.name}`, { channel, to: target });
35
+ };
36
+ // Pass image attachment paths so the message consumer can deliver them
37
+ // as photo/file attachments instead of plain text. Scripts signal this
38
+ // by writing [IMAGE:/path/to/file] markers to stdout.
39
+ if (opts.imageAttachments?.length > 0) {
40
+ msg.attachments = opts.imageAttachments;
41
+ }
42
+ sendMessage(msg);
43
+ log('info', `Enqueued: ${job.name}`, { channel, to: target, attachments: opts.imageAttachments?.length || 0 });
37
44
  } catch (err) {
38
45
  log('error', `Delivery enqueue failed: ${job.name}: ${err.message}`);
39
46
  }
@@ -203,15 +203,19 @@ export async function finalizeDispatch(job, ctx, result, deps) {
203
203
  const shouldAnnounce = ['announce', 'announce-always'].includes(job.delivery_mode)
204
204
  && deliveryContent?.trim();
205
205
 
206
+ const deliveryOpts = result.imageAttachments?.length > 0
207
+ ? { imageAttachments: result.imageAttachments }
208
+ : {};
209
+
206
210
  if (shouldAnnounce) {
207
211
  if (result.deliveryOverride) {
208
- await handleDelivery(job, result.deliveryOverride);
212
+ await handleDelivery(job, result.deliveryOverride, deliveryOpts);
209
213
  } else if (result.status === 'error') {
210
214
  const willRetry = (job.max_retries ?? 0) > 0 && (ctx.run.retry_count || 0) < job.max_retries;
211
215
  const retryLabel = willRetry ? 'will retry' : 'no retries configured';
212
- await handleDelivery(job, `\u26a0\ufe0f Job soft-failed (${retryLabel}): ${job.name}\n\n${deliveryContent}`);
216
+ await handleDelivery(job, `\u26a0\ufe0f Job soft-failed (${retryLabel}): ${job.name}\n\n${deliveryContent}`, deliveryOpts);
213
217
  } else {
214
- await handleDelivery(job, deliveryContent);
218
+ await handleDelivery(job, deliveryContent, deliveryOpts);
215
219
  }
216
220
  }
217
221
  }
@@ -1008,6 +1012,9 @@ export async function executeShell(job, ctx, deps) {
1008
1012
  result.summary = shellResult.summary;
1009
1013
  result.errorMessage = shellResult.errorMessage;
1010
1014
  result.content = shellResult.deliveryText;
1015
+ if (shellResult.imageAttachments?.length > 0) {
1016
+ result.imageAttachments = shellResult.imageAttachments;
1017
+ }
1011
1018
  result.runFinishFields = {
1012
1019
  context_summary: shellResult.contextSummary,
1013
1020
  shell_exit_code: shellResult.exitCode,
@@ -1076,7 +1083,10 @@ export async function executeAgent(job, ctx, deps) {
1076
1083
  // the warm session. This avoids full agent bootstrap on every dispatch --
1077
1084
  // memory search, plugin init, and context loading only happen on the first
1078
1085
  // run. Later runs get a pre-warmed session with context already loaded.
1079
- const sessionKey = job.preferred_session_key || `scheduler:${job.id}`;
1086
+ const requestedSessionKey = job.preferred_session_key || `scheduler:${job.id}`;
1087
+ const sessionKey = requestedSessionKey.startsWith('agent:')
1088
+ ? requestedSessionKey
1089
+ : `agent:${job.agent_id || 'main'}:${requestedSessionKey}`;
1080
1090
  updateRunSession(ctx.run.id, sessionKey, null);
1081
1091
 
1082
1092
  // Mark agent as busy
@@ -1112,6 +1122,37 @@ export async function executeAgent(job, ctx, deps) {
1112
1122
  }
1113
1123
  }
1114
1124
 
1125
+ // Always sync the live auth store to the agent's auth-profiles.json BEFORE
1126
+ // every agent turn. This ensures sessions that reuse a stable key (scheduler:<jobId>)
1127
+ // always have fresh credentials -- token refreshes, order changes, and new
1128
+ // profiles are picked up automatically without requiring an explicit auth_profile
1129
+ // on every job.
1130
+ const { syncAuthStoreToSession: syncAuth } = deps;
1131
+ if (typeof syncAuth === 'function') {
1132
+ const syncResult = syncAuth(job.agent_id || 'main');
1133
+ if (syncResult.ok) {
1134
+ log('debug', `Synced live auth store to agent '${job.agent_id || 'main'}'`, { jobId: job.id });
1135
+ } else {
1136
+ log('warn', `Failed to sync auth store: ${syncResult.error}`, { jobId: job.id });
1137
+ }
1138
+ }
1139
+
1140
+ // Apply auth profile to session store BEFORE the agent turn.
1141
+ // The x-openclaw-auth-profile HTTP header is not read by the gateway (dead header).
1142
+ // Writing authProfileOverride directly to sessions.json is the effective mechanism
1143
+ // for auth profile propagation to isolated/embedded sessions.
1144
+ if (resolvedAuthProfile && resolvedAuthProfile !== 'inherit') {
1145
+ const { applyAuthProfileToSessionStore: applyAuthProfile } = deps;
1146
+ if (typeof applyAuthProfile === 'function') {
1147
+ const applyResult = applyAuthProfile(sessionKey, resolvedAuthProfile, job.agent_id || 'main');
1148
+ if (applyResult.ok) {
1149
+ log('debug', `Applied auth profile '${resolvedAuthProfile}' to session store for ${sessionKey}`, { jobId: job.id });
1150
+ } else {
1151
+ log('warn', `Failed to apply auth profile to session store: ${applyResult.error}`, { jobId: job.id, sessionKey });
1152
+ }
1153
+ }
1154
+ }
1155
+
1115
1156
  const turnResult = await runAgentTurnWithActivityTimeout({
1116
1157
  message: prompt,
1117
1158
  agentId: job.agent_id || 'main',
package/dispatcher.js CHANGED
@@ -53,6 +53,8 @@ import { upsertAgent, setAgentStatus } from './agents.js';
53
53
  import {
54
54
  runAgentTurnWithActivityTimeout, sendSystemEvent, getAllSubAgentSessions, listSessions,
55
55
  deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
56
+ applyAuthProfileToSessionStore,
57
+ syncAuthStoreToSession,
56
58
  } from './gateway.js';
57
59
  import { normalizeShellResult } from './shell-result.js';
58
60
  import {
@@ -307,6 +309,8 @@ function buildDispatchDeps() {
307
309
  updateContextSummary, releaseIdempotencyKey,
308
310
  matchesSentinel, detectTransientError,
309
311
  listSessions,
312
+ applyAuthProfileToSessionStore,
313
+ syncAuthStoreToSession,
310
314
  // Finalize
311
315
  updateIdempotencyResultHash,
312
316
  shouldRetry, scheduleRetry,
@@ -378,11 +382,22 @@ function buildJobPrompt(job, run) {
378
382
  );
379
383
  }
380
384
 
381
- // Include any pending messages for this agent
385
+ // Include any pending messages for this agent.
386
+ // getInbox() without includeDelivered already filters to status='pending' only,
387
+ // but we add an explicit guard here to log and skip any message that slipped
388
+ // through with status='delivered' or 'read' -- re-displaying such messages
389
+ // would cause duplicate notifications when the inbox-consumer later picks them up.
382
390
  const inbox = getInbox(job.agent_id || 'main', { limit: 5 });
383
- if (inbox.length > 0) {
391
+ const injectableMessages = inbox.filter(msg => {
392
+ if (msg.status && msg.status !== 'pending') {
393
+ log('warn', `buildJobPrompt: skipping non-pending message ${msg.id} (status=${msg.status}) for agent ${job.agent_id || 'main'}`);
394
+ return false;
395
+ }
396
+ return true;
397
+ });
398
+ if (injectableMessages.length > 0) {
384
399
  parts.push('\n--- Pending Messages ---');
385
- for (const msg of inbox) {
400
+ for (const msg of injectableMessages) {
386
401
  const kindLabel = msg.kind && !['text', 'result', 'status', 'system', 'spawn'].includes(msg.kind)
387
402
  ? `[${msg.kind}]${msg.owner ? ` (owner: ${msg.owner})` : ''} `
388
403
  : '';
@@ -402,7 +417,7 @@ function buildJobPrompt(job, run) {
402
417
 
403
418
  // Collect context metadata
404
419
  const contextMeta = {
405
- messages_injected: inbox.length,
420
+ messages_injected: injectableMessages.length,
406
421
  scope: job.payload_scope || 'own',
407
422
  job_class: job.job_class || 'standard',
408
423
  delivery_guarantee: job.delivery_guarantee || 'at-most-once',
package/gateway.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Gateway API client -- independent dispatch via chat completions + system events
2
2
  import { execFileSync } from 'child_process';
3
- import { readFileSync } from 'fs';
3
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { join } from 'path';
6
6
  import { getDb } from './db.js';
@@ -471,3 +471,119 @@ export async function waitForGateway(timeoutMs = 30000, intervalMs = 2000) {
471
471
  }
472
472
  return false;
473
473
  }
474
+
475
+ /**
476
+ * Write authProfileOverride directly to the gateway's sessions.json store.
477
+ *
478
+ * The gateway reads sessions.json on each agent turn (with mtime-based cache
479
+ * invalidation), so writing here before dispatch ensures the embedded runner
480
+ * picks up the correct auth profile.
481
+ *
482
+ * The x-openclaw-auth-profile HTTP header sent by runAgentTurnWithActivityTimeout
483
+ * is NOT read by the gateway (dead header). This direct store write is the
484
+ * effective mechanism for auth profile propagation to isolated sessions.
485
+ *
486
+ * @param {string} sessionKey - Session key as used in the HTTP request (e.g. 'scheduler:<jobId>')
487
+ * @param {string} authProfile - Auth profile ID (e.g. 'anthropic:gmail')
488
+ * @param {string} [agentId='main'] - Agent ID for store path resolution
489
+ * @returns {{ ok: boolean, error?: string }}
490
+ */
491
+ export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId = 'main') {
492
+ if (!sessionKey || !authProfile) {
493
+ return { ok: false, error: 'sessionKey and authProfile are required' };
494
+ }
495
+
496
+ // The gateway may persist session state under either the canonical agent-scoped
497
+ // key or the flat transport key, depending on which path created the session.
498
+ // Keep both aliases in sync so isolated scheduler jobs cannot miss the override.
499
+ const canonicalMatch = sessionKey.match(/^agent:[^:]+:(.+)$/);
500
+ const canonicalKey = sessionKey.startsWith('agent:')
501
+ ? sessionKey
502
+ : `agent:${agentId}:${sessionKey}`;
503
+ const flatSessionKey = canonicalMatch?.[1] || sessionKey;
504
+ const keyAliases = Array.from(new Set([canonicalKey, flatSessionKey]));
505
+ const sessionsPath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'sessions', 'sessions.json');
506
+
507
+ try {
508
+ if (!existsSync(sessionsPath)) {
509
+ return { ok: false, error: `sessions.json not found at ${sessionsPath}` };
510
+ }
511
+
512
+ const raw = readFileSync(sessionsPath, 'utf-8');
513
+ const store = JSON.parse(raw);
514
+
515
+ const now = Date.now();
516
+ let changed = false;
517
+
518
+ for (const key of keyAliases) {
519
+ const entry = store[key];
520
+ if (!entry) {
521
+ // Session doesn't exist yet -- create a minimal entry.
522
+ // The gateway will populate the rest on the first agent turn.
523
+ store[key] = {
524
+ updatedAt: now,
525
+ authProfileOverride: authProfile,
526
+ authProfileOverrideSource: 'user',
527
+ };
528
+ changed = true;
529
+ continue;
530
+ }
531
+
532
+ if (entry.authProfileOverride !== authProfile || entry.authProfileOverrideSource !== 'user') {
533
+ // Update existing entry
534
+ entry.authProfileOverride = authProfile;
535
+ entry.authProfileOverrideSource = 'user';
536
+ entry.updatedAt = now;
537
+ // Clear compaction count so the override sticks across compactions
538
+ delete entry.authProfileOverrideCompactionCount;
539
+ changed = true;
540
+ }
541
+ }
542
+
543
+ if (!changed) {
544
+ return { ok: true };
545
+ }
546
+
547
+ writeFileSync(sessionsPath, JSON.stringify(store), 'utf-8');
548
+ return { ok: true };
549
+ } catch (err) {
550
+ return { ok: false, error: `Failed to update sessions.json: ${err.message}` };
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Sync the live auth-profiles.json from ~/.openclaw/credentials/ to the agent's
556
+ * auth store at ~/.openclaw/agents/<agentId>/agent/auth-profiles.json.
557
+ *
558
+ * This ensures scheduler sessions always use fresh credentials (tokens, order,
559
+ * default profile) even when no explicit auth_profile is set on the job.
560
+ * Without this, sessions created from a stable session key inherit a stale
561
+ * copy of the auth store that was snapshotted when the session was first created.
562
+ *
563
+ * This is a fast file-copy operation (~1ms) and is safe to call before every
564
+ * agent turn.
565
+ *
566
+ * @param {string} [agentId='main'] - Agent ID for store path resolution
567
+ * @returns {{ ok: boolean, error?: string }}
568
+ */
569
+ export function syncAuthStoreToSession(agentId = 'main') {
570
+ const livePath = join(HOME_DIR, '.openclaw', 'credentials', 'auth-profiles.json');
571
+ const agentStorePath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
572
+
573
+ try {
574
+ if (!existsSync(livePath)) {
575
+ return { ok: false, error: `Live auth store not found at ${livePath}` };
576
+ }
577
+
578
+ // Ensure the agent directory exists
579
+ const agentDir = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent');
580
+ if (!existsSync(agentDir)) {
581
+ mkdirSync(agentDir, { recursive: true });
582
+ }
583
+
584
+ copyFileSync(livePath, agentStorePath);
585
+ return { ok: true };
586
+ } catch (err) {
587
+ return { ok: false, error: `Failed to sync auth store: ${err.message}` };
588
+ }
589
+ }
package/jobs.js CHANGED
@@ -305,17 +305,46 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
305
305
  assertEnum('overlap_policy', merged.overlap_policy || 'skip', VALID_OVERLAP_POLICIES);
306
306
  assertEnum('delivery_mode', merged.delivery_mode || 'announce', VALID_DELIVERY_MODES);
307
307
 
308
+ // INSERT-only: enforce delivery_to for all non-exempt jobs.
309
+ // Exempt from this requirement:
310
+ // - job_type='watchdog' (internal health monitor jobs)
311
+ // - name starts with 'watchdog:' (watchdog jobs by naming convention)
312
+ // - session_target='main' (injects into the main session, no chat routing needed)
313
+ // - delivery_mode='none' (explicitly opted out of delivery)
314
+ if (mode === 'create') {
315
+ const _target = merged.session_target || 'isolated';
316
+ const _dmode = merged.delivery_mode || 'announce';
317
+ const _type = merged.job_type || 'standard';
318
+ const _name = String(merged.name || '');
319
+ const _isExempt =
320
+ _type === 'watchdog' ||
321
+ _name.startsWith('watchdog:') ||
322
+ _target === 'main' ||
323
+ _dmode === 'none';
324
+ if (!_isExempt && (!merged.delivery_to || String(merged.delivery_to).trim() === '')) {
325
+ throw new Error(
326
+ 'delivery_to is required on job insert. Set it to the origin chat_id ' +
327
+ '(e.g. -1001234567890 for a group chat, or 987654321 for a personal DM).'
328
+ );
329
+ }
330
+ }
331
+
308
332
  // Enforce: delivery_to is required when delivery_mode is explicitly set to
309
333
  // 'announce' or 'announce-always'. Validates on create (when delivery_mode is
310
334
  // explicitly provided) and on update (when delivery_mode is being changed or
311
335
  // the merged record would end up in announce mode without a delivery_to).
336
+ // Exempt: watchdog jobs, session_target='main' (no external chat routing needed).
312
337
  {
313
338
  const modeExplicitlySet = 'delivery_mode' in normalized;
314
339
  const deliveryToExplicitlySet = 'delivery_to' in normalized;
315
340
  const effectiveMode = merged.delivery_mode || 'announce';
316
341
  const isAnnounceMode = ['announce', 'announce-always'].includes(effectiveMode);
342
+ const _announceExempt =
343
+ (merged.job_type || 'standard') === 'watchdog' ||
344
+ String(merged.name || '').startsWith('watchdog:') ||
345
+ (merged.session_target || 'isolated') === 'main';
317
346
 
318
- if (isAnnounceMode && (mode === 'create' || modeExplicitlySet || deliveryToExplicitlySet)) {
347
+ if (isAnnounceMode && !_announceExempt && (mode === 'create' || modeExplicitlySet || deliveryToExplicitlySet)) {
319
348
  // Re-evaluate: if mode is being set to announce OR delivery_to is being
320
349
  // cleared on an announce-mode job, check the merged delivery_to is present.
321
350
  if (!merged.delivery_to || (typeof merged.delivery_to === 'string' && merged.delivery_to.trim() === '')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-scheduler",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -70,6 +70,7 @@
70
70
  "migrate.js",
71
71
  "migrate-consolidate.js",
72
72
  "paths.js",
73
+ "provider-registry.js",
73
74
  "prompt-context.js",
74
75
  "retrieval.js",
75
76
  "runs.js",