openclaw-scheduler 0.2.4 → 0.2.5
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 +1035 -34
- package/dispatch/hooks.mjs +17 -5
- package/dispatch/index.mjs +573 -217
- package/dispatch/message-input.mjs +67 -0
- package/dispatch/watcher.mjs +110 -39
- package/dispatcher-strategies.js +121 -20
- package/gateway.js +32 -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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { isatty } from 'node:tty';
|
|
3
|
+
|
|
4
|
+
function normalizeFlagValue(value, flagName) {
|
|
5
|
+
if (value === undefined || value === null) return null;
|
|
6
|
+
if (value === true) throw new Error(`${flagName} requires a value`);
|
|
7
|
+
return String(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function resolveMessageInput({
|
|
11
|
+
message = null,
|
|
12
|
+
messageFile = null,
|
|
13
|
+
messageEnv = null,
|
|
14
|
+
messageStdin = false,
|
|
15
|
+
stdinIsTTY = isatty(0),
|
|
16
|
+
env = process.env,
|
|
17
|
+
readFile = (path) => readFileSync(path, 'utf8'),
|
|
18
|
+
readStdin = () => readFileSync(0, 'utf8'),
|
|
19
|
+
} = {}) {
|
|
20
|
+
const directMessage = normalizeFlagValue(message, '--message');
|
|
21
|
+
const filePath = normalizeFlagValue(messageFile, '--message-file');
|
|
22
|
+
const envVar = normalizeFlagValue(messageEnv, '--message-env');
|
|
23
|
+
const wantsStdin = messageStdin === true || messageStdin === 'true';
|
|
24
|
+
|
|
25
|
+
const explicitSources = [];
|
|
26
|
+
if (directMessage !== null) explicitSources.push('--message');
|
|
27
|
+
if (filePath !== null) explicitSources.push('--message-file');
|
|
28
|
+
if (envVar !== null) explicitSources.push('--message-env');
|
|
29
|
+
if (wantsStdin) explicitSources.push('--message-stdin');
|
|
30
|
+
|
|
31
|
+
if (explicitSources.length > 1) {
|
|
32
|
+
throw new Error(`choose only one of ${explicitSources.join(', ')} for the prompt source`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (directMessage !== null) return directMessage;
|
|
36
|
+
|
|
37
|
+
if (filePath !== null) {
|
|
38
|
+
if (filePath === '-') {
|
|
39
|
+
if (stdinIsTTY === true) throw new Error('--message-file - requires piped stdin');
|
|
40
|
+
return readStdin();
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return readFile(filePath);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`--message-file: could not read file: ${err.message}`, { cause: err });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (envVar !== null) {
|
|
50
|
+
if (!Object.prototype.hasOwnProperty.call(env, envVar)) {
|
|
51
|
+
throw new Error(`--message-env: environment variable ${envVar} is not set`);
|
|
52
|
+
}
|
|
53
|
+
return String(env[envVar] ?? '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (wantsStdin) {
|
|
57
|
+
if (stdinIsTTY === true) throw new Error('--message-stdin requires piped stdin');
|
|
58
|
+
return readStdin();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stdinIsTTY !== true) {
|
|
62
|
+
const pipedText = readStdin();
|
|
63
|
+
return pipedText.length > 0 ? pipedText : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/dispatch/watcher.mjs
CHANGED
|
@@ -31,7 +31,11 @@ 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 {
|
|
34
|
+
import {
|
|
35
|
+
extractTerminalAssistantReplyFromEntries,
|
|
36
|
+
hasCompletionSignal,
|
|
37
|
+
resolveCompletionDelivery,
|
|
38
|
+
} from './completion.mjs';
|
|
35
39
|
import { sendMessage } from '../messages.js';
|
|
36
40
|
|
|
37
41
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -586,6 +590,28 @@ function readJsonlLastLines(sessionId, agentDir = 'main', n = 3) {
|
|
|
586
590
|
}
|
|
587
591
|
}
|
|
588
592
|
|
|
593
|
+
function readJsonlTailEntries(sessionId, agentDir = 'main', n = 200) {
|
|
594
|
+
return readJsonlLastLines(sessionId, agentDir, n);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function getSessionTerminalReply(sessionId, agentDir = 'main') {
|
|
598
|
+
const entries = readJsonlTailEntries(sessionId, agentDir, 200);
|
|
599
|
+
return extractTerminalAssistantReplyFromEntries(entries);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function formatDiagnosticSnippet(reply) {
|
|
603
|
+
if (!reply || typeof reply !== 'string') return '';
|
|
604
|
+
const normalized = reply.trim();
|
|
605
|
+
if (!normalized) return '';
|
|
606
|
+
|
|
607
|
+
const maxLen = 1200;
|
|
608
|
+
const clipped = normalized.length > maxLen
|
|
609
|
+
? normalized.slice(0, maxLen) + '\n\n..[truncated]'
|
|
610
|
+
: normalized;
|
|
611
|
+
|
|
612
|
+
return `\n\nLast assistant report observed:\n${clipped}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
589
615
|
/**
|
|
590
616
|
* Check if a session is currently mid-turn by inspecting its JSONL tail.
|
|
591
617
|
* Returns a reason string if mid-turn is detected, null if safe to proceed.
|
|
@@ -796,6 +822,21 @@ function deliverResult(label, lastReply, fallbackSummary, completionPayload = nu
|
|
|
796
822
|
process.exit(0);
|
|
797
823
|
}
|
|
798
824
|
|
|
825
|
+
function emitInterruptedOutcome(label, summary, result = null) {
|
|
826
|
+
process.stderr.write(`[watcher] [${label}] session auto-resolved as interrupted -- work may be incomplete\n`);
|
|
827
|
+
markLabelError(label, summary || 'interrupted: session went idle without calling done');
|
|
828
|
+
process.stdout.write(
|
|
829
|
+
`⚠️ dispatch [${label}] session went idle before completing -- work may be incomplete` +
|
|
830
|
+
`${formatDiagnosticSnippet(result?.diagnosticReply || result?.lastReply || null)}\n`
|
|
831
|
+
);
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function emitTimeoutOutcome(label, message, result = null) {
|
|
836
|
+
process.stdout.write(`${message}${formatDiagnosticSnippet(result?.diagnosticReply || result?.lastReply || null)}\n`);
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
|
|
799
840
|
// -- Watcher heartbeat interval ref --------------------------------------
|
|
800
841
|
// Populated after label is validated (in main body). Cleared on exit.
|
|
801
842
|
// The interval writes lastPing to labels.json so the watchdog guard in
|
|
@@ -870,17 +911,47 @@ let lastKnownReply = null;
|
|
|
870
911
|
let lastKnownCompletion = null;
|
|
871
912
|
|
|
872
913
|
// -- SIGTERM handler (scheduler kills watcher with SIGTERM before SIGKILL) --
|
|
873
|
-
//
|
|
914
|
+
// Hand off to a fresh watcher instead of converting the kill into a fake success.
|
|
874
915
|
process.on('SIGTERM', () => {
|
|
875
|
-
process.stderr.write(`[watcher] SIGTERM received for ${label} --
|
|
876
|
-
|
|
916
|
+
process.stderr.write(`[watcher] SIGTERM received for ${label} -- attempting watcher handoff\n`);
|
|
917
|
+
|
|
918
|
+
let latestStatus = null;
|
|
919
|
+
try {
|
|
920
|
+
latestStatus = dispatch('status', ['--label', label]);
|
|
921
|
+
} catch {}
|
|
922
|
+
|
|
877
923
|
try {
|
|
878
924
|
const result = dispatch('result', ['--label', label]);
|
|
879
925
|
if (result?.lastReply) lastKnownReply = result.lastReply;
|
|
880
926
|
if (result?.completion) lastKnownCompletion = result.completion;
|
|
881
927
|
} catch {}
|
|
882
|
-
|
|
883
|
-
|
|
928
|
+
|
|
929
|
+
if (latestStatus?.status === 'done') {
|
|
930
|
+
deliverResult(label, lastKnownReply, latestStatus.summary || null, lastKnownCompletion || latestStatus?.completion || null);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (latestStatus?.status === 'interrupted') {
|
|
934
|
+
markLabelError(label, latestStatus.summary || 'interrupted: session went idle without calling done');
|
|
935
|
+
process.exit(1);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (latestStatus?.status && latestStatus.status !== 'running') {
|
|
939
|
+
const summary = latestStatus.error || latestStatus.summary || `terminal failure (${latestStatus.status})`;
|
|
940
|
+
markLabelError(label, summary);
|
|
941
|
+
process.stdout.write(`🌶️ *dispatch* [${label}] failed\nSummary: ${summary}\n`);
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const handoff = dispatch('watcher-handoff', ['--label', label, '--reason', 'sigterm']);
|
|
946
|
+
if (handoff?.ok && (handoff.scheduled || handoff.reason === 'label already terminal' || handoff.reason === 'delivery disabled for this label')) {
|
|
947
|
+
process.stderr.write(`[watcher] SIGTERM handoff ${handoff.scheduled ? 'scheduled' : 'skipped'} for ${label}\n`);
|
|
948
|
+
process.exit(0);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const failureSummary = 'interrupted by watcher timeout (handoff failed)';
|
|
952
|
+
markLabelError(label, failureSummary);
|
|
953
|
+
process.stdout.write(`⚠️ dispatch [${label}] watcher interrupted and handoff failed\nSummary: ${failureSummary}\n`);
|
|
954
|
+
process.exit(1);
|
|
884
955
|
});
|
|
885
956
|
|
|
886
957
|
// -- Rolling deadline vars ------------------------------------
|
|
@@ -1024,11 +1095,21 @@ while (Date.now() < deadline) {
|
|
|
1024
1095
|
|
|
1025
1096
|
// -- Path 1: status auto-resolved to done ------------------
|
|
1026
1097
|
if (status.status !== 'running') {
|
|
1098
|
+
const terminalResult = dispatch('result', ['--label', label]);
|
|
1099
|
+
const terminalCompletion = terminalResult?.completion || status?.completion || null;
|
|
1100
|
+
const hasTerminalCompletionEvidence = Boolean(
|
|
1101
|
+
terminalResult?.lastReply
|
|
1102
|
+
|| terminalResult?.completion?.deliveryText
|
|
1103
|
+
|| terminalResult?.completion?.summary
|
|
1104
|
+
|| status?.completion?.deliveryText
|
|
1105
|
+
|| status?.completion?.summary
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1027
1108
|
// -- Spawn failure detection -----------------------------------------
|
|
1028
1109
|
// If the session was auto-resolved to 'done' (or 'spawn-warning') but was
|
|
1029
|
-
// never seen in the gateway, it never ran --
|
|
1030
|
-
//
|
|
1031
|
-
if (!sessionEverFound && (status.status === '
|
|
1110
|
+
// never seen in the gateway, it never ran -- unless a terminal completion
|
|
1111
|
+
// payload/reply proves the work already finished before this watcher saw it.
|
|
1112
|
+
if (!sessionEverFound && (status.status === 'spawn-warning' || status.status === 'error' || (status.status === 'done' && !hasTerminalCompletionEvidence))) {
|
|
1032
1113
|
const spawnErrMsg =
|
|
1033
1114
|
`[dispatch] SPAWN FAILURE: session ${status.sessionKey || '(unknown)'} never appeared ` +
|
|
1034
1115
|
`in gateway -- spawn likely failed (auth timeout, quota, or gateway error). Label: ${label}`;
|
|
@@ -1055,7 +1136,7 @@ while (Date.now() < deadline) {
|
|
|
1055
1136
|
// If the session DID produce a lastReply before being killed, deliver it normally.
|
|
1056
1137
|
if (sessionEverFound && isGatewayRestartKill(status.summary)) {
|
|
1057
1138
|
const gwCheckResult = dispatch('result', ['--label', label]);
|
|
1058
|
-
if (!gwCheckResult?.lastReply && !gwCheckResult?.completion
|
|
1139
|
+
if (!gwCheckResult?.lastReply && !hasCompletionSignal(gwCheckResult?.completion)) {
|
|
1059
1140
|
// No result captured -- session was killed before completing
|
|
1060
1141
|
const retryCount = getGwRestartRetryCount(label);
|
|
1061
1142
|
if (retryCount >= MAX_GW_RESTART_RETRIES) {
|
|
@@ -1113,12 +1194,8 @@ while (Date.now() < deadline) {
|
|
|
1113
1194
|
//
|
|
1114
1195
|
// NOTE: Always resolve as 'interrupted', never 'done'. Only agent-side cmdDone may set status=done.
|
|
1115
1196
|
if (status.status === 'interrupted') {
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
`⚠️ dispatch [${label}] session went idle before completing -- work may be incomplete\n`
|
|
1119
|
-
);
|
|
1120
|
-
markLabelError(label, status.summary || 'interrupted: session went idle without calling done');
|
|
1121
|
-
process.exit(1);
|
|
1197
|
+
const interruptedResult = dispatch('result', ['--label', label]);
|
|
1198
|
+
emitInterruptedOutcome(label, status.summary, interruptedResult);
|
|
1122
1199
|
}
|
|
1123
1200
|
|
|
1124
1201
|
// Reset 529 retryCount on successful completion
|
|
@@ -1129,8 +1206,7 @@ while (Date.now() < deadline) {
|
|
|
1129
1206
|
process.stderr.write(`[watcher] [${label}] completed after ${currentRetryCount} retry(ies), reset retryCount\n`);
|
|
1130
1207
|
}
|
|
1131
1208
|
}
|
|
1132
|
-
|
|
1133
|
-
deliverResult(label, result?.lastReply, status.summary, result?.completion || status?.completion || null);
|
|
1209
|
+
deliverResult(label, terminalResult?.lastReply, status.summary, terminalCompletion);
|
|
1134
1210
|
}
|
|
1135
1211
|
|
|
1136
1212
|
// -- Path 2a: stop_reason early delivery (clean end_turn) --
|
|
@@ -1141,10 +1217,11 @@ while (Date.now() < deadline) {
|
|
|
1141
1217
|
const _e2a = getSessionStoreEntry(status.sessionKey);
|
|
1142
1218
|
const _sid2a = _e2a?.sessionId || null;
|
|
1143
1219
|
const _adir2a = (status.sessionKey.split(':')[1]) || 'main';
|
|
1144
|
-
|
|
1220
|
+
const terminalJsonlReply = _sid2a ? getSessionTerminalReply(_sid2a, _adir2a) : null;
|
|
1221
|
+
if (_sid2a && terminalJsonlReply && isSessionCleanlyFinished(_sid2a, _adir2a)) {
|
|
1145
1222
|
process.stderr.write(`[watcher] stop_reason=end_turn detected -- delivering early\n`);
|
|
1146
1223
|
const result = dispatch('result', ['--label', label]);
|
|
1147
|
-
deliverResult(label, result?.lastReply, 'completed (stop_reason=end_turn)', result?.completion || null);
|
|
1224
|
+
deliverResult(label, result?.lastReply || terminalJsonlReply, 'completed (stop_reason=end_turn)', result?.completion || null);
|
|
1148
1225
|
// deliverResult exits
|
|
1149
1226
|
}
|
|
1150
1227
|
}
|
|
@@ -1158,7 +1235,7 @@ while (Date.now() < deadline) {
|
|
|
1158
1235
|
const ageMs = status.liveness?.ageMs;
|
|
1159
1236
|
if (ageMs != null && ageMs >= IDLE_RESULT_CHECK_MS) {
|
|
1160
1237
|
const result = dispatch('result', ['--label', label]);
|
|
1161
|
-
if (result?.lastReply || result?.completion
|
|
1238
|
+
if (result?.lastReply || hasCompletionSignal(result?.completion)) {
|
|
1162
1239
|
deliverResult(label, result?.lastReply || null, null, result?.completion || null);
|
|
1163
1240
|
}
|
|
1164
1241
|
}
|
|
@@ -1183,11 +1260,7 @@ if (finalStatus?.status === 'done') {
|
|
|
1183
1260
|
// If status is interrupted (auto-resolved as incomplete), exit non-zero
|
|
1184
1261
|
if (finalStatus?.status === 'interrupted') {
|
|
1185
1262
|
process.stderr.write(`[watcher] [${label}] final status=interrupted -- session idle without completion\n`);
|
|
1186
|
-
|
|
1187
|
-
`⚠️ dispatch [${label}] session went idle before completing -- work may be incomplete\n`
|
|
1188
|
-
);
|
|
1189
|
-
markLabelError(label, finalStatus?.summary || 'interrupted: session went idle without calling done');
|
|
1190
|
-
process.exit(1);
|
|
1263
|
+
emitInterruptedOutcome(label, finalStatus?.summary, finalResult);
|
|
1191
1264
|
}
|
|
1192
1265
|
|
|
1193
1266
|
// -- Token-based activity check before steering ----------------------------
|
|
@@ -1237,7 +1310,7 @@ if (sessionInternalId) {
|
|
|
1237
1310
|
// If the session already completed (gateway pruned it -> null tokens), exit cleanly.
|
|
1238
1311
|
if (statusAtDeadline?.status === 'done' || baselineTokens === null) {
|
|
1239
1312
|
const r = dispatch('result', ['--label', label]);
|
|
1240
|
-
if (r?.lastReply || r?.completion
|
|
1313
|
+
if (r?.lastReply || hasCompletionSignal(r?.completion)) {
|
|
1241
1314
|
// deliverResult calls process.exit(0) internally
|
|
1242
1315
|
deliverResult(label, r?.lastReply || null, statusAtDeadline?.summary || null, r?.completion || null);
|
|
1243
1316
|
}
|
|
@@ -1255,8 +1328,7 @@ if (statusAtDeadline?.status === 'done' || baselineTokens === null) {
|
|
|
1255
1328
|
// Session truly not found -- telemetry unavailable, exit
|
|
1256
1329
|
process.stderr.write(`[watcher] token telemetry unavailable for ${label}; session not in store\n`);
|
|
1257
1330
|
markLabelError(label, `timed out after ${timeoutS}s -- token telemetry unavailable`);
|
|
1258
|
-
|
|
1259
|
-
process.exit(1);
|
|
1331
|
+
emitTimeoutOutcome(label, `⏱ dispatch [${label}] timed out after ${timeoutS}s -- token telemetry unavailable; no steer/kill attempted`, r);
|
|
1260
1332
|
}
|
|
1261
1333
|
// Session IS in store but no tokens -- mid-tool-call, fall through to activity window
|
|
1262
1334
|
// Use updatedAt as activity signal instead of tokens
|
|
@@ -1277,7 +1349,7 @@ while (Date.now() - flatSince < FLAT_WINDOW_MS) {
|
|
|
1277
1349
|
deliverResult(label, r?.lastReply || null, st.summary, r?.completion || st?.completion || null);
|
|
1278
1350
|
}
|
|
1279
1351
|
const r2 = dispatch('result', ['--label', label]);
|
|
1280
|
-
if (r2?.lastReply || r2?.completion
|
|
1352
|
+
if (r2?.lastReply || hasCompletionSignal(r2?.completion)) {
|
|
1281
1353
|
// deliverResult calls process.exit(0) internally
|
|
1282
1354
|
deliverResult(label, r2?.lastReply || null, null, r2?.completion || null);
|
|
1283
1355
|
}
|
|
@@ -1290,8 +1362,8 @@ while (Date.now() - flatSince < FLAT_WINDOW_MS) {
|
|
|
1290
1362
|
if (!entry) {
|
|
1291
1363
|
process.stderr.write(`[watcher] token telemetry lost for ${label}; session gone from store\n`);
|
|
1292
1364
|
markLabelError(label, `timed out after ${timeoutS}s -- token telemetry lost`);
|
|
1293
|
-
|
|
1294
|
-
|
|
1365
|
+
const tokenLostResult = dispatch('result', ['--label', label]);
|
|
1366
|
+
emitTimeoutOutcome(label, `⏱ dispatch [${label}] timed out after ${timeoutS}s -- token telemetry lost; no steer/kill attempted`, tokenLostResult);
|
|
1295
1367
|
}
|
|
1296
1368
|
// Still in store -- check if updatedAt advanced (tool call still running)
|
|
1297
1369
|
// Normalize: updatedAt may be seconds or milliseconds depending on agent framework version
|
|
@@ -1371,7 +1443,7 @@ if (sessionInternalId) {
|
|
|
1371
1443
|
deliverResult(label, rExt?.lastReply || null, stExt.summary, rExt?.completion || stExt?.completion || null);
|
|
1372
1444
|
}
|
|
1373
1445
|
const rExt2 = dispatch('result', ['--label', label]);
|
|
1374
|
-
if (rExt2?.lastReply || rExt2?.completion
|
|
1446
|
+
if (rExt2?.lastReply || hasCompletionSignal(rExt2?.completion)) {
|
|
1375
1447
|
// deliverResult calls process.exit(0) internally
|
|
1376
1448
|
deliverResult(label, rExt2?.lastReply || null, null, rExt2?.completion || null);
|
|
1377
1449
|
}
|
|
@@ -1428,7 +1500,7 @@ for (const round of steerRounds) {
|
|
|
1428
1500
|
deliverResult(label, r3?.lastReply || null, st2.summary, r3?.completion || st2?.completion || null);
|
|
1429
1501
|
}
|
|
1430
1502
|
const r3 = dispatch('result', ['--label', label]);
|
|
1431
|
-
if (r3?.lastReply || r3?.completion
|
|
1503
|
+
if (r3?.lastReply || hasCompletionSignal(r3?.completion)) {
|
|
1432
1504
|
// deliverResult calls process.exit(0) internally
|
|
1433
1505
|
deliverResult(label, r3?.lastReply || null, null, r3?.completion || null);
|
|
1434
1506
|
}
|
|
@@ -1443,17 +1515,16 @@ for (const round of steerRounds) {
|
|
|
1443
1515
|
if (st3?.status === 'done') {
|
|
1444
1516
|
// Check if a result was captured before marking as error
|
|
1445
1517
|
const r4 = dispatch('result', ['--label', label]);
|
|
1446
|
-
if (r4?.lastReply || r4?.completion
|
|
1518
|
+
if (r4?.lastReply || hasCompletionSignal(r4?.completion)) {
|
|
1447
1519
|
deliverResult(label, r4?.lastReply || null, st3.summary, r4?.completion || st3?.completion || null); // deliverResult calls process.exit(0)
|
|
1448
1520
|
}
|
|
1449
1521
|
markLabelError(label, 'timed out -- killed after steer attempts (no result captured)');
|
|
1450
|
-
|
|
1451
|
-
process.exit(1);
|
|
1522
|
+
emitTimeoutOutcome(label, `⏱ dispatch [${label}] killed after steer attempts -- no result captured`, r4);
|
|
1452
1523
|
}
|
|
1453
1524
|
}
|
|
1454
1525
|
}
|
|
1455
1526
|
}
|
|
1456
1527
|
|
|
1457
1528
|
markLabelError(label, `timed out after ${timeoutS}s -- killed after steer attempts`);
|
|
1458
|
-
|
|
1459
|
-
|
|
1529
|
+
const timeoutResult = dispatch('result', ['--label', label]);
|
|
1530
|
+
emitTimeoutOutcome(label, `⏱ dispatch [${label}] timed out after ${timeoutS}s -- session killed after steer attempts`, timeoutResult);
|
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
|
|
package/gateway.js
CHANGED
|
@@ -388,19 +388,43 @@ export function splitMessageForChannel(channel, message) {
|
|
|
388
388
|
return [String(message ?? '')];
|
|
389
389
|
}
|
|
390
390
|
|
|
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;
|
|
391
|
+
export function normalizeDeliveryTarget(channel, target) {
|
|
392
|
+
let resolvedChannel = channel || null;
|
|
397
393
|
let resolvedTarget = target;
|
|
398
394
|
|
|
399
|
-
|
|
400
|
-
|
|
395
|
+
const normalizedTarget = typeof resolvedTarget === 'string' ? resolvedTarget.trim() : resolvedTarget;
|
|
396
|
+
if (!normalizedTarget) {
|
|
397
|
+
return { channel: resolvedChannel, target: normalizedTarget || null };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const prefixedMatch = normalizedTarget.match(/^([a-z0-9_-]+)([:/])(.*)$/i);
|
|
401
|
+
if (prefixedMatch) {
|
|
402
|
+
const [, prefix,, rest] = prefixedMatch;
|
|
403
|
+
if (!resolvedChannel || resolvedChannel === prefix) {
|
|
404
|
+
resolvedChannel = prefix;
|
|
405
|
+
resolvedTarget = rest;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
401
409
|
if (resolvedTarget && resolvedChannel && resolvedTarget.startsWith(resolvedChannel + '/')) {
|
|
402
410
|
resolvedTarget = resolvedTarget.slice(resolvedChannel.length + 1);
|
|
403
411
|
}
|
|
412
|
+
if (resolvedTarget && resolvedChannel && resolvedTarget.startsWith(resolvedChannel + ':')) {
|
|
413
|
+
resolvedTarget = resolvedTarget.slice(resolvedChannel.length + 1);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
channel: resolvedChannel,
|
|
418
|
+
target: typeof resolvedTarget === 'string' ? resolvedTarget.trim() : resolvedTarget,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Send a message to a Telegram/channel target via message tool.
|
|
424
|
+
* Automatically resolves delivery aliases (e.g. '@team_room', 'owner_dm').
|
|
425
|
+
*/
|
|
426
|
+
export async function deliverMessage(channel, target, message) {
|
|
427
|
+
let { channel: resolvedChannel, target: resolvedTarget } = normalizeDeliveryTarget(channel, target);
|
|
404
428
|
|
|
405
429
|
// Resolve alias: try '@name' strip and bare name lookup
|
|
406
430
|
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.5",
|
|
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",
|