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
package/runs.js ADDED
@@ -0,0 +1,270 @@
1
+ // Run lifecycle management
2
+ import { randomUUID } from 'crypto';
3
+ import { getDb } from './db.js';
4
+
5
+ /**
6
+ * Create a new run for a job.
7
+ */
8
+ export function createRun(jobId, opts = {}) {
9
+ const db = getDb();
10
+ const id = randomUUID();
11
+
12
+ db.prepare(`
13
+ INSERT INTO runs (
14
+ id, job_id, status, run_timeout_ms, session_key, session_id,
15
+ dispatched_at, context_summary, replay_of, idempotency_key, retry_count,
16
+ retry_of, triggered_by_run, dispatch_queue_id
17
+ )
18
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?)
19
+ `).run(
20
+ id,
21
+ jobId,
22
+ opts.status || 'running',
23
+ opts.run_timeout_ms || 300000,
24
+ opts.session_key || null,
25
+ opts.session_id || null,
26
+ opts.context_summary ? JSON.stringify(opts.context_summary) : null,
27
+ opts.replay_of || null,
28
+ opts.idempotency_key || null,
29
+ opts.retry_count ?? 0,
30
+ opts.retry_of || null,
31
+ opts.triggered_by_run || null,
32
+ opts.dispatch_queue_id || null
33
+ );
34
+
35
+ return getRun(id);
36
+ }
37
+
38
+ /**
39
+ * Get a run by ID.
40
+ */
41
+ export function getRun(id) {
42
+ return getDb().prepare('SELECT * FROM runs WHERE id = ?').get(id);
43
+ }
44
+
45
+ /**
46
+ * Get runs for a job (most recent first).
47
+ */
48
+ export function getRunsForJob(jobId, limit = 50) {
49
+ return getDb().prepare(`
50
+ SELECT * FROM runs WHERE job_id = ? ORDER BY started_at DESC LIMIT ?
51
+ `).all(jobId, limit);
52
+ }
53
+
54
+ /**
55
+ * Update run status to finished (ok/error/timeout).
56
+ */
57
+ export function finishRun(id, status, opts = {}) {
58
+ const db = getDb();
59
+ const run = getRun(id);
60
+ if (!run) return null;
61
+
62
+ const iso = run.started_at
63
+ ? (run.started_at.includes('T') ? run.started_at : run.started_at.replace(' ', 'T') + 'Z')
64
+ : null;
65
+ const startedAt = iso ? new Date(iso).getTime() : Date.now();
66
+ const durationMs = Date.now() - startedAt;
67
+
68
+ db.prepare(`
69
+ UPDATE runs SET
70
+ status = ?,
71
+ finished_at = datetime('now'),
72
+ duration_ms = ?,
73
+ summary = COALESCE(?, summary),
74
+ error_message = COALESCE(?, error_message),
75
+ context_summary = COALESCE(?, context_summary),
76
+ shell_exit_code = COALESCE(?, shell_exit_code),
77
+ shell_signal = COALESCE(?, shell_signal),
78
+ shell_timed_out = COALESCE(?, shell_timed_out),
79
+ shell_stdout = COALESCE(?, shell_stdout),
80
+ shell_stderr = COALESCE(?, shell_stderr),
81
+ shell_stdout_path = COALESCE(?, shell_stdout_path),
82
+ shell_stderr_path = COALESCE(?, shell_stderr_path),
83
+ shell_stdout_bytes = COALESCE(?, shell_stdout_bytes),
84
+ shell_stderr_bytes = COALESCE(?, shell_stderr_bytes)
85
+ WHERE id = ? AND status IN ('pending', 'running', 'awaiting_approval', 'approved')
86
+ `).run(
87
+ status,
88
+ durationMs,
89
+ opts.summary ?? null,
90
+ opts.error_message ?? null,
91
+ opts.context_summary ? JSON.stringify(opts.context_summary) : null,
92
+ opts.shell_exit_code ?? null,
93
+ opts.shell_signal ?? null,
94
+ opts.shell_timed_out != null ? Number(Boolean(opts.shell_timed_out)) : null,
95
+ opts.shell_stdout ?? null,
96
+ opts.shell_stderr ?? null,
97
+ opts.shell_stdout_path ?? null,
98
+ opts.shell_stderr_path ?? null,
99
+ opts.shell_stdout_bytes ?? null,
100
+ opts.shell_stderr_bytes ?? null,
101
+ id
102
+ );
103
+
104
+ return getRun(id);
105
+ }
106
+
107
+ /**
108
+ * Update last_heartbeat for a run (called by dispatcher when session activity detected).
109
+ */
110
+ export function updateHeartbeat(id) {
111
+ getDb().prepare(`
112
+ UPDATE runs SET last_heartbeat = datetime('now') WHERE id = ?
113
+ `).run(id);
114
+ }
115
+
116
+ /**
117
+ * Update session info for a run.
118
+ */
119
+ export function updateRunSession(id, sessionKey, sessionId) {
120
+ getDb().prepare(`
121
+ UPDATE runs SET session_key = ?, session_id = ? WHERE id = ?
122
+ `).run(sessionKey, sessionId, id);
123
+ }
124
+
125
+ /**
126
+ * Find stale runs: treats shell jobs and session-based jobs differently.
127
+ *
128
+ * - Session-based jobs (session_target != 'shell'): stale if last_heartbeat older than thresholdSeconds.
129
+ * These jobs emit heartbeats via gateway/session activity -- silence means stuck.
130
+ * - Shell jobs (session_target = 'shell'): stale only if elapsed time > run_timeout_ms.
131
+ * Shell jobs have no heartbeat mechanism; they run until exit. Use timeout as the upper bound.
132
+ * Shell jobs with run_timeout_ms IS NULL are NOT flagged -- that's getTimedOutRuns' concern.
133
+ *
134
+ * Default threshold: 90 seconds (3 missed 30s heartbeats) for agent jobs.
135
+ */
136
+ export function getStaleRuns(thresholdSeconds = 90) {
137
+ if (!Number.isInteger(thresholdSeconds) || thresholdSeconds < 0) {
138
+ throw new Error(`getStaleRuns: thresholdSeconds must be a non-negative integer, got ${thresholdSeconds}`);
139
+ }
140
+ return getDb().prepare(`
141
+ SELECT r.*, j.name as job_name, j.run_timeout_ms as job_timeout_ms
142
+ FROM runs r
143
+ JOIN jobs j ON r.job_id = j.id
144
+ WHERE r.status = 'running'
145
+ AND (
146
+ -- Shell jobs: stale only if they exceed their absolute run_timeout_ms
147
+ (j.session_target = 'shell'
148
+ AND r.run_timeout_ms IS NOT NULL
149
+ AND (julianday('now') - julianday(r.started_at)) * 86400000 > r.run_timeout_ms)
150
+ OR
151
+ -- Session-based jobs: stale if last_heartbeat not updated within threshold,
152
+ -- or if they never heartbeated and started_at is past the threshold (startup grace)
153
+ (j.session_target != 'shell'
154
+ AND (r.last_heartbeat < datetime('now', '-' || ? || ' seconds')
155
+ OR (r.last_heartbeat IS NULL
156
+ AND r.started_at < datetime('now', '-' || ? || ' seconds'))))
157
+ )
158
+ `).all(thresholdSeconds, thresholdSeconds);
159
+ }
160
+
161
+ /**
162
+ * Find runs that have exceeded their absolute timeout (run_timeout_ms).
163
+ *
164
+ * Important overlap note: this function may return runs also returned by
165
+ * `getStaleRuns`. Both queries match running runs that have exceeded their
166
+ * run_timeout_ms. Callers must check the run's current status before acting
167
+ * on results to avoid double-processing (e.g., finishing an already-finished run).
168
+ *
169
+ * This function serves as the fallback for when heartbeat-based stale detection
170
+ * is not available -- for example, shell jobs that have no heartbeat mechanism,
171
+ * or agent jobs whose first heartbeat has not yet arrived. Unlike `getStaleRuns`,
172
+ * which requires a heartbeat timestamp to compare against, this function uses
173
+ * only the run's started_at and run_timeout_ms columns.
174
+ */
175
+ export function getTimedOutRuns() {
176
+ return getDb().prepare(`
177
+ SELECT r.*, j.name as job_name, j.run_timeout_ms as job_timeout_ms
178
+ FROM runs r
179
+ JOIN jobs j ON r.job_id = j.id
180
+ WHERE r.status = 'running'
181
+ AND r.run_timeout_ms IS NOT NULL
182
+ AND (julianday('now') - julianday(r.started_at)) * 86400000 > r.run_timeout_ms
183
+ `).all();
184
+ }
185
+
186
+ /**
187
+ * Get active running runs (for heartbeat checking).
188
+ */
189
+ export function getRunningRuns() {
190
+ return getDb().prepare(`
191
+ SELECT r.*, j.name as job_name, j.run_timeout_ms as job_timeout_ms
192
+ FROM runs r
193
+ JOIN jobs j ON r.job_id = j.id
194
+ WHERE r.status = 'running'
195
+ `).all();
196
+ }
197
+
198
+ /**
199
+ * Prune old runs (keep last N per job).
200
+ */
201
+ export function pruneRuns(keepPerJob = 100) {
202
+ const db = getDb();
203
+ const jobs = db.prepare('SELECT id FROM jobs').all();
204
+
205
+ for (const job of jobs) {
206
+ db.prepare(`
207
+ DELETE FROM runs WHERE job_id = ? AND id NOT IN (
208
+ SELECT id FROM runs WHERE job_id = ? ORDER BY started_at DESC LIMIT ?
209
+ )
210
+ `).run(job.id, job.id, keepPerJob);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Get all running runs for jobs in a given resource pool.
216
+ */
217
+ export function getRunningRunsByPool(poolName) {
218
+ return getDb().prepare(`
219
+ SELECT r.*, j.name as job_name FROM runs r
220
+ JOIN jobs j ON r.job_id = j.id
221
+ WHERE r.status = 'running' AND j.resource_pool = ?
222
+ `).all(poolName);
223
+ }
224
+
225
+ /**
226
+ * Store or update the context_summary JSON for a run.
227
+ */
228
+ export function updateContextSummary(runId, summaryObj) {
229
+ const json = typeof summaryObj === 'string' ? summaryObj : JSON.stringify(summaryObj);
230
+ getDb().prepare(`
231
+ UPDATE runs SET context_summary = ? WHERE id = ?
232
+ `).run(json, runId);
233
+ return getRun(runId);
234
+ }
235
+
236
+ /**
237
+ * Persist v0.2 runtime outcomes on a run record.
238
+ *
239
+ * Only updates columns present in the outcomes object. Values that are objects
240
+ * are JSON-stringified before storage.
241
+ *
242
+ * Valid columns: identity_resolved, trust_evaluation, authorization_decision,
243
+ * authorization_proof_verification, evidence_record, credential_handoff_summary.
244
+ */
245
+ const V02_OUTCOME_COLUMNS = new Set([
246
+ 'identity_resolved',
247
+ 'trust_evaluation',
248
+ 'authorization_decision',
249
+ 'authorization_proof_verification',
250
+ 'evidence_record',
251
+ 'credential_handoff_summary',
252
+ ]);
253
+
254
+ export function persistV02Outcomes(runId, outcomes) {
255
+ if (!outcomes || typeof outcomes !== 'object') return;
256
+ if (!runId) return;
257
+ const db = getDb();
258
+ const fields = [];
259
+ const values = [];
260
+ for (const [key, value] of Object.entries(outcomes)) {
261
+ if (value === undefined) continue;
262
+ if (!V02_OUTCOME_COLUMNS.has(key)) continue;
263
+ if (!/^[a-z_]+$/.test(key)) throw new Error(`persistV02Outcomes: invalid column name "${key}"`);
264
+ fields.push(`${key} = ?`);
265
+ values.push(value != null && typeof value === 'object' ? JSON.stringify(value) : value);
266
+ }
267
+ if (fields.length === 0) return;
268
+ values.push(runId);
269
+ db.prepare(`UPDATE runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
270
+ }
@@ -0,0 +1,101 @@
1
+ export const SCHEDULER_SCHEMAS = {
2
+ jobs: {
3
+ type: 'object',
4
+ required: ['name', 'payload_message', 'run_timeout_ms'], // origin also required for non-child jobs
5
+ fields: {
6
+ name: { type: 'string', maxLength: 200 },
7
+ enabled: { type: 'boolean', default: true },
8
+ schedule_cron: { type: 'string', requiredFor: 'root jobs' },
9
+ schedule_tz: { type: 'string', default: 'UTC' },
10
+ session_target: { type: 'string', enum: ['main', 'isolated', 'shell'], default: 'isolated' },
11
+ payload_kind: { type: 'string', enum: ['systemEvent', 'agentTurn', 'shellCommand'] },
12
+ payload_message: { type: 'string', maxLength: 100000 },
13
+ payload_model: { type: 'string', nullable: true },
14
+ payload_thinking: { type: 'string', nullable: true },
15
+ payload_timeout_seconds: { type: 'integer', min: 1, default: 120 },
16
+ execution_intent: { type: 'string', enum: ['execute', 'plan'], default: 'execute' },
17
+ execution_read_only: { type: 'boolean', default: false },
18
+ overlap_policy: { type: 'string', enum: ['skip', 'allow', 'queue'], default: 'skip' },
19
+ run_timeout_ms: { type: 'integer', min: 1, required: true, description: 'Required: max ms a run may execute before timeout (no default -- caller must be explicit)' },
20
+ max_queued_dispatches: { type: 'integer', min: 1, default: 25 },
21
+ max_pending_approvals: { type: 'integer', min: 1, default: 10 },
22
+ max_trigger_fanout: { type: 'integer', min: 1, default: 25 },
23
+ delivery_mode: { type: 'string', enum: ['announce', 'announce-always', 'none'], default: 'announce' },
24
+ delivery_channel: { type: 'string', nullable: true },
25
+ delivery_to: { type: 'string', nullable: true },
26
+ parent_id: { type: 'string', nullable: true },
27
+ trigger_on: { type: 'string', enum: ['success', 'failure', 'complete'], nullable: true },
28
+ trigger_delay_s: { type: 'integer', min: 0, default: 0 },
29
+ trigger_condition: { type: 'string', nullable: true, examples: ['contains:ALERT', 'regex:ERROR.*critical'] },
30
+ max_retries: { type: 'integer', min: 0, default: 0 },
31
+ payload_scope: { type: 'string', enum: ['own', 'global'], default: 'own' },
32
+ resource_pool: { type: 'string', nullable: true },
33
+ delivery_guarantee: { type: 'string', enum: ['at-most-once', 'at-least-once'], default: 'at-most-once' },
34
+ job_class: { type: 'string', enum: ['standard', 'pre_compaction_flush'], default: 'standard' },
35
+ approval_required: { type: 'boolean', default: false },
36
+ approval_timeout_s: { type: 'integer', min: 1, default: 3600 },
37
+ approval_auto: { type: 'string', enum: ['approve', 'reject'], default: 'reject' },
38
+ context_retrieval: { type: 'string', enum: ['none', 'recent', 'hybrid'], default: 'none' },
39
+ context_retrieval_limit: { type: 'integer', min: 1, default: 5 },
40
+ 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_offload_threshold_bytes: { type: 'integer', min: 128, default: 65536 },
44
+ preferred_session_key: { type: 'string', nullable: true },
45
+ auth_profile: { type: 'string', nullable: true, description: 'Auth profile override: null=default, "inherit"=main session profile, or "provider:label"' },
46
+ delivery_opt_out_reason: { type: 'string', nullable: true, maxLength: 256 },
47
+ delete_after_run: { type: 'boolean', default: false },
48
+ run_now: { type: 'boolean', default: false, note: 'create-time convenience flag' },
49
+
50
+ // v0.2 Identity
51
+ identity_principal: { type: 'string', nullable: true },
52
+ identity_run_as: { type: 'string', nullable: true },
53
+ identity_attestation: { type: 'string', nullable: true },
54
+ identity_ref: { type: 'string', nullable: true },
55
+ identity_subject_kind: { type: 'string', enum: ['agent', 'service', 'workload', 'user', 'composite', 'delegated-agent', 'unknown'], nullable: true },
56
+ identity_subject_principal: { type: 'string', nullable: true },
57
+ identity_trust_level: { type: 'string', enum: ['untrusted', 'restricted', 'supervised', 'autonomous'], nullable: true },
58
+ identity_delegation_mode: { type: 'string', enum: ['none', 'on-behalf-of', 'impersonation'], nullable: true },
59
+ identity: { type: 'string', nullable: true, description: 'JSON blob: full identity declaration' },
60
+
61
+ // v0.2 Authorization Proof
62
+ authorization_proof_ref: { type: 'string', nullable: true },
63
+ authorization_proof: { type: 'string', nullable: true, description: 'JSON blob: authorization proof structure' },
64
+
65
+ // v0.2 Authorization
66
+ authorization_ref: { type: 'string', nullable: true },
67
+ authorization: { type: 'string', nullable: true, description: 'JSON blob: authorization policy declaration' },
68
+
69
+ // v0.2 Evidence
70
+ evidence_ref: { type: 'string', nullable: true },
71
+ evidence: { type: 'string', nullable: true, description: 'JSON blob: evidence collection declaration' },
72
+
73
+ // v0.2 Contract
74
+ contract_required_trust_level: { type: 'string', enum: ['untrusted', 'restricted', 'supervised', 'autonomous'], nullable: true },
75
+ contract_trust_enforcement: { type: 'string', enum: ['none', 'warn', 'block', 'advisory', 'strict'], nullable: true, description: 'advisory/strict normalize to warn/block at runtime' },
76
+ contract_sandbox: { type: 'string', nullable: true, description: 'JSON blob: sandbox constraints' },
77
+ contract_allowed_paths: { type: 'string', nullable: true, description: 'JSON blob: allowed filesystem paths' },
78
+ contract_network: { type: 'string', nullable: true, description: 'JSON blob: network access policy' },
79
+ contract_max_cost_usd: { type: 'number', nullable: true, min: 0 },
80
+ contract_audit: { type: 'string', nullable: true, description: 'JSON blob: audit configuration' },
81
+ child_credential_policy: { type: 'string', nullable: true, enum: ['none', 'inherit', 'downscope', 'independent'], description: 'Credential flow policy for child tasks' },
82
+ },
83
+ },
84
+ runs: {
85
+ statuses: ['pending', 'running', 'ok', 'error', 'timeout', 'skipped', 'awaiting_approval', 'approved', 'cancelled', 'crashed'],
86
+ key_fields: ['job_id', 'status', 'started_at', 'finished_at', 'summary', 'error_message', 'shell_exit_code', 'shell_signal', 'shell_timed_out', 'shell_stdout', 'shell_stderr', 'shell_stdout_path', 'shell_stderr_path', 'shell_stdout_bytes', 'shell_stderr_bytes', 'retry_of', 'triggered_by_run', 'dispatch_queue_id', 'idempotency_key', 'identity_resolved', 'trust_evaluation', 'authorization_decision', 'authorization_proof_verification', 'evidence_record', 'credential_handoff_summary'],
87
+ },
88
+ approvals: {
89
+ statuses: ['pending', 'approved', 'rejected', 'timed_out', 'dispatched'],
90
+ key_fields: ['job_id', 'run_id', 'dispatch_queue_id', 'requested_at', 'resolved_at', 'resolved_by', 'notes'],
91
+ },
92
+ dispatches: {
93
+ kinds: ['manual', 'chain', 'retry'],
94
+ statuses: ['pending', 'claimed', 'awaiting_approval', 'done', 'cancelled'],
95
+ key_fields: ['job_id', 'dispatch_kind', 'status', 'scheduled_for', 'source_run_id', 'retry_of_run_id'],
96
+ },
97
+ messages: {
98
+ kinds: ['text', 'task', 'result', 'status', 'system', 'spawn', 'decision', 'constraint', 'fact', 'preference'],
99
+ statuses: ['pending', 'delivered', 'read', 'expired', 'failed'],
100
+ },
101
+ };