openclaw-scheduler 0.2.0

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.
Files changed (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. package/v02-runtime.js +599 -0
@@ -0,0 +1,24 @@
1
+ {
2
+ "_comment": "Copy this to config.json and customize. All fields are optional.",
3
+
4
+ "name": "dispatch",
5
+ "_name": "Branding name used in notifications and log output. e.g. 'my-bot' or 'dispatch'",
6
+
7
+ "startupGraceMs": 90000,
8
+ "_startupGraceMs": "How long after spawn before stuck-detector and auto-resolve kick in (ms). Default: 90000 (90s)",
9
+
10
+ "deliver_watcher_ttl_hours": 48,
11
+ "_deliver_watcher_ttl_hours": "TTL (in hours) for scheduler-registered deliver-watcher jobs. These jobs are transient -- they auto-prune once delivery is confirmed. Default: 48. Lower values (e.g. 4) aggressively prune; higher values (e.g. 72) retain audit history longer.",
12
+
13
+ "watchdogIntervalCron": "*/5 * * * *",
14
+ "_watchdogIntervalCron": "Cron schedule for the built-in watchdog check job. Default: every 5 minutes.",
15
+
16
+ "watchdogTimeoutMin": 10,
17
+ "_watchdogTimeoutMin": "Timeout in minutes for the watchdog check job. Default: 10.",
18
+
19
+ "deliverTo": null,
20
+ "_deliverTo": "Fallback delivery target for untracked-label done calls (e.g. 'telegram:123456789'). Default: null (no fallback delivery).",
21
+
22
+ "deliveryChannel": null,
23
+ "_deliveryChannel": "Fallback delivery channel for untracked-label done calls (e.g. 'telegram'). Default: null."
24
+ }
@@ -0,0 +1,57 @@
1
+ #!/bin/bash
2
+ # deliver-watcher.sh — Poll dispatch session for a result; exit 0 when done (triggers scheduler delivery)
3
+ # Called by scheduler as a one-shot shell job. Exits non-zero while no result available,
4
+ # causing the scheduler to retry on the next cron tick. On completion, outputs the result
5
+ # and exits 0 — scheduler delivers the output and self-deletes the job.
6
+ #
7
+ # Usage: deliver-watcher.sh <label>
8
+
9
+ set -euo pipefail
10
+ LABEL="${1:?Usage: deliver-watcher.sh <label>}"
11
+ OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}"
12
+ NODE_BIN="${NODE_BIN:-$(command -v node)}"
13
+
14
+ # Resolution order:
15
+ # 1) openclaw-scheduler bin (preferred, if in PATH)
16
+ # 2) DISPATCH_CLI env override
17
+ # 3) dispatch/index.mjs directly
18
+
19
+ CLI_PATH=""
20
+ USE_BIN=false
21
+
22
+ if command -v openclaw-scheduler >/dev/null 2>&1; then
23
+ USE_BIN=true
24
+ elif [ -n "${DISPATCH_CLI:-}" ] && [ -f "$DISPATCH_CLI" ]; then
25
+ CLI_PATH="$DISPATCH_CLI"
26
+ elif [ -f "$OPENCLAW_HOME/scheduler/dispatch/index.mjs" ]; then
27
+ CLI_PATH="$OPENCLAW_HOME/scheduler/dispatch/index.mjs"
28
+ else
29
+ echo "[deliver-watcher] dispatch CLI not found" >&2
30
+ exit 1
31
+ fi
32
+
33
+ run_dispatch() {
34
+ if [ "$USE_BIN" = true ]; then
35
+ openclaw-scheduler "$@"
36
+ else
37
+ "$NODE_BIN" "$CLI_PATH" "$@"
38
+ fi
39
+ }
40
+
41
+ # Check if the agent produced a reply (direct check, no idle threshold)
42
+ RESULT_JSON=$(run_dispatch result --label "$LABEL" 2>/dev/null || echo '{}')
43
+ REPLY=$(echo "$RESULT_JSON" | "$NODE_BIN" -e "
44
+ let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{
45
+ try{const r=JSON.parse(d);console.log((r.lastReply||r.summary||'').trim().slice(0,3000));}catch(e){process.stderr.write('parse error: '+e.message+'\\n');}
46
+ });
47
+ " || echo "")
48
+
49
+ if [ -n "$REPLY" ]; then
50
+ # Mark as done in labels.json (best-effort)
51
+ run_dispatch status --label "$LABEL" >/dev/null 2>&1 || true
52
+ echo "✅ $LABEL: $REPLY"
53
+ exit 0
54
+ fi
55
+
56
+ # No reply yet — retry later
57
+ exit 1
@@ -0,0 +1,171 @@
1
+ /**
2
+ * dispatch-hooks.mjs -- Lifecycle event emitter
3
+ *
4
+ * Fires structured dispatch events to:
5
+ * 1. Loki (always -- structured log stream for Grafana observability)
6
+ * 2. DISPATCH_WEBHOOK_URL (optional -- external systems, dashboards, etc.)
7
+ * 3. Gateway post office (optional -- when opts.deliverTo is set)
8
+ *
9
+ * All calls are best-effort and non-blocking. A hook failure never
10
+ * prevents dispatch from completing.
11
+ *
12
+ * Event types:
13
+ * dispatch.started -- job created + queued in scheduler
14
+ * dispatch.finished -- run completed (ok or error)
15
+ * dispatch.stuck -- stuck run detected by detector
16
+ * dispatch.cancelled -- run manually cancelled
17
+ */
18
+
19
+ import { hostname } from 'os';
20
+ import { sendMessage } from '../messages.js';
21
+
22
+ const LOKI_URL = process.env.LOKI_PUSH_URL || '';
23
+ const WEBHOOK_URL = process.env.DISPATCH_WEBHOOK_URL || '';
24
+ const HOST = process.env.DISPATCH_HOST
25
+ || hostname()
26
+ || 'unknown-host';
27
+ const TIMEOUT_MS = 3000;
28
+
29
+ // -- Loki push -----------------------------------------------
30
+
31
+ async function lokiPush(event, payload) {
32
+ if (!LOKI_URL) return; // not configured -- skip silently
33
+ const ts = String(Date.now() * 1_000_000); // nanoseconds
34
+ const logLine = JSON.stringify({ event, host: HOST, ...payload });
35
+
36
+ const body = JSON.stringify({
37
+ streams: [{
38
+ stream: { service_name: 'dispatch', host: HOST, event },
39
+ values: [[ts, logLine]],
40
+ }],
41
+ });
42
+
43
+ const res = await fetch(LOKI_URL, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body,
47
+ signal: AbortSignal.timeout(TIMEOUT_MS),
48
+ });
49
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
50
+ }
51
+
52
+ // -- Webhook push --------------------------------------------
53
+
54
+ async function webhookPush(event, payload) {
55
+ if (!WEBHOOK_URL) return;
56
+ const res = await fetch(WEBHOOK_URL, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ event, ts: Date.now(), host: HOST, ...payload }),
60
+ signal: AbortSignal.timeout(TIMEOUT_MS),
61
+ });
62
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
63
+ }
64
+
65
+ // -- Post-office notification ---------------------------------
66
+
67
+ /**
68
+ * Enqueue a completion notification into the messages queue (post office).
69
+ * The Inbox Consumer drains pending messages and delivers to Telegram.
70
+ * Used for unregistered-label done signals where no watcher is waiting.
71
+ *
72
+ * @param {string} label - Dispatch label
73
+ * @param {string} summary - One-line summary of what was done
74
+ * @param {string} deliverTo - Target chat/user ID (stored for reference)
75
+ * @param {string} [deliveryChannel='telegram'] - Channel to deliver via (stored for reference)
76
+ */
77
+ async function gatewayNotify(label, summary, deliverTo, deliveryChannel = 'telegram') {
78
+ try {
79
+ const body = `✅ [${label}] done -- ${summary}`;
80
+ await sendMessage({
81
+ from_agent: 'dispatch',
82
+ to_agent: 'main',
83
+ kind: 'result',
84
+ subject: label,
85
+ body,
86
+ channel: deliveryChannel,
87
+ delivery_to: deliverTo,
88
+ });
89
+ } catch (e) {
90
+ process.stderr.write(`[dispatch-hooks] post-office enqueue failed for ${label}: ${e.message}\n`);
91
+ }
92
+ }
93
+
94
+ // -- Public API -----------------------------------------------
95
+
96
+ /**
97
+ * Emit a dispatch lifecycle event. Best-effort -- never throws.
98
+ */
99
+ export async function emitEvent(event, payload = {}) {
100
+ const tasks = [
101
+ lokiPush(event, payload).catch(e =>
102
+ process.stderr.write(`[dispatch-hooks] loki failed (${event}): ${e.message}\n`)
103
+ ),
104
+ WEBHOOK_URL
105
+ ? webhookPush(event, payload).catch(e =>
106
+ process.stderr.write(`[dispatch-hooks] webhook failed (${event}): ${e.message}\n`)
107
+ )
108
+ : Promise.resolve(),
109
+ ];
110
+ await Promise.allSettled(tasks);
111
+ }
112
+
113
+ /** Convenience: dispatch.started */
114
+ export function onStarted(opts) {
115
+ return emitEvent('dispatch.started', {
116
+ label: opts.label,
117
+ job_id: opts.job_id,
118
+ run_id: opts.run_id,
119
+ agent: opts.agent,
120
+ mode: opts.mode,
121
+ session_key: opts.session_key || null,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Convenience: dispatch.finished
127
+ *
128
+ * Fires to Loki + webhook (always) and optionally to the gateway post office.
129
+ *
130
+ * Extended opts:
131
+ * deliverTo {string} -- If set, send a completion notification via gateway
132
+ * deliveryChannel {string} -- Channel for delivery (default: 'telegram')
133
+ * summary {string} -- One-line summary to include in the notification
134
+ */
135
+ export async function onFinished(opts) {
136
+ const tasks = [
137
+ emitEvent('dispatch.finished', {
138
+ label: opts.label,
139
+ job_id: opts.job_id,
140
+ run_id: opts.run_id,
141
+ agent: opts.agent,
142
+ status: opts.status, // ok | error | timeout | cancelled
143
+ duration_ms: opts.duration_ms || null,
144
+ error: opts.error || null,
145
+ session_key: opts.session_key || null,
146
+ }),
147
+ ];
148
+
149
+ // Optional gateway post-office delivery (used for unregistered-label done signals)
150
+ if (opts.deliverTo) {
151
+ const summary = opts.summary || opts.status || 'completed';
152
+ tasks.push(
153
+ gatewayNotify(opts.label, summary, opts.deliverTo, opts.deliveryChannel || 'telegram')
154
+ );
155
+ }
156
+
157
+ return Promise.allSettled(tasks);
158
+ }
159
+
160
+ /** Convenience: dispatch.stuck */
161
+ export function onStuck(stuckRuns) {
162
+ return emitEvent('dispatch.stuck', {
163
+ stuck_count: stuckRuns.length,
164
+ runs: stuckRuns.map(r => ({
165
+ run_id: r.id,
166
+ job_name: r.job_name,
167
+ started_at: r.started_at,
168
+ age_s: r.age_s,
169
+ })),
170
+ });
171
+ }