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.
@@ -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
 
@@ -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
- // Shell delivery logic: announce-always sends on all results, announce sends on error only
1032
- const announcePayload = shellResult.deliveryText.trim() ? shellResult.deliveryText : shellResult.errorMessage;
1033
- if (job.delivery_mode === 'announce-always' && announcePayload) {
1034
- const prefix = shellResult.status === 'ok' ? '' : `\u26a0\ufe0f Shell job failed: ${job.name}\n\n`;
1035
- result.deliveryOverride = `${prefix}${announcePayload}`;
1036
- } else if (job.delivery_mode === 'announce' && shellResult.status !== 'ok' && announcePayload) {
1037
- result.deliveryOverride = announcePayload;
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
- result.skipDelivery = true;
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 ${shellResult.status}: ${job.name}`, {
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
- const turnResult = await runAgentTurnWithActivityTimeout({
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, sendSystemEvent, getAllSubAgentSessions, listSessions,
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
- * 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;
430
+ export function normalizeDeliveryTarget(channel, target) {
431
+ let resolvedChannel = channel || null;
397
432
  let resolvedTarget = target;
398
433
 
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".
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.4",
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
+ }