openclaw-scheduler 0.2.4 → 0.2.6
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/CHANGELOG.md +14 -0
- package/README.md +16 -6
- package/cli.js +13 -4
- package/dispatch/README.md +18 -3
- package/dispatch/completion.mjs +1312 -34
- package/dispatch/hooks.mjs +17 -5
- package/dispatch/index.mjs +600 -226
- package/dispatch/message-input.mjs +67 -0
- package/dispatch/watcher.mjs +381 -43
- package/dispatcher-strategies.js +203 -30
- package/dispatcher.js +6 -1
- package/gateway.js +71 -8
- package/index.d.ts +1 -0
- package/package.json +3 -1
- package/scripts/dispatch-cli-utils.mjs +53 -0
- package/scripts/inbox-watcher-guardrail.mjs +506 -0
package/dispatcher-strategies.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Strategy pattern for dispatchJob: each execution target returns a DispatchResult,
|
|
3
3
|
// and finalizeDispatch processes it uniformly.
|
|
4
4
|
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* DispatchResult shape (returned by every strategy):
|
|
7
9
|
* {
|
|
@@ -50,6 +52,103 @@ function safeParse(str) {
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
function shellSingleQuote(value) {
|
|
56
|
+
return "'" + String(value ?? '').replaceAll("'", "'\\''") + "'";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseStructuredWatchdogPayload(text) {
|
|
60
|
+
const direct = safeParse(text);
|
|
61
|
+
if (direct && typeof direct === 'object' && !Array.isArray(direct)) return direct;
|
|
62
|
+
|
|
63
|
+
const firstBrace = text.indexOf('{');
|
|
64
|
+
const lastBrace = text.lastIndexOf('}');
|
|
65
|
+
if (firstBrace === -1 || lastBrace <= firstBrace) return null;
|
|
66
|
+
|
|
67
|
+
const candidate = text.slice(firstBrace, lastBrace + 1);
|
|
68
|
+
const parsed = safeParse(candidate);
|
|
69
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TERMINAL_WATCHDOG_STATUSES = new Set(['done', 'error', 'interrupted', 'spawn-warning']);
|
|
73
|
+
|
|
74
|
+
function normalizeWatchdogText(value) {
|
|
75
|
+
if (typeof value !== 'string') return null;
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
return trimmed ? trimmed : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function firstWatchdogText(...values) {
|
|
81
|
+
for (const value of values) {
|
|
82
|
+
const text = normalizeWatchdogText(value);
|
|
83
|
+
if (text) return text;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveWatchdogTerminalPayload(stdout) {
|
|
89
|
+
const text = normalizeWatchdogText(stdout);
|
|
90
|
+
if (!text) return null;
|
|
91
|
+
|
|
92
|
+
const parsed = parseStructuredWatchdogPayload(text);
|
|
93
|
+
if (!parsed) return null;
|
|
94
|
+
|
|
95
|
+
const status = typeof parsed.status === 'string' ? parsed.status : null;
|
|
96
|
+
const terminal = parsed.terminal === true || (status ? TERMINAL_WATCHDOG_STATUSES.has(status) : false);
|
|
97
|
+
if (!terminal) return null;
|
|
98
|
+
|
|
99
|
+
const detail = firstWatchdogText(
|
|
100
|
+
parsed.deliveryText,
|
|
101
|
+
parsed.lastReply,
|
|
102
|
+
parsed.error,
|
|
103
|
+
parsed.summary,
|
|
104
|
+
parsed.completion?.deliveryText,
|
|
105
|
+
parsed.completion?.summary,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (status && status !== 'done') {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'failed',
|
|
111
|
+
detail: detail || `Task ended with status ${status}.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: 'completed',
|
|
117
|
+
detail: detail || `Task reported terminal status ${status || 'done'}.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildFireAndForgetDeliveryInstruction(job) {
|
|
122
|
+
if (!job.delivery_mode || job.delivery_mode === 'none' || !job.delivery_channel || !job.delivery_to) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const schedulerCliPath = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
127
|
+
const fromLabel = `scheduler-fire-and-forget:${job.id || job.name || 'job'}`;
|
|
128
|
+
const baseCmd = [
|
|
129
|
+
'node',
|
|
130
|
+
shellSingleQuote(schedulerCliPath),
|
|
131
|
+
'messages',
|
|
132
|
+
'send',
|
|
133
|
+
'--from', shellSingleQuote(fromLabel),
|
|
134
|
+
'--to', 'main',
|
|
135
|
+
'--channel', shellSingleQuote(job.delivery_channel),
|
|
136
|
+
'--delivery-to', shellSingleQuote(job.delivery_to),
|
|
137
|
+
].join(' ');
|
|
138
|
+
|
|
139
|
+
return [
|
|
140
|
+
'\n[SYSTEM NOTE -- delivery]',
|
|
141
|
+
'When you have completed this task, queue the result through the scheduler post office.',
|
|
142
|
+
`Final result: ${baseCmd} --kind result --body "<final result>"`,
|
|
143
|
+
`Progress update: ${baseCmd} --kind status --body "<brief progress update>"`,
|
|
144
|
+
'Do NOT use the message tool, sessions_send, or any direct chat delivery.',
|
|
145
|
+
'The inbox consumer will deliver queued messages durably to the configured target.',
|
|
146
|
+
'Keep queued updates concise and actionable.',
|
|
147
|
+
'If there is nothing noteworthy to report, do not queue a message.',
|
|
148
|
+
'[END SYSTEM NOTE]\n',
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
53
152
|
function getIdentityTrustLevel(identity) {
|
|
54
153
|
if (!identity || typeof identity !== 'object') return null;
|
|
55
154
|
return identity.trust_level
|
|
@@ -850,16 +949,29 @@ export async function executeWatchdog(job, ctx, deps) {
|
|
|
850
949
|
if (elapsedMin >= job.watchdog_timeout_min) timedOut = true;
|
|
851
950
|
}
|
|
852
951
|
|
|
952
|
+
const terminalPayload = resolveWatchdogTerminalPayload(stdout);
|
|
953
|
+
|
|
853
954
|
if (exitCode === 2) {
|
|
854
955
|
result.summary = `Watchdog check failed (transient): ${stderr || stdout}`;
|
|
855
956
|
result.skipDelivery = true;
|
|
856
957
|
log('debug', `Watchdog check transient failure: ${job.name}`, { exitCode, stderr: stderr.slice(0, 200) });
|
|
857
958
|
|
|
858
|
-
} else if (exitCode === 0 &&
|
|
859
|
-
const completionMsg =
|
|
959
|
+
} else if (exitCode === 0 && terminalPayload) {
|
|
960
|
+
const completionMsg = terminalPayload.kind === 'failed'
|
|
961
|
+
? [
|
|
962
|
+
`⚠️ [watchdog] Task "${job.watchdog_target_label}" ended with failure -- watchdog disarmed`,
|
|
963
|
+
terminalPayload.detail ? `Details: ${terminalPayload.detail}` : null,
|
|
964
|
+
].filter(Boolean).join('\n')
|
|
965
|
+
: [
|
|
966
|
+
`\u2705 [watchdog] Task "${job.watchdog_target_label}" completed -- watchdog disarmed`,
|
|
967
|
+
terminalPayload.detail || null,
|
|
968
|
+
].filter(Boolean).join('\n\n');
|
|
860
969
|
result.summary = completionMsg;
|
|
861
970
|
result.content = completionMsg;
|
|
862
|
-
log('info', `Watchdog: target
|
|
971
|
+
log(terminalPayload.kind === 'failed' ? 'warn' : 'info', `Watchdog: target terminal: ${job.watchdog_target_label}`, {
|
|
972
|
+
jobId: job.id,
|
|
973
|
+
terminalKind: terminalPayload.kind,
|
|
974
|
+
});
|
|
863
975
|
|
|
864
976
|
if (job.watchdog_alert_channel && job.watchdog_alert_target) {
|
|
865
977
|
await handleDelivery({
|
|
@@ -909,10 +1021,12 @@ export async function executeWatchdog(job, ctx, deps) {
|
|
|
909
1021
|
result.skipDelivery = true;
|
|
910
1022
|
|
|
911
1023
|
} else if (exitCode === 0) {
|
|
912
|
-
result.summary =
|
|
1024
|
+
result.summary = stdout
|
|
1025
|
+
? `Watchdog check returned non-terminal output; target still running (${elapsedMin}min elapsed)`
|
|
1026
|
+
: `Watchdog check: target still running (${elapsedMin}min elapsed)`;
|
|
913
1027
|
result.skipDelivery = true;
|
|
914
1028
|
log('debug', `Watchdog: target still running: ${job.watchdog_target_label}`, {
|
|
915
|
-
jobId: job.id, elapsedMin,
|
|
1029
|
+
jobId: job.id, elapsedMin, sawStdout: Boolean(stdout),
|
|
916
1030
|
});
|
|
917
1031
|
} else {
|
|
918
1032
|
result.summary = `Watchdog check command returned unexpected exit code ${exitCode}`;
|
|
@@ -963,27 +1077,14 @@ export async function executeMain(job, ctx, deps) {
|
|
|
963
1077
|
? `[SYSTEM NOTE -- model policy]\nPrefer reasoning depth: ${job.payload_thinking}.\n[END SYSTEM NOTE]\n\n`
|
|
964
1078
|
: '';
|
|
965
1079
|
|
|
966
|
-
|
|
967
|
-
// back through the scheduler post office when it finishes processing.
|
|
968
|
-
let deliveryInstruction = '';
|
|
969
|
-
if (job.delivery_mode && job.delivery_mode !== 'none' && job.delivery_channel && job.delivery_to) {
|
|
970
|
-
deliveryInstruction = [
|
|
971
|
-
'\n[SYSTEM NOTE -- delivery]',
|
|
972
|
-
`When you have completed this task, send your results using the message tool.`,
|
|
973
|
-
`Channel: ${job.delivery_channel}`,
|
|
974
|
-
`Target: ${job.delivery_to}`,
|
|
975
|
-
`Keep the message concise and actionable.`,
|
|
976
|
-
`If there is nothing noteworthy to report, do not send a message.`,
|
|
977
|
-
'[END SYSTEM NOTE]\n',
|
|
978
|
-
].join('\n');
|
|
979
|
-
}
|
|
1080
|
+
const deliveryInstruction = buildFireAndForgetDeliveryInstruction(job);
|
|
980
1081
|
|
|
981
1082
|
const prompt = `${executionNote ? `${executionNote}\n\n` : ''}${modelNote}${deliveryInstruction}${job.payload_message}`;
|
|
982
1083
|
await sendSystemEvent(prompt, 'now');
|
|
983
1084
|
|
|
984
1085
|
result.summary = 'System event dispatched (fire-and-forget)';
|
|
985
1086
|
result.content = job.payload_message;
|
|
986
|
-
result.skipDelivery = true; //
|
|
1087
|
+
result.skipDelivery = true; // Async completion is queued through the scheduler post office
|
|
987
1088
|
result.skipChildren = true;
|
|
988
1089
|
result.skipDequeue = true;
|
|
989
1090
|
|
|
@@ -994,6 +1095,25 @@ export async function executeMain(job, ctx, deps) {
|
|
|
994
1095
|
|
|
995
1096
|
// -- Strategy: Shell -----------------------------------------
|
|
996
1097
|
|
|
1098
|
+
function isCompletionDeliveryWatcherJob(job) {
|
|
1099
|
+
return /^(?:dispatch|chilisaus)-deliver:/.test(String(job?.name || ''));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function isCompletionWatcherPendingTick(shellResult) {
|
|
1103
|
+
return !(shellResult.stdout || '').trim()
|
|
1104
|
+
&& /\bWATCHER_PENDING\b/.test(shellResult.stderr || '');
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function buildCompletionWatcherNoPayloadMessage(job, shellResult) {
|
|
1108
|
+
const statusLabel = shellResult.status === 'ok'
|
|
1109
|
+
? 'completed without a deliverable result'
|
|
1110
|
+
: `failed before producing a deliverable result${shellResult.errorMessage ? ` (${shellResult.errorMessage})` : ''}`;
|
|
1111
|
+
return [
|
|
1112
|
+
`⚠️ Completion delivery watcher for ${job.name} ${statusLabel}.`,
|
|
1113
|
+
'No internal diagnostics were delivered as the completion message; check the scheduler run logs for stderr/details.',
|
|
1114
|
+
].join('\n');
|
|
1115
|
+
}
|
|
1116
|
+
|
|
997
1117
|
export async function executeShell(job, ctx, deps) {
|
|
998
1118
|
const { runShellCommand, normalizeShellResult, log } = deps;
|
|
999
1119
|
const result = makeDefaultResult();
|
|
@@ -1028,18 +1148,61 @@ export async function executeShell(job, ctx, deps) {
|
|
|
1028
1148
|
shell_stderr_bytes: shellResult.stderrBytes,
|
|
1029
1149
|
};
|
|
1030
1150
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1151
|
+
if (isCompletionDeliveryWatcherJob(job)) {
|
|
1152
|
+
const watcherStdout = (shellResult.stdout || '').trim();
|
|
1153
|
+
const watcherStderr = (shellResult.stderr || '').trim();
|
|
1154
|
+
|
|
1155
|
+
if (isCompletionWatcherPendingTick(shellResult)) {
|
|
1156
|
+
result.status = 'skipped';
|
|
1157
|
+
result.summary = 'Completion delivery watcher pending; target session is still running';
|
|
1158
|
+
result.content = '';
|
|
1159
|
+
result.errorMessage = null;
|
|
1160
|
+
result.idemAction = 'release';
|
|
1161
|
+
result.skipDelivery = true;
|
|
1162
|
+
} else if (watcherStdout) {
|
|
1163
|
+
// Completion watcher stdout is the only user-facing contract. Stderr is
|
|
1164
|
+
// diagnostics-only and must never be repackaged as a "successful" final
|
|
1165
|
+
// completion if the watcher suppressed the real payload.
|
|
1166
|
+
result.summary = watcherStdout;
|
|
1167
|
+
result.content = watcherStdout;
|
|
1168
|
+
if (['announce', 'announce-always'].includes(job.delivery_mode)) {
|
|
1169
|
+
result.deliveryOverride = watcherStdout;
|
|
1170
|
+
} else {
|
|
1171
|
+
result.skipDelivery = true;
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
const noPayloadMessage = buildCompletionWatcherNoPayloadMessage(job, shellResult);
|
|
1175
|
+
result.status = 'error';
|
|
1176
|
+
result.summary = noPayloadMessage;
|
|
1177
|
+
result.errorMessage = 'Completion delivery watcher produced no user-facing stdout payload';
|
|
1178
|
+
result.content = noPayloadMessage;
|
|
1179
|
+
if (['announce', 'announce-always'].includes(job.delivery_mode)) {
|
|
1180
|
+
result.deliveryOverride = noPayloadMessage;
|
|
1181
|
+
} else {
|
|
1182
|
+
result.skipDelivery = true;
|
|
1183
|
+
}
|
|
1184
|
+
log('warn', `Completion watcher produced no deliverable stdout: ${job.name}`, {
|
|
1185
|
+
runId: ctx.run.id,
|
|
1186
|
+
shellStatus: shellResult.status,
|
|
1187
|
+
exitCode: shellResult.exitCode,
|
|
1188
|
+
stderrExcerpt: watcherStderr.slice(0, 500),
|
|
1189
|
+
skippedOrDisabled: /\b(?:skipped|disabled)\b/i.test(watcherStderr),
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1038
1192
|
} else {
|
|
1039
|
-
|
|
1193
|
+
// Shell delivery logic: announce-always sends on all results, announce sends on error only
|
|
1194
|
+
const announcePayload = shellResult.deliveryText.trim() ? shellResult.deliveryText : shellResult.errorMessage;
|
|
1195
|
+
if (job.delivery_mode === 'announce-always' && announcePayload) {
|
|
1196
|
+
const prefix = shellResult.status === 'ok' ? '' : `\u26a0\ufe0f Shell job failed: ${job.name}\n\n`;
|
|
1197
|
+
result.deliveryOverride = `${prefix}${announcePayload}`;
|
|
1198
|
+
} else if (job.delivery_mode === 'announce' && shellResult.status !== 'ok' && announcePayload) {
|
|
1199
|
+
result.deliveryOverride = announcePayload;
|
|
1200
|
+
} else {
|
|
1201
|
+
result.skipDelivery = true;
|
|
1202
|
+
}
|
|
1040
1203
|
}
|
|
1041
1204
|
|
|
1042
|
-
log('info', `Shell ${
|
|
1205
|
+
log('info', `Shell ${result.status}: ${job.name}`, {
|
|
1043
1206
|
runId: ctx.run.id,
|
|
1044
1207
|
exitCode: shellResult.exitCode,
|
|
1045
1208
|
signal: shellResult.signal,
|
|
@@ -1055,11 +1218,16 @@ export async function executeAgent(job, ctx, deps) {
|
|
|
1055
1218
|
const {
|
|
1056
1219
|
waitForGateway, updateRunSession, setAgentStatus,
|
|
1057
1220
|
buildJobPrompt, runAgentTurnWithActivityTimeout,
|
|
1221
|
+
// Sanctioned isolated dispatch primitive. Falls back to the activity-aware
|
|
1222
|
+
// runner when callers (e.g. tests) wire only the older name -- both helpers
|
|
1223
|
+
// share the same HTTP-only contract, no subprocess spawn.
|
|
1224
|
+
runIsolatedAgentTurn,
|
|
1058
1225
|
updateContextSummary, releaseDispatch, releaseIdempotencyKey,
|
|
1059
1226
|
updateJob, matchesSentinel, detectTransientError,
|
|
1060
1227
|
listSessions,
|
|
1061
1228
|
sqliteNow, log,
|
|
1062
1229
|
} = deps;
|
|
1230
|
+
const dispatchAgentTurn = runIsolatedAgentTurn || runAgentTurnWithActivityTimeout;
|
|
1063
1231
|
const result = makeDefaultResult();
|
|
1064
1232
|
|
|
1065
1233
|
// Gateway health check
|
|
@@ -1153,7 +1321,12 @@ export async function executeAgent(job, ctx, deps) {
|
|
|
1153
1321
|
}
|
|
1154
1322
|
}
|
|
1155
1323
|
|
|
1156
|
-
|
|
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({
|
|
1157
1330
|
message: prompt,
|
|
1158
1331
|
agentId: job.agent_id || 'main',
|
|
1159
1332
|
sessionKey,
|
package/dispatcher.js
CHANGED
|
@@ -51,7 +51,8 @@ import {
|
|
|
51
51
|
import { buildRetrievalContext } from './retrieval.js';
|
|
52
52
|
import { upsertAgent, setAgentStatus } from './agents.js';
|
|
53
53
|
import {
|
|
54
|
-
runAgentTurnWithActivityTimeout,
|
|
54
|
+
runAgentTurnWithActivityTimeout, runIsolatedAgentTurn,
|
|
55
|
+
sendSystemEvent, getAllSubAgentSessions, listSessions,
|
|
55
56
|
deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
|
|
56
57
|
applyAuthProfileToSessionStore,
|
|
57
58
|
syncAuthStoreToSession,
|
|
@@ -306,6 +307,10 @@ function buildDispatchDeps() {
|
|
|
306
307
|
// Agent
|
|
307
308
|
waitForGateway, updateRunSession, setAgentStatus,
|
|
308
309
|
buildJobPrompt, runAgentTurnWithActivityTimeout,
|
|
310
|
+
// Isolated cron-dispatch primitive: HTTP-only wrapper around the
|
|
311
|
+
// chat-completions API; never forks a sibling openclaw process that
|
|
312
|
+
// could SIGTERM the launchd-tracked gateway parent.
|
|
313
|
+
runIsolatedAgentTurn,
|
|
309
314
|
updateContextSummary, releaseIdempotencyKey,
|
|
310
315
|
matchesSentinel, detectTransientError,
|
|
311
316
|
listSessions,
|
package/gateway.js
CHANGED
|
@@ -9,6 +9,22 @@ const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || 'http://127.0.0.1:18789'
|
|
|
9
9
|
const HOME_DIR = process.env.HOME || homedir();
|
|
10
10
|
export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
|
|
11
11
|
|
|
12
|
+
// -- Isolated dispatch primitive contract --------------------
|
|
13
|
+
//
|
|
14
|
+
// Cron jobs with session_target=isolated must reach the gateway via the
|
|
15
|
+
// public HTTP API only. Forking a sibling `openclaw` process to spawn the
|
|
16
|
+
// session is rejected: in production that primitive has SIGTERM'd the
|
|
17
|
+
// launchd-tracked gateway parent (the child inherits the parent's listening
|
|
18
|
+
// socket on port 18789 and the parent dies), leaving an orphan node process
|
|
19
|
+
// holding the port. See rh-bot.lan zombie-cascade incident report.
|
|
20
|
+
//
|
|
21
|
+
// runIsolatedAgentTurn is the only sanctioned dispatch primitive for
|
|
22
|
+
// session_target=isolated cron jobs. It MUST NOT spawn, fork, or exec any
|
|
23
|
+
// child process. Any future change that needs subprocess execution belongs
|
|
24
|
+
// behind a different, explicitly-named helper so reviewers can keep this
|
|
25
|
+
// contract intact.
|
|
26
|
+
export const ISOLATED_DISPATCH_PRIMITIVE = 'http-chat-completions';
|
|
27
|
+
|
|
12
28
|
let _cachedToken;
|
|
13
29
|
let _tokenLoaded = false;
|
|
14
30
|
|
|
@@ -246,6 +262,29 @@ export async function runAgentTurnWithActivityTimeout(opts) {
|
|
|
246
262
|
}
|
|
247
263
|
}
|
|
248
264
|
|
|
265
|
+
// -- Isolated dispatch primitive -----------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Sanctioned dispatch primitive for session_target=isolated cron jobs.
|
|
269
|
+
*
|
|
270
|
+
* This is a thin wrapper around runAgentTurnWithActivityTimeout that names
|
|
271
|
+
* the contract: HTTP-only request to the gateway, no child process spawn.
|
|
272
|
+
* The scheduler routes every session_target=isolated job through this
|
|
273
|
+
* helper so the no-fork invariant is reviewable at one call site and
|
|
274
|
+
* testable in isolation (see the no-subprocess regression test in test.js).
|
|
275
|
+
*
|
|
276
|
+
* Why a named wrapper instead of calling runAgentTurnWithActivityTimeout
|
|
277
|
+
* directly: the dispatch primitive is the load-bearing surface that the
|
|
278
|
+
* rh-bot.lan zombie-on-port outage cascaded through. A named entry point
|
|
279
|
+
* gives operators and reviewers a single grep target ("runIsolatedAgentTurn")
|
|
280
|
+
* to audit the no-spawn invariant.
|
|
281
|
+
*
|
|
282
|
+
* Accepts the same options as runAgentTurnWithActivityTimeout.
|
|
283
|
+
*/
|
|
284
|
+
export async function runIsolatedAgentTurn(opts) {
|
|
285
|
+
return await runAgentTurnWithActivityTimeout(opts);
|
|
286
|
+
}
|
|
287
|
+
|
|
249
288
|
// -- System Events (main session) ----------------------------
|
|
250
289
|
|
|
251
290
|
/**
|
|
@@ -388,19 +427,43 @@ export function splitMessageForChannel(channel, message) {
|
|
|
388
427
|
return [String(message ?? '')];
|
|
389
428
|
}
|
|
390
429
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
* Automatically resolves delivery aliases (e.g. '@team_room', 'owner_dm').
|
|
394
|
-
*/
|
|
395
|
-
export async function deliverMessage(channel, target, message) {
|
|
396
|
-
let resolvedChannel = channel;
|
|
430
|
+
export function normalizeDeliveryTarget(channel, target) {
|
|
431
|
+
let resolvedChannel = channel || null;
|
|
397
432
|
let resolvedTarget = target;
|
|
398
433
|
|
|
399
|
-
|
|
400
|
-
|
|
434
|
+
const normalizedTarget = typeof resolvedTarget === 'string' ? resolvedTarget.trim() : resolvedTarget;
|
|
435
|
+
if (!normalizedTarget) {
|
|
436
|
+
return { channel: resolvedChannel, target: normalizedTarget || null };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const prefixedMatch = normalizedTarget.match(/^([a-z0-9_-]+)([:/])(.*)$/i);
|
|
440
|
+
if (prefixedMatch) {
|
|
441
|
+
const [, prefix,, rest] = prefixedMatch;
|
|
442
|
+
if (!resolvedChannel || resolvedChannel === prefix) {
|
|
443
|
+
resolvedChannel = prefix;
|
|
444
|
+
resolvedTarget = rest;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
401
448
|
if (resolvedTarget && resolvedChannel && resolvedTarget.startsWith(resolvedChannel + '/')) {
|
|
402
449
|
resolvedTarget = resolvedTarget.slice(resolvedChannel.length + 1);
|
|
403
450
|
}
|
|
451
|
+
if (resolvedTarget && resolvedChannel && resolvedTarget.startsWith(resolvedChannel + ':')) {
|
|
452
|
+
resolvedTarget = resolvedTarget.slice(resolvedChannel.length + 1);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
channel: resolvedChannel,
|
|
457
|
+
target: typeof resolvedTarget === 'string' ? resolvedTarget.trim() : resolvedTarget,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Send a message to a Telegram/channel target via message tool.
|
|
463
|
+
* Automatically resolves delivery aliases (e.g. '@team_room', 'owner_dm').
|
|
464
|
+
*/
|
|
465
|
+
export async function deliverMessage(channel, target, message) {
|
|
466
|
+
let { channel: resolvedChannel, target: resolvedTarget } = normalizeDeliveryTarget(channel, target);
|
|
404
467
|
|
|
405
468
|
// Resolve alias: try '@name' strip and bare name lookup
|
|
406
469
|
if (resolvedTarget) {
|
package/index.d.ts
CHANGED
|
@@ -620,6 +620,7 @@ export const gateway: {
|
|
|
620
620
|
listSessions(opts?: { activeMinutes?: number; limit?: number; kinds?: string[] }): Promise<Record<string, unknown>>;
|
|
621
621
|
getAllSubAgentSessions(activeMinutes?: number): Promise<Array<Record<string, unknown>>>;
|
|
622
622
|
splitMessageForChannel(channel: string, message: string): string[];
|
|
623
|
+
normalizeDeliveryTarget(channel: string | null, target: string | null): { channel: string | null; target: string | null };
|
|
623
624
|
resolveDeliveryAlias(rawTarget: string): { channel: string; target: string } | null;
|
|
624
625
|
deliverMessage(channel: string, target: string, message: string): Promise<DeliveryResult>;
|
|
625
626
|
checkGatewayHealth(): Promise<boolean>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-scheduler",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -42,10 +42,12 @@
|
|
|
42
42
|
"dispatch/deliver-watcher.sh",
|
|
43
43
|
"dispatch/hooks.mjs",
|
|
44
44
|
"dispatch/index.mjs",
|
|
45
|
+
"dispatch/message-input.mjs",
|
|
45
46
|
"dispatch/README.md",
|
|
46
47
|
"dispatch/watcher.mjs",
|
|
47
48
|
"scripts/dispatch-cli-utils.mjs",
|
|
48
49
|
"scripts/inbox-consumer.mjs",
|
|
50
|
+
"scripts/inbox-watcher-guardrail.mjs",
|
|
49
51
|
"scripts/stuck-run-detector.mjs",
|
|
50
52
|
"scripts/stuck-detector.sh",
|
|
51
53
|
"scripts/telegram-webhook-check.mjs",
|
|
@@ -63,3 +63,56 @@ export function resolveDispatchLabel(jobName, labels = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
function normalizeOptionalString(value) {
|
|
68
|
+
if (typeof value !== 'string') return null;
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
return trimmed ? trimmed : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a human-clear, machine-usable delivery surface for dispatch CLI output.
|
|
75
|
+
* Distinguishes between enabled delivery, intentional disablement, and missing/legacy state.
|
|
76
|
+
*/
|
|
77
|
+
export function buildDispatchDeliverySurface(record = {}) {
|
|
78
|
+
const deliverTo = normalizeOptionalString(record.deliverTo ?? record.delivery_to);
|
|
79
|
+
const deliverChannel = normalizeOptionalString(record.deliverChannel ?? record.delivery_channel);
|
|
80
|
+
const deliveryMode = normalizeOptionalString(record.deliveryMode ?? record.delivery_mode);
|
|
81
|
+
const explicitReason = normalizeOptionalString(
|
|
82
|
+
record.deliveryDisabledReason
|
|
83
|
+
?? record.delivery_opt_out_reason
|
|
84
|
+
?? record.reason
|
|
85
|
+
);
|
|
86
|
+
const deliveryDisabled = record.deliveryDisabled === true
|
|
87
|
+
|| (record.delivery_mode === 'none' && !deliverTo)
|
|
88
|
+
|| (record.deliveryMode === 'none' && !deliverTo);
|
|
89
|
+
|
|
90
|
+
if (deliverTo) {
|
|
91
|
+
return {
|
|
92
|
+
status: 'enabled',
|
|
93
|
+
mode: deliveryMode || 'announce',
|
|
94
|
+
channel: deliverChannel,
|
|
95
|
+
target: deliverTo,
|
|
96
|
+
...(typeof record.scheduler === 'boolean' ? { scheduler: record.scheduler } : {}),
|
|
97
|
+
...(typeof record.gateway === 'boolean' ? { gateway: record.gateway } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (deliveryDisabled || explicitReason) {
|
|
102
|
+
return {
|
|
103
|
+
status: 'disabled',
|
|
104
|
+
mode: deliveryMode || null,
|
|
105
|
+
channel: null,
|
|
106
|
+
target: null,
|
|
107
|
+
reason: explicitReason || 'explicit opt-out',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status: 'missing',
|
|
113
|
+
mode: deliveryMode || null,
|
|
114
|
+
channel: null,
|
|
115
|
+
target: null,
|
|
116
|
+
reason: 'delivery target missing or not recorded',
|
|
117
|
+
};
|
|
118
|
+
}
|