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,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
|
+
}
|