openclaw-scheduler 0.2.3 → 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.
@@ -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
+ }
@@ -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 { resolveCompletionDelivery } from './completion.mjs';
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
- // Ensures labels.json is updated and a delivery attempt is made even when killed.
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} -- marking as interrupted\n`);
876
- // Try to fetch the latest result before dying
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
- // deliverResult calls process.exit(0) internally
883
- deliverResult(label, lastKnownReply, 'interrupted by watcher timeout', lastKnownCompletion);
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 -- this is a spawn failure.
1030
- // Causes: auth timeout, quota exhaustion, gateway error at spawn time.
1031
- if (!sessionEverFound && (status.status === 'done' || status.status === 'spawn-warning' || status.status === 'error')) {
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?.deliveryText) {
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
- process.stderr.write(`[watcher] [${label}] session auto-resolved as interrupted -- work may be incomplete\n`);
1117
- process.stdout.write(
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
- const result = dispatch('result', ['--label', label]);
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
- if (_sid2a && isSessionCleanlyFinished(_sid2a, _adir2a)) {
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?.deliveryText) {
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
- process.stdout.write(
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?.deliveryText) {
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
- process.stdout.write(`⏱ dispatch [${label}] timed out after ${timeoutS}s -- token telemetry unavailable; no steer/kill attempted\n`);
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?.deliveryText) {
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
- process.stdout.write(`⏱ dispatch [${label}] timed out after ${timeoutS}s -- token telemetry lost; no steer/kill attempted\n`);
1294
- process.exit(1);
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?.deliveryText) {
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?.deliveryText) {
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?.deliveryText) {
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
- process.stdout.write(`⏱ dispatch [${label}] killed after steer attempts -- no result captured\n`);
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
- process.stdout.write(`⏱ dispatch [${label}] timed out after ${timeoutS}s -- session killed after steer attempts\n`);
1459
- process.exit(1);
1529
+ const timeoutResult = dispatch('result', ['--label', label]);
1530
+ emitTimeoutOutcome(label, `⏱ dispatch [${label}] timed out after ${timeoutS}s -- session killed after steer attempts`, timeoutResult);
@@ -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 && stdout) {
859
- const completionMsg = `\u2705 [watchdog] Task "${job.watchdog_target_label}" completed -- watchdog disarmed`;
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 completed: ${job.watchdog_target_label}`, { jobId: job.id });
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 = `Watchdog check: target still running (${elapsedMin}min elapsed)`;
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
- // Build the delivery reply-to instruction so the agent can send results
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; // Agent handles delivery via message tool
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
- * Send a message to a Telegram/channel target via message tool.
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
- // Strip channel prefix from target if present (e.g., "telegram/123456789" -> "123456789")
400
- // Some jobs store the channel in the delivery_to field as "channel/id".
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",
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",
@@ -37,14 +37,17 @@
37
37
  "files": [
38
38
  "bin",
39
39
  "dispatch/529-recovery.mjs",
40
+ "dispatch/completion.mjs",
40
41
  "dispatch/config.example.json",
41
42
  "dispatch/deliver-watcher.sh",
42
43
  "dispatch/hooks.mjs",
43
44
  "dispatch/index.mjs",
45
+ "dispatch/message-input.mjs",
44
46
  "dispatch/README.md",
45
47
  "dispatch/watcher.mjs",
46
48
  "scripts/dispatch-cli-utils.mjs",
47
49
  "scripts/inbox-consumer.mjs",
50
+ "scripts/inbox-watcher-guardrail.mjs",
48
51
  "scripts/stuck-run-detector.mjs",
49
52
  "scripts/stuck-detector.sh",
50
53
  "scripts/telegram-webhook-check.mjs",