openclaw-scheduler 0.2.0 → 0.2.1

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 CHANGED
@@ -2,21 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.2.1] -- 2026-04-01
6
6
 
7
7
  ### Fixed
8
8
  - fix(watcher): exit cleanly when session status=done (PR #1)
9
9
  - fix(watchdog): prevent auto-resolving active sessions with heartbeat + hard ceiling (PR #2)
10
10
  - fix(gateway): reset idle timer while fetch is in flight (PR #3)
11
11
  - fix(watcher): prevent premature kill of active subagent sessions with JSONL activity signal (PR #7)
12
+ - fix(db): add SQLite busy_timeout (5s) to prevent SQLITE_BUSY on CLI + dispatcher contention
13
+ - fix(approvals): prevent double-dispatch race on auto-approved jobs
14
+ - fix(watcher): cap deadline extension at min(timeout, 4h) to prevent zombie watchers
15
+ - fix(runs): preserve empty string summary/error_message (use ?? instead of ||)
16
+ - fix(runs): guard getTimedOutRuns against NULL run_timeout_ms on legacy rows
17
+ - fix(gateway): use byte length for Telegram message chunking (4096-byte limit)
18
+ - fix(jobs): validate schedule_tz as real IANA timezone via Intl.DateTimeFormat
19
+ - fix(dispatcher): wrap delete_after_run cleanup in transaction
20
+ - fix(dispatch): remove 4000-char truncation in formatMessageForDelivery
21
+ - fix(dispatch): add retry exception path delivery announcement
22
+ - fix(dispatch): fix dispatch CLI subcommand routing in bin wrapper
12
23
 
13
24
  ### Added
14
25
  - feat: v0.2 runtime with identity/trust/authorization/evidence/credential handoff (PR #4)
15
26
  - feat: x-openclaw-env-inject header for agent task credentials (PR #5)
27
+ - feat: [IMAGE:path] marker protocol for shell job image attachments
28
+ - feat: auto-delete watcher and watchdog jobs after completion (delete_after_run)
29
+ - feat: enforce delivery_to as required field on job INSERT
30
+ - feat: multi-platform CI (Linux, macOS, Windows)
16
31
  - docs: trust architecture, multi-agent gateway routing, agent adoption files
32
+ - docs: AGENTS.md, CONTEXT.md, JOB-QUICK-REF.md for agent adoption
17
33
 
18
34
  ### Changed
19
35
  - chore: replace non-ASCII characters with ASCII equivalents (PR #6)
36
+ - chore: bump output_excerpt_limit and output_summary_limit defaults to 64KB
20
37
 
21
38
  ## [0.2.0] -- 2026-03-11
22
39
 
@@ -6,7 +6,7 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
6
6
  return resolveDeliveryAlias(target);
7
7
  }
8
8
 
9
- async function handleDelivery(job, content) {
9
+ async function handleDelivery(job, content, opts = {}) {
10
10
  if (!['announce', 'announce-always'].includes(job.delivery_mode)) return;
11
11
  if (!job.delivery_channel && !job.delivery_to) return;
12
12
 
@@ -24,7 +24,7 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
24
24
 
25
25
  try {
26
26
  const subject = (job.name || '').slice(0, 100);
27
- sendMessage({
27
+ const msg = {
28
28
  from_agent: 'scheduler',
29
29
  to_agent: 'main',
30
30
  kind: 'result',
@@ -32,8 +32,15 @@ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
32
32
  body: content,
33
33
  channel,
34
34
  delivery_to: target,
35
- });
36
- log('info', `Enqueued: ${job.name}`, { channel, to: target });
35
+ };
36
+ // Pass image attachment paths so the message consumer can deliver them
37
+ // as photo/file attachments instead of plain text. Scripts signal this
38
+ // by writing [IMAGE:/path/to/file] markers to stdout.
39
+ if (opts.imageAttachments?.length > 0) {
40
+ msg.attachments = opts.imageAttachments;
41
+ }
42
+ sendMessage(msg);
43
+ log('info', `Enqueued: ${job.name}`, { channel, to: target, attachments: opts.imageAttachments?.length || 0 });
37
44
  } catch (err) {
38
45
  log('error', `Delivery enqueue failed: ${job.name}: ${err.message}`);
39
46
  }
@@ -203,15 +203,19 @@ export async function finalizeDispatch(job, ctx, result, deps) {
203
203
  const shouldAnnounce = ['announce', 'announce-always'].includes(job.delivery_mode)
204
204
  && deliveryContent?.trim();
205
205
 
206
+ const deliveryOpts = result.imageAttachments?.length > 0
207
+ ? { imageAttachments: result.imageAttachments }
208
+ : {};
209
+
206
210
  if (shouldAnnounce) {
207
211
  if (result.deliveryOverride) {
208
- await handleDelivery(job, result.deliveryOverride);
212
+ await handleDelivery(job, result.deliveryOverride, deliveryOpts);
209
213
  } else if (result.status === 'error') {
210
214
  const willRetry = (job.max_retries ?? 0) > 0 && (ctx.run.retry_count || 0) < job.max_retries;
211
215
  const retryLabel = willRetry ? 'will retry' : 'no retries configured';
212
- await handleDelivery(job, `\u26a0\ufe0f Job soft-failed (${retryLabel}): ${job.name}\n\n${deliveryContent}`);
216
+ await handleDelivery(job, `\u26a0\ufe0f Job soft-failed (${retryLabel}): ${job.name}\n\n${deliveryContent}`, deliveryOpts);
213
217
  } else {
214
- await handleDelivery(job, deliveryContent);
218
+ await handleDelivery(job, deliveryContent, deliveryOpts);
215
219
  }
216
220
  }
217
221
  }
@@ -1008,6 +1012,9 @@ export async function executeShell(job, ctx, deps) {
1008
1012
  result.summary = shellResult.summary;
1009
1013
  result.errorMessage = shellResult.errorMessage;
1010
1014
  result.content = shellResult.deliveryText;
1015
+ if (shellResult.imageAttachments?.length > 0) {
1016
+ result.imageAttachments = shellResult.imageAttachments;
1017
+ }
1011
1018
  result.runFinishFields = {
1012
1019
  context_summary: shellResult.contextSummary,
1013
1020
  shell_exit_code: shellResult.exitCode,
package/jobs.js CHANGED
@@ -305,17 +305,46 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
305
305
  assertEnum('overlap_policy', merged.overlap_policy || 'skip', VALID_OVERLAP_POLICIES);
306
306
  assertEnum('delivery_mode', merged.delivery_mode || 'announce', VALID_DELIVERY_MODES);
307
307
 
308
+ // INSERT-only: enforce delivery_to for all non-exempt jobs.
309
+ // Exempt from this requirement:
310
+ // - job_type='watchdog' (internal health monitor jobs)
311
+ // - name starts with 'watchdog:' (watchdog jobs by naming convention)
312
+ // - session_target='main' (injects into the main session, no chat routing needed)
313
+ // - delivery_mode='none' (explicitly opted out of delivery)
314
+ if (mode === 'create') {
315
+ const _target = merged.session_target || 'isolated';
316
+ const _dmode = merged.delivery_mode || 'announce';
317
+ const _type = merged.job_type || 'standard';
318
+ const _name = String(merged.name || '');
319
+ const _isExempt =
320
+ _type === 'watchdog' ||
321
+ _name.startsWith('watchdog:') ||
322
+ _target === 'main' ||
323
+ _dmode === 'none';
324
+ if (!_isExempt && (!merged.delivery_to || String(merged.delivery_to).trim() === '')) {
325
+ throw new Error(
326
+ 'delivery_to is required on job insert. Set it to the origin chat_id ' +
327
+ '(e.g. -5240776892 for AI Assisted Degeneracy, or 484946046 for Alex DM).'
328
+ );
329
+ }
330
+ }
331
+
308
332
  // Enforce: delivery_to is required when delivery_mode is explicitly set to
309
333
  // 'announce' or 'announce-always'. Validates on create (when delivery_mode is
310
334
  // explicitly provided) and on update (when delivery_mode is being changed or
311
335
  // the merged record would end up in announce mode without a delivery_to).
336
+ // Exempt: watchdog jobs, session_target='main' (no external chat routing needed).
312
337
  {
313
338
  const modeExplicitlySet = 'delivery_mode' in normalized;
314
339
  const deliveryToExplicitlySet = 'delivery_to' in normalized;
315
340
  const effectiveMode = merged.delivery_mode || 'announce';
316
341
  const isAnnounceMode = ['announce', 'announce-always'].includes(effectiveMode);
342
+ const _announceExempt =
343
+ (merged.job_type || 'standard') === 'watchdog' ||
344
+ String(merged.name || '').startsWith('watchdog:') ||
345
+ (merged.session_target || 'isolated') === 'main';
317
346
 
318
- if (isAnnounceMode && (mode === 'create' || modeExplicitlySet || deliveryToExplicitlySet)) {
347
+ if (isAnnounceMode && !_announceExempt && (mode === 'create' || modeExplicitlySet || deliveryToExplicitlySet)) {
319
348
  // Re-evaluate: if mode is being set to announce OR delivery_to is being
320
349
  // cleared on an announce-mode job, check the merged delivery_to is present.
321
350
  if (!merged.delivery_to || (typeof merged.delivery_to === 'string' && merged.delivery_to.trim() === '')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-scheduler",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -22,7 +22,9 @@ export const SCHEDULER_SCHEMAS = {
22
22
  max_trigger_fanout: { type: 'integer', min: 1, default: 25 },
23
23
  delivery_mode: { type: 'string', enum: ['announce', 'announce-always', 'none'], default: 'announce' },
24
24
  delivery_channel: { type: 'string', nullable: true },
25
- delivery_to: { type: 'string', nullable: true },
25
+ // REQUIRED on insert for non-exempt jobs. Exempt: job_type='watchdog', name starts with 'watchdog:',
26
+ // session_target='main', or delivery_mode='none'. Set to the origin chat_id so results reach the right chat.
27
+ delivery_to: { type: 'string', nullable: true, required: 'non-system jobs', description: 'Target chat/user id for delivery (e.g. telegram chat_id). Required on insert for non-system jobs.' },
26
28
  parent_id: { type: 'string', nullable: true },
27
29
  trigger_on: { type: 'string', enum: ['success', 'failure', 'complete'], nullable: true },
28
30
  trigger_delay_s: { type: 'integer', min: 0, default: 0 },
@@ -38,8 +40,8 @@ export const SCHEDULER_SCHEMAS = {
38
40
  context_retrieval: { type: 'string', enum: ['none', 'recent', 'hybrid'], default: 'none' },
39
41
  context_retrieval_limit: { type: 'integer', min: 1, default: 5 },
40
42
  output_store_limit_bytes: { type: 'integer', min: 128, default: 65536 },
41
- output_excerpt_limit_bytes: { type: 'integer', min: 64, default: 2000 },
42
- output_summary_limit_bytes: { type: 'integer', min: 64, default: 5000 },
43
+ output_excerpt_limit_bytes: { type: 'integer', min: 64, default: 65536 },
44
+ output_summary_limit_bytes: { type: 'integer', min: 64, default: 65536 },
43
45
  output_offload_threshold_bytes: { type: 'integer', min: 128, default: 65536 },
44
46
  preferred_session_key: { type: 'string', nullable: true },
45
47
  auth_profile: { type: 'string', nullable: true, description: 'Auth profile override: null=default, "inherit"=main session profile, or "provider:label"' },
package/schema.sql CHANGED
@@ -81,8 +81,8 @@ CREATE TABLE IF NOT EXISTS jobs (
81
81
 
82
82
  -- Output handling (v14)
83
83
  output_store_limit_bytes INTEGER NOT NULL DEFAULT 65536,
84
- output_excerpt_limit_bytes INTEGER NOT NULL DEFAULT 2000,
85
- output_summary_limit_bytes INTEGER NOT NULL DEFAULT 5000,
84
+ output_excerpt_limit_bytes INTEGER NOT NULL DEFAULT 65536,
85
+ output_summary_limit_bytes INTEGER NOT NULL DEFAULT 65536,
86
86
  output_offload_threshold_bytes INTEGER NOT NULL DEFAULT 65536,
87
87
 
88
88
  -- Session continuity (v9)
@@ -118,7 +118,7 @@ function formatMessageForDelivery(msg, { brand = 'Scheduler' } = {}) {
118
118
  const subject = msg.subject || 'Notification';
119
119
  const header = `${brand} | ${subject} | ${age}`;
120
120
 
121
- return `${header}\n\n${body}`.slice(0, 4000);
121
+ return `${header}\n\n${body}`;
122
122
  }
123
123
 
124
124
  /**
package/shell-result.js CHANGED
@@ -4,8 +4,8 @@ import { getResolvedDbPath } from './db.js';
4
4
  import { ensureArtifactsDir, resolveArtifactsDir } from './paths.js';
5
5
 
6
6
  export const DEFAULT_STORE_LIMIT = 64 * 1024;
7
- export const DEFAULT_EXCERPT_LIMIT = 2000;
8
- export const DEFAULT_SUMMARY_LIMIT = 5000;
7
+ export const DEFAULT_EXCERPT_LIMIT = 64 * 1024;
8
+ export const DEFAULT_SUMMARY_LIMIT = 64 * 1024;
9
9
  export const DEFAULT_OFFLOAD_THRESHOLD = 64 * 1024;
10
10
 
11
11
  function toText(value) {
@@ -92,7 +92,24 @@ export function normalizeShellResult(
92
92
  artifactsDir = resolveArtifactsDir({ dbPath: getResolvedDbPath() }),
93
93
  } = {}
94
94
  ) {
95
- const stdoutText = toText(stdout);
95
+ const rawStdout = toText(stdout);
96
+
97
+ // Extract [IMAGE:path] markers from stdout before processing.
98
+ // Scripts can signal image attachments by writing lines like:
99
+ // [IMAGE:/tmp/chart.png]
100
+ // [IMAGE:/tmp/report.pdf]
101
+ // Extracted paths are passed through to the delivery layer.
102
+ const IMAGE_MARKER_RE = /^\[IMAGE:(\/[^\]\n]+)\]$/gm;
103
+ const imageAttachments = [];
104
+ let match;
105
+ while ((match = IMAGE_MARKER_RE.exec(rawStdout)) !== null) {
106
+ imageAttachments.push(match[1]);
107
+ }
108
+ // Strip markers from stdout so they don't appear in delivery text
109
+ const stdoutText = imageAttachments.length > 0
110
+ ? rawStdout.replace(IMAGE_MARKER_RE, '').replace(/\n{3,}/g, '\n\n').trim()
111
+ : rawStdout;
112
+
96
113
  const stderrText = toText(stderr);
97
114
  const stdoutBytes = textBytes(stdoutText);
98
115
  const stderrBytes = textBytes(stderrText);
@@ -144,6 +161,7 @@ export function normalizeShellResult(
144
161
  stderrTruncated: stderrStored.truncated,
145
162
  summary: truncateText(previewText, summaryLimit).text,
146
163
  deliveryText: previewText,
164
+ imageAttachments,
147
165
  errorMessage,
148
166
  contextSummary: {
149
167
  shell_result: {