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.
- package/AGENTS.md +21 -0
- package/BEST-PRACTICES.md +20 -3
- package/CHANGELOG.md +38 -1
- package/INSTALL.md +23 -0
- package/README.md +24 -12
- package/UPGRADING.md +31 -0
- package/dispatch/index.mjs +73 -28
- package/dispatch/watcher.mjs +101 -38
- package/dispatcher-delivery.js +11 -4
- package/dispatcher-strategies.js +45 -4
- package/dispatcher.js +19 -4
- package/gateway.js +117 -1
- package/jobs.js +30 -1
- package/package.json +2 -1
- package/provider-registry.js +94 -0
- package/scheduler-schema.js +5 -3
- package/schema.sql +2 -2
- package/scripts/inbox-consumer.mjs +29 -2
- package/shell-result.js +21 -3
package/dispatch/watcher.mjs
CHANGED
|
@@ -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
|
|
736
|
-
|
|
780
|
+
const completion = resolveCompletionDelivery({
|
|
781
|
+
lastReply,
|
|
782
|
+
completion: completionPayload,
|
|
783
|
+
fallbackSummary,
|
|
784
|
+
});
|
|
785
|
+
markLabelDone(label, completion.summary);
|
|
737
786
|
|
|
738
|
-
if (
|
|
787
|
+
if (completion.deliveryText) {
|
|
739
788
|
const maxLen = 3500;
|
|
740
|
-
const reply =
|
|
741
|
-
?
|
|
742
|
-
:
|
|
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.
|
|
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
|
|
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 (
|
|
1173
|
+
if (finalStatus?.status === 'done') {
|
|
1110
1174
|
const rc = getRetryCount(label);
|
|
1111
1175
|
if (rc > 0) setRetryCount(label, 0);
|
|
1112
|
-
deliverResult(
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`);
|
package/dispatcher-delivery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dispatcher-strategies.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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",
|