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.
- package/AGENTS.md +302 -0
- package/BEST-PRACTICES.md +506 -0
- package/CHANGELOG.md +82 -0
- package/CODE_OF_CONDUCT.md +22 -0
- package/CONTEXT.md +26 -0
- package/CONTRIBUTING.md +73 -0
- package/IMPLEMENTATION_SPEC.md +170 -0
- package/INSTALL-ADDITIONAL-HOST.md +333 -0
- package/INSTALL-LINUX.md +419 -0
- package/INSTALL-WINDOWS.md +305 -0
- package/INSTALL.md +364 -0
- package/JOB-QUICK-REF.md +222 -0
- package/LICENSE +21 -0
- package/QUICK-START.md +256 -0
- package/README.md +2170 -0
- package/SECURITY.md +34 -0
- package/UNINSTALL.md +129 -0
- package/UPGRADING.md +436 -0
- package/agents.js +67 -0
- package/approval.js +107 -0
- package/backup.js +390 -0
- package/bin/openclaw-scheduler.js +138 -0
- package/cli.js +1083 -0
- package/db.js +122 -0
- package/dispatch/529-recovery.mjs +204 -0
- package/dispatch/README.md +372 -0
- package/dispatch/config.example.json +24 -0
- package/dispatch/deliver-watcher.sh +57 -0
- package/dispatch/hooks.mjs +171 -0
- package/dispatch/index.mjs +1836 -0
- package/dispatch/watcher.mjs +1396 -0
- package/dispatch-queue.js +112 -0
- package/dispatcher-approvals.js +96 -0
- package/dispatcher-delivery.js +43 -0
- package/dispatcher-maintenance.js +242 -0
- package/dispatcher-shell.js +29 -0
- package/dispatcher-strategies.js +1280 -0
- package/dispatcher-utils.js +81 -0
- package/dispatcher.js +855 -0
- package/docs/adr-schedule-ownership.md +73 -0
- package/docs/gateway-contract.md +904 -0
- package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
- package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
- package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
- package/docs/trust-architecture.md +266 -0
- package/gateway.js +473 -0
- package/idempotency.js +119 -0
- package/index.d.ts +864 -0
- package/index.js +17 -0
- package/jobs.js +1224 -0
- package/messages.js +357 -0
- package/migrate-consolidate.js +694 -0
- package/migrate.js +125 -0
- package/package.json +130 -0
- package/paths.js +79 -0
- package/prompt-context.js +94 -0
- package/retrieval.js +176 -0
- package/runs.js +270 -0
- package/scheduler-schema.js +101 -0
- package/schema.sql +480 -0
- package/scripts/dispatch-cli-utils.mjs +65 -0
- package/scripts/inbox-consumer.mjs +288 -0
- package/scripts/stuck-detector.sh +18 -0
- package/scripts/stuck-run-detector.mjs +333 -0
- package/scripts/telegram-webhook-check.mjs +238 -0
- package/setup.mjs +724 -0
- package/shell-result.js +214 -0
- package/task-tracker.js +300 -0
- package/team-adapter.js +335 -0
- 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
|
+
}
|