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,112 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { getDb } from './db.js';
3
+ import { sqliteNow } from './dispatcher-utils.js';
4
+
5
+ const VALID_DISPATCH_KINDS = new Set(['manual', 'chain', 'retry']);
6
+ const VALID_DISPATCH_STATUSES = new Set([
7
+ 'pending',
8
+ 'claimed',
9
+ 'awaiting_approval',
10
+ 'done',
11
+ 'cancelled',
12
+ ]);
13
+
14
+ function assertKind(kind) {
15
+ if (!VALID_DISPATCH_KINDS.has(kind)) {
16
+ throw new Error(`Invalid dispatch kind "${kind}". Valid: ${[...VALID_DISPATCH_KINDS].join(', ')}`);
17
+ }
18
+ }
19
+
20
+ function assertStatus(status) {
21
+ if (!VALID_DISPATCH_STATUSES.has(status)) {
22
+ throw new Error(`Invalid dispatch status "${status}". Valid: ${[...VALID_DISPATCH_STATUSES].join(', ')}`);
23
+ }
24
+ }
25
+
26
+ export function enqueueDispatch(jobId, opts = {}) {
27
+ const db = getDb();
28
+ const id = opts.id || randomUUID();
29
+ const kind = opts.kind || 'manual';
30
+ const status = opts.status || 'pending';
31
+ assertKind(kind);
32
+ assertStatus(status);
33
+
34
+ db.prepare(`
35
+ INSERT INTO job_dispatch_queue (
36
+ id, job_id, dispatch_kind, status, scheduled_for,
37
+ source_run_id, retry_of_run_id, created_at, claimed_at, processed_at
38
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?)
39
+ `).run(
40
+ id,
41
+ jobId,
42
+ kind,
43
+ status,
44
+ opts.scheduled_for || sqliteNow(-1000),
45
+ opts.source_run_id || null,
46
+ opts.retry_of_run_id || null,
47
+ opts.claimed_at || null,
48
+ opts.processed_at || null
49
+ );
50
+
51
+ return getDispatch(id);
52
+ }
53
+
54
+ export function getDispatch(id) {
55
+ return getDb().prepare('SELECT * FROM job_dispatch_queue WHERE id = ?').get(id) || null;
56
+ }
57
+
58
+ export function getDueDispatches(limit = 100) {
59
+ return getDb().prepare(`
60
+ SELECT q.*, j.name as job_name
61
+ FROM job_dispatch_queue q
62
+ JOIN jobs j ON q.job_id = j.id
63
+ WHERE q.status = 'pending'
64
+ AND q.scheduled_for <= datetime('now')
65
+ AND (j.enabled = 1 OR q.dispatch_kind = 'manual')
66
+ ORDER BY q.scheduled_for ASC, q.created_at ASC
67
+ LIMIT ?
68
+ `).all(limit);
69
+ }
70
+
71
+ export function claimDispatch(id) {
72
+ const result = getDb().prepare(`
73
+ UPDATE job_dispatch_queue
74
+ SET status = 'claimed',
75
+ claimed_at = datetime('now')
76
+ WHERE id = ? AND status = 'pending'
77
+ `).run(id);
78
+ return result.changes > 0 ? getDispatch(id) : null;
79
+ }
80
+
81
+ export function releaseDispatch(id, scheduledFor = null) {
82
+ const result = getDb().prepare(`
83
+ UPDATE job_dispatch_queue
84
+ SET status = 'pending',
85
+ scheduled_for = COALESCE(?, scheduled_for),
86
+ claimed_at = NULL
87
+ WHERE id = ? AND status IN ('claimed', 'awaiting_approval')
88
+ `).run(scheduledFor, id);
89
+ return result.changes > 0 ? getDispatch(id) : null;
90
+ }
91
+
92
+ export function setDispatchStatus(id, status) {
93
+ assertStatus(status);
94
+ const processedAt = ['done', 'cancelled'].includes(status) ? sqliteNow() : null;
95
+ getDb().prepare(`
96
+ UPDATE job_dispatch_queue
97
+ SET status = ?,
98
+ processed_at = COALESCE(?, processed_at)
99
+ WHERE id = ?
100
+ `).run(status, processedAt, id);
101
+ return getDispatch(id);
102
+ }
103
+
104
+ export function listDispatchesForJob(jobId, limit = 20) {
105
+ return getDb().prepare(`
106
+ SELECT *
107
+ FROM job_dispatch_queue
108
+ WHERE job_id = ?
109
+ ORDER BY created_at DESC
110
+ LIMIT ?
111
+ `).all(jobId, limit);
112
+ }
@@ -0,0 +1,96 @@
1
+ export async function checkApprovals({
2
+ log,
3
+ getDb,
4
+ getTimedOutApprovals,
5
+ getJob,
6
+ resolveApproval,
7
+ dispatchJob,
8
+ getDispatch,
9
+ setDispatchStatus,
10
+ }) {
11
+ try {
12
+ const timedOut = getTimedOutApprovals();
13
+ for (const approval of timedOut) {
14
+ const job = getJob(approval.job_id);
15
+ if (!job) continue;
16
+
17
+ if (approval.approval_auto === 'approve' || job.approval_auto === 'approve') {
18
+ resolveApproval(approval.id, 'approved', 'timeout');
19
+ getDb().prepare(`
20
+ UPDATE approvals
21
+ SET status = 'dispatched',
22
+ notes = COALESCE(notes, 'Auto-approved and dispatched by scheduler')
23
+ WHERE id = ? AND status = 'approved'
24
+ `).run(approval.id);
25
+ log('info', `Approval auto-approved (timeout): ${approval.job_name || job.name}`, { approvalId: approval.id });
26
+ if (approval.run_id) {
27
+ getDb().prepare(`
28
+ UPDATE runs
29
+ SET status = 'approved',
30
+ finished_at = datetime('now'),
31
+ duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER),
32
+ summary = COALESCE(summary, 'Approval granted (timeout auto-approve)')
33
+ WHERE id = ? AND status IN ('awaiting_approval', 'pending')
34
+ `).run(approval.run_id);
35
+ }
36
+ await dispatchJob(job, {
37
+ approvalBypass: true,
38
+ dispatchRecord: approval.dispatch_queue_id ? getDispatch(approval.dispatch_queue_id) : null,
39
+ });
40
+ } else {
41
+ resolveApproval(approval.id, 'timed_out', 'timeout');
42
+ if (approval.dispatch_queue_id) setDispatchStatus(approval.dispatch_queue_id, 'cancelled');
43
+ if (approval.run_id) {
44
+ getDb().prepare(`
45
+ UPDATE runs
46
+ SET status = 'cancelled', finished_at = datetime('now')
47
+ WHERE id = ? AND status = 'awaiting_approval'
48
+ `).run(approval.run_id);
49
+ }
50
+ log('info', `Approval timed out (rejected): ${approval.job_name || job.name}`, { approvalId: approval.id });
51
+ }
52
+ }
53
+ } catch (err) {
54
+ log('error', `Approval timeout check error: ${err.message}`);
55
+ }
56
+
57
+ try {
58
+ const db = getDb();
59
+ const approved = db.prepare(`
60
+ SELECT a.*, j.name as job_name
61
+ FROM approvals a
62
+ JOIN jobs j ON a.job_id = j.id
63
+ LEFT JOIN runs r ON a.run_id = r.id
64
+ WHERE a.status = 'approved'
65
+ AND (a.run_id IS NULL OR r.status IN ('awaiting_approval', 'pending'))
66
+ `).all();
67
+
68
+ for (const approval of approved) {
69
+ const job = getJob(approval.job_id);
70
+ if (!job) continue;
71
+ if (approval.run_id) {
72
+ db.prepare(`
73
+ UPDATE runs
74
+ SET status = 'approved',
75
+ finished_at = datetime('now'),
76
+ duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER),
77
+ summary = COALESCE(summary, 'Approved by operator')
78
+ WHERE id = ? AND status IN ('awaiting_approval', 'pending')
79
+ `).run(approval.run_id);
80
+ }
81
+ log('info', `Dispatching approved job: ${approval.job_name}`, { approvalId: approval.id });
82
+ await dispatchJob(job, {
83
+ approvalBypass: true,
84
+ dispatchRecord: approval.dispatch_queue_id ? getDispatch(approval.dispatch_queue_id) : null,
85
+ });
86
+ db.prepare(`
87
+ UPDATE approvals
88
+ SET status = 'dispatched',
89
+ notes = COALESCE(notes, 'Approved and dispatched by scheduler')
90
+ WHERE id = ? AND status = 'approved'
91
+ `).run(approval.id);
92
+ }
93
+ } catch (err) {
94
+ log('error', `Approval dispatch error: ${err.message}`);
95
+ }
96
+ }
@@ -0,0 +1,43 @@
1
+ import { sendMessage } from './messages.js';
2
+
3
+ export function createDeliveryHelpers({ log, resolveDeliveryAlias }) {
4
+ function resolveAlias(target) {
5
+ if (!target) return null;
6
+ return resolveDeliveryAlias(target);
7
+ }
8
+
9
+ async function handleDelivery(job, content) {
10
+ if (!['announce', 'announce-always'].includes(job.delivery_mode)) return;
11
+ if (!job.delivery_channel && !job.delivery_to) return;
12
+
13
+ let channel = job.delivery_channel;
14
+ let target = job.delivery_to;
15
+
16
+ if (target) {
17
+ const resolved = resolveAlias(target);
18
+ if (resolved) {
19
+ channel = resolved.channel;
20
+ target = resolved.target;
21
+ log('info', `Resolved alias '${job.delivery_to}' -> ${channel}/${target}`);
22
+ }
23
+ }
24
+
25
+ try {
26
+ const subject = (job.name || '').slice(0, 100);
27
+ sendMessage({
28
+ from_agent: 'scheduler',
29
+ to_agent: 'main',
30
+ kind: 'result',
31
+ subject,
32
+ body: content,
33
+ channel,
34
+ delivery_to: target,
35
+ });
36
+ log('info', `Enqueued: ${job.name}`, { channel, to: target });
37
+ } catch (err) {
38
+ log('error', `Delivery enqueue failed: ${job.name}: ${err.message}`);
39
+ }
40
+ }
41
+
42
+ return { handleDelivery };
43
+ }
@@ -0,0 +1,242 @@
1
+ export async function checkRunHealth({
2
+ log,
3
+ getDb,
4
+ getRunningRuns,
5
+ getStaleRuns,
6
+ getTimedOutRuns,
7
+ finishRun,
8
+ getJob,
9
+ updateJobAfterRun,
10
+ handleDelivery,
11
+ dequeueJob,
12
+ shouldRetry,
13
+ scheduleRetry,
14
+ staleThresholdSeconds,
15
+ }) {
16
+ const runningRuns = getRunningRuns();
17
+ if (runningRuns.length === 0) return;
18
+
19
+ log('debug', `Checking ${runningRuns.length} running run(s)`);
20
+
21
+ const staleRuns = getStaleRuns(staleThresholdSeconds);
22
+ for (const run of staleRuns) {
23
+ log('warn', `Stale run: ${run.job_name}`, { runId: run.id });
24
+ finishRun(run.id, 'timeout', {
25
+ error_message: `No activity for ${staleThresholdSeconds}s`,
26
+ });
27
+ const job = getJob(run.job_id);
28
+ if (!job) continue;
29
+ if (shouldRetry(job, run.id)) {
30
+ const retry = scheduleRetry(job, run.id, { lastStatus: 'timeout' });
31
+ if (retry && !retry.skipped) {
32
+ getDb().prepare('UPDATE runs SET retry_count = ? WHERE id = ?').run(retry.retryCount, run.id);
33
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
34
+ await handleDelivery(
35
+ job,
36
+ `[timeout] Job timed out (stale, will retry): ${job.name}\n\nNo activity for ${staleThresholdSeconds}s\nRetry ${retry.retryCount}/${job.max_retries} in ${retry.delaySec}s`
37
+ );
38
+ }
39
+ if (dequeueJob(job.id)) {
40
+ log('info', `Dequeued pending dispatch for ${job.name} (after stale timeout retry scheduling)`);
41
+ }
42
+ log('info', `Scheduled retry ${retry.retryCount} for timed-out stale run: ${job.name}`, { runId: run.id, delaySec: retry.delaySec });
43
+ continue;
44
+ }
45
+ }
46
+ updateJobAfterRun(job, 'timeout');
47
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
48
+ await handleDelivery(job, `[timeout] Job timed out (stale): ${job.name}\n\nNo activity for ${staleThresholdSeconds}s`);
49
+ }
50
+ if (dequeueJob(job.id)) {
51
+ log('info', `Dequeued pending dispatch for ${job.name} (after stale timeout)`);
52
+ }
53
+ }
54
+
55
+ const staleRunIds = new Set(staleRuns.map(r => r.id));
56
+ const timedOut = getTimedOutRuns();
57
+ for (const run of timedOut) {
58
+ if (staleRunIds.has(run.id)) continue; // already handled above
59
+ log('warn', `Timed out: ${run.job_name}`, { runId: run.id, timeoutMs: run.run_timeout_ms });
60
+ finishRun(run.id, 'timeout', {
61
+ error_message: `Exceeded ${run.run_timeout_ms}ms timeout`,
62
+ });
63
+ const job = getJob(run.job_id);
64
+ if (!job) continue;
65
+ if (shouldRetry(job, run.id)) {
66
+ const retry = scheduleRetry(job, run.id, { lastStatus: 'timeout' });
67
+ if (retry && !retry.skipped) {
68
+ getDb().prepare('UPDATE runs SET retry_count = ? WHERE id = ?').run(retry.retryCount, run.id);
69
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
70
+ await handleDelivery(
71
+ job,
72
+ `[timeout] Job timed out (will retry): ${job.name}\n\nExceeded ${run.run_timeout_ms}ms timeout\nRetry ${retry.retryCount}/${job.max_retries} in ${retry.delaySec}s`
73
+ );
74
+ }
75
+ if (dequeueJob(job.id)) {
76
+ log('info', `Dequeued pending dispatch for ${job.name} (after timeout retry scheduling)`);
77
+ }
78
+ log('info', `Scheduled retry ${retry.retryCount} for timed-out run: ${job.name}`, { runId: run.id, delaySec: retry.delaySec });
79
+ continue;
80
+ }
81
+ }
82
+ updateJobAfterRun(job, 'timeout');
83
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
84
+ await handleDelivery(job, `[timeout] Job timed out: ${job.name}\n\nExceeded ${run.run_timeout_ms}ms timeout`);
85
+ }
86
+ if (dequeueJob(job.id)) {
87
+ log('info', `Dequeued pending dispatch for ${job.name} (after absolute timeout)`);
88
+ }
89
+ }
90
+ }
91
+
92
+ export async function checkTaskTrackers({
93
+ log,
94
+ getDb,
95
+ getAllSubAgentSessions,
96
+ touchAgentHeartbeat,
97
+ checkDeadAgents,
98
+ listActiveTaskGroups,
99
+ checkGroupCompletion,
100
+ getTaskGroupStatus,
101
+ resolveDeliveryAlias,
102
+ deliverMessage,
103
+ }) {
104
+ try {
105
+ try {
106
+ const db = getDb();
107
+ const activeSessions = await getAllSubAgentSessions(10);
108
+ if (activeSessions.length > 0) {
109
+ for (const session of activeSessions) {
110
+ const sessionKey = session.key || session.sessionKey;
111
+ if (!sessionKey) continue;
112
+
113
+ const agent = db.prepare(`
114
+ SELECT a.tracker_id, a.agent_label
115
+ FROM task_tracker_agents a
116
+ JOIN task_tracker t ON a.tracker_id = t.id
117
+ WHERE a.session_key = ? AND a.status IN ('pending', 'running') AND t.status = 'active'
118
+ `).get(sessionKey);
119
+
120
+ if (agent) {
121
+ touchAgentHeartbeat(agent.tracker_id, agent.agent_label);
122
+ log('debug', `Auto-heartbeat: ${agent.agent_label} (session active)`);
123
+ }
124
+ }
125
+ }
126
+ } catch (corrErr) {
127
+ log('debug', `Session auto-correlation skipped: ${corrErr.message}`);
128
+ }
129
+
130
+ const deadAgents = checkDeadAgents();
131
+ if (deadAgents.length > 0) {
132
+ log('warn', `Marked ${deadAgents.length} dead agent(s)`, {
133
+ agents: deadAgents.map(d => `${d.tracker_id.slice(0, 8)}/${d.agent_label}`),
134
+ });
135
+ }
136
+
137
+ const activeGroups = listActiveTaskGroups();
138
+ for (const group of activeGroups) {
139
+ const result = checkGroupCompletion(group.id);
140
+ if (!result) continue;
141
+ const status = getTaskGroupStatus(group.id);
142
+ const statusTag = result.status === 'completed' ? '[ok]' : '[FAILED]';
143
+ const msg = `${statusTag} Task group "${group.name}" ${result.status}\n\n${result.summary || ''}`;
144
+ log('info', `Task group ${result.status}: ${group.name}`, {
145
+ trackerId: group.id,
146
+ status: status?.status || result.status,
147
+ });
148
+
149
+ if (group.delivery_channel && group.delivery_to) {
150
+ try {
151
+ let channel = group.delivery_channel;
152
+ let target = group.delivery_to;
153
+ const resolved = resolveDeliveryAlias(target);
154
+ if (resolved) {
155
+ channel = resolved.channel;
156
+ target = resolved.target;
157
+ }
158
+ await deliverMessage(channel, target, msg);
159
+ log('info', `Task tracker summary delivered`, { channel, target, trackerId: group.id });
160
+ } catch (err) {
161
+ log('error', `Task tracker delivery failed: ${err.message}`, { trackerId: group.id });
162
+ }
163
+ }
164
+ }
165
+ } catch (err) {
166
+ log('error', `Task tracker check error: ${err.message}`);
167
+ }
168
+ }
169
+
170
+ export function expireStaleMessages({ expireMessages }) {
171
+ expireMessages();
172
+ }
173
+
174
+ /**
175
+ * Validate that a value does not contain shell metacharacters that could
176
+ * enable injection when interpolated into a shell command string.
177
+ */
178
+ function assertSafeShellArg(val, name) {
179
+ if (typeof val !== 'string') return;
180
+ if (/[`$\\;|&<>(){}[\]!#~\n\r]/.test(val)) {
181
+ throw new Error(`${name} contains unsafe shell characters: ${val}`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Shell-safe single-quote escaping. Returns a fully-quoted token wrapped
187
+ * in single quotes. Embedded single quotes use the standard bash idiom
188
+ * 'foo'\''bar' which ends the current single-quoted string, inserts an
189
+ * escaped single quote, and reopens single quoting.
190
+ */
191
+ function sq(val) {
192
+ return "'" + String(val).replace(/'/g, "'\\''") + "'";
193
+ }
194
+
195
+ export function ensureAgentInboxJobs({ log, getDb, createJob }) {
196
+ try {
197
+ const db = getDb();
198
+
199
+ // Find agents with delivery config
200
+ const agents = db.prepare(`
201
+ SELECT id, delivery_channel, delivery_to, brand_name
202
+ FROM agents
203
+ WHERE delivery_channel IS NOT NULL AND delivery_to IS NOT NULL
204
+ `).all();
205
+
206
+ if (agents.length === 0) return;
207
+
208
+ for (const agent of agents) {
209
+ const jobName = `inbox-consumer:${agent.id}`;
210
+
211
+ // Check if job already exists
212
+ const existing = db.prepare('SELECT id FROM jobs WHERE name = ?').get(jobName);
213
+ if (existing) continue;
214
+
215
+ // Validate args are free of shell metacharacters before interpolation
216
+ assertSafeShellArg(agent.id, 'agent.id');
217
+ assertSafeShellArg(agent.delivery_to, 'delivery_to');
218
+ assertSafeShellArg(agent.delivery_channel, 'delivery_channel');
219
+
220
+ // Use the bin command registered in package.json so the job does not
221
+ // embed an absolute filesystem path that would break after upgrades.
222
+ const consumerCmd = `openclaw-inbox-consumer --agent ${sq(agent.id)} --to ${sq(agent.delivery_to)} --channel ${sq(agent.delivery_channel)}`;
223
+
224
+ createJob({
225
+ name: jobName,
226
+ schedule_cron: '*/5 * * * *',
227
+ session_target: 'shell',
228
+ payload_kind: 'shellCommand',
229
+ payload_message: consumerCmd,
230
+ delivery_mode: 'none',
231
+ overlap_policy: 'skip',
232
+ enabled: 1,
233
+ run_timeout_ms: 120_000, // 2 min: inbox consumer shell script should be fast
234
+ origin: 'system',
235
+ });
236
+
237
+ log('info', `Created inbox consumer job: ${jobName} -> ${agent.delivery_channel}:${agent.delivery_to}`);
238
+ }
239
+ } catch (err) {
240
+ log('error', `ensureAgentInboxJobs error: ${err.message}`);
241
+ }
242
+ }
@@ -0,0 +1,29 @@
1
+ import { exec as execCb } from 'child_process';
2
+
3
+ // Platform-aware shell defaults:
4
+ // - macOS: /bin/zsh
5
+ // - Linux/WSL: /bin/bash
6
+ // - Windows: cmd.exe
7
+ // Override with SCHEDULER_SHELL env var.
8
+ export const DEFAULT_SHELL = process.env.SCHEDULER_SHELL
9
+ || (process.platform === 'darwin'
10
+ ? '/bin/zsh'
11
+ : process.platform === 'win32'
12
+ ? 'cmd.exe'
13
+ : '/bin/bash');
14
+
15
+ export function runShellCommand(cmd, timeoutMs = 300000, env = null) {
16
+ if (!cmd || typeof cmd !== 'string') throw new Error('Shell command must be a non-empty string');
17
+ const safeTimeout = (Number.isFinite(timeoutMs) && timeoutMs > 0) ? timeoutMs : 300_000;
18
+ return new Promise((resolve) => {
19
+ execCb(cmd, { timeout: safeTimeout, maxBuffer: 64 * 1024 * 1024, shell: DEFAULT_SHELL, env: env ? { ...process.env, ...env } : undefined }, (err, stdout, stderr) => {
20
+ resolve({
21
+ stdout: stdout || '',
22
+ stderr: stderr || '',
23
+ exitCode: Number.isInteger(err?.code) ? err.code : (err ? 1 : 0),
24
+ signal: err?.signal || null,
25
+ error: err || null,
26
+ });
27
+ });
28
+ });
29
+ }