openclaw-scheduler 0.2.8 → 0.2.10
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/CHANGELOG.md +1 -0
- package/INSTALL-ADDITIONAL-HOST.md +1 -1
- package/INSTALL-LINUX.md +1 -1
- package/INSTALL-WINDOWS.md +1 -1
- package/INSTALL.md +1 -1
- package/JOB-QUICK-REF.md +2 -0
- package/README.md +5 -5
- package/cli.js +9 -1
- package/dispatch/529-recovery.mjs +21 -2
- package/dispatch/completion.mjs +49 -0
- package/dispatch/index.mjs +179 -11
- package/dispatch/paths.mjs +36 -0
- package/dispatch/watcher.mjs +78 -9
- package/dispatcher-strategies.js +121 -72
- package/dispatcher.js +4 -2
- package/docs/gateway-contract.md +21 -0
- package/gateway.js +140 -30
- package/index.d.ts +5 -0
- package/jobs.js +23 -8
- package/migrate-consolidate.js +6 -2
- package/package.json +4 -3
- package/paths.js +43 -1
- package/scheduler-schema.js +2 -0
- package/schema.sql +6 -1
- package/setup.mjs +24 -22
package/CHANGELOG.md
CHANGED
|
@@ -75,6 +75,7 @@ All notable changes to this project will be documented in this file.
|
|
|
75
75
|
- feat: x-openclaw-env-inject header for agent task credentials (PR #5)
|
|
76
76
|
- feat: [IMAGE:path] marker protocol for shell job image attachments
|
|
77
77
|
- feat: auto-delete watcher and watchdog jobs after completion (delete_after_run)
|
|
78
|
+
- feat(jobs): add durable payload_model_fallback/auth_profile_fallback fields with same-run fallback retry
|
|
78
79
|
- feat: enforce delivery_to as required field on job INSERT
|
|
79
80
|
- feat: multi-platform CI (Linux, macOS, Windows)
|
|
80
81
|
- docs: trust architecture, multi-agent gateway routing, agent adoption files
|
|
@@ -12,7 +12,7 @@ This guide is for setting up the scheduler on a **second or additional OpenClaw
|
|
|
12
12
|
| Requirement | Notes |
|
|
13
13
|
|-------------|-------|
|
|
14
14
|
| macOS or Linux | Tested on macOS arm64 |
|
|
15
|
-
| Node.js
|
|
15
|
+
| Node.js 22 LTS, 24 LTS, or 26 Current | `node --version` (use full path if needed: `/opt/homebrew/bin/node --version`) |
|
|
16
16
|
| OpenClaw gateway running | With auth token |
|
|
17
17
|
| Git or SCP access | To clone/copy the repo |
|
|
18
18
|
|
package/INSTALL-LINUX.md
CHANGED
|
@@ -12,7 +12,7 @@ Step-by-step guide to deploy the scheduler on a Linux host running OpenClaw.
|
|
|
12
12
|
|
|
13
13
|
| Requirement | Notes |
|
|
14
14
|
|-------------|-------|
|
|
15
|
-
| Node.js
|
|
15
|
+
| Node.js 22 LTS, 24 LTS, or 26 Current | Install via [nvm](https://github.com/nvm-sh/nvm) or [NodeSource](https://github.com/nodesource/distributions) |
|
|
16
16
|
| build-essential | `sudo apt install build-essential python3` — required for `better-sqlite3` native compile |
|
|
17
17
|
| OpenClaw gateway running | With auth token |
|
|
18
18
|
| Git | `sudo apt install git` |
|
package/INSTALL-WINDOWS.md
CHANGED
|
@@ -38,7 +38,7 @@ Use this path only if you can't use WSL2 — for example, if OpenClaw itself is
|
|
|
38
38
|
|
|
39
39
|
| Requirement | Install |
|
|
40
40
|
|-------------|---------|
|
|
41
|
-
| Node.js
|
|
41
|
+
| Node.js 22 LTS, 24 LTS, or 26 Current | [nodejs.org](https://nodejs.org) -- use an installer for one of the supported versions |
|
|
42
42
|
| pm2 | `npm install -g pm2` |
|
|
43
43
|
| OpenClaw gateway | Must be running with a valid auth token |
|
|
44
44
|
| Git for Windows | [git-scm.com](https://git-scm.com) or use GitHub Desktop |
|
package/INSTALL.md
CHANGED
|
@@ -11,7 +11,7 @@ If you just want the fastest path to a working local install, start with the npm
|
|
|
11
11
|
| Requirement | Notes |
|
|
12
12
|
|-------------|-------|
|
|
13
13
|
| macOS or Linux | Tested on macOS arm64 |
|
|
14
|
-
| Node.js
|
|
14
|
+
| Node.js 22 LTS, 24 LTS, or 26 Current | `node --version` |
|
|
15
15
|
| OpenClaw gateway running | With auth token |
|
|
16
16
|
| Git or SCP access | To clone/copy the repo |
|
|
17
17
|
|
package/JOB-QUICK-REF.md
CHANGED
|
@@ -196,6 +196,8 @@ openclaw-scheduler jobs reject <id> "not ready yet"
|
|
|
196
196
|
| `payload_kind` | string | yes | `shellCommand`, `systemEvent`, or `agentTurn` |
|
|
197
197
|
| `payload_message` | string | yes | Shell command or agent prompt |
|
|
198
198
|
| `payload_model` | string | no | Model override for agent tasks |
|
|
199
|
+
| `payload_model_fallback` | string | no | Optional fallback model override for same-run retry after primary selection failure |
|
|
200
|
+
| `auth_profile_fallback` | string | no | Optional fallback auth profile for same-run retry after primary selection failure |
|
|
199
201
|
| `run_timeout_ms` | integer | yes | Max run duration in ms (no default) |
|
|
200
202
|
| `delivery_mode` | string | no | `none`, `announce`, `announce-always` |
|
|
201
203
|
| `delivery_channel` | string | no | Channel name (telegram, discord, etc.) |
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/amittell/openclaw-scheduler/actions/workflows/ci.yml)
|
|
4
4
|
[]()
|
|
5
|
-
[](https://nodejs.org)
|
|
6
6
|
|
|
7
7
|
A durable orchestration runtime for [OpenClaw](https://openclaw.ai) agents and shell workflows. Use it when built-in cron and heartbeat stop being enough: jobs fail and disappear into logs, shell scripts depend on gateway uptime, multi-step workflows need retries and approvals, and you want a real audit trail for what ran, what failed, and what triggered what.
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ It replaces OpenClaw's built-in cron/heartbeat with a SQLite-backed scheduler th
|
|
|
11
11
|
**Repo:** `github.com/amittell/openclaw-scheduler`
|
|
12
12
|
**Default location:** `~/.openclaw/scheduler/`
|
|
13
13
|
**Service:** `ai.openclaw.scheduler` (macOS launchd: LaunchAgent or LaunchDaemon)
|
|
14
|
-
**Runtime:** Node.js 22
|
|
14
|
+
**Runtime:** Node.js 22 LTS, 24 LTS, or 26 Current (ESM), SQLite via `better-sqlite3`, cron parsing via `croner`
|
|
15
15
|
**Tests:** run with `npm test` (full suite, in-memory SQLite)
|
|
16
16
|
**Platform:** macOS · Linux · Windows (WSL2)
|
|
17
17
|
|
|
@@ -204,7 +204,7 @@ npm run verify:local # full local maintainer gate
|
|
|
204
204
|
npm run verify:smoke # lightweight smoke gate used by GitHub Actions
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
GitHub Actions runs the smoke gate plus the in-memory test suite on Linux and macOS with Node 22. Publishing uses Node 24
|
|
207
|
+
GitHub Actions runs the smoke gate plus the in-memory test suite on Linux and macOS with the supported Node.js lines: Node 22, Node 24, and Node 26. Publishing uses Node 24 for OIDC trusted publisher support. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
|
|
208
208
|
|
|
209
209
|
### Option C: local npm pack (simulate the published package from source)
|
|
210
210
|
|
|
@@ -1255,14 +1255,14 @@ openclaw-scheduler agents register <id> [name]
|
|
|
1255
1255
|
```
|
|
1256
1256
|
id, name, enabled, schedule_kind, schedule_cron, schedule_at, schedule_tz,
|
|
1257
1257
|
session_target, agent_id, payload_kind, payload_message,
|
|
1258
|
-
payload_model, payload_thinking, execution_intent, execution_read_only,
|
|
1258
|
+
payload_model, payload_model_fallback, payload_thinking, execution_intent, execution_read_only,
|
|
1259
1259
|
overlap_policy, run_timeout_ms, max_queued_dispatches, max_pending_approvals,
|
|
1260
1260
|
max_trigger_fanout, output_store_limit_bytes, output_excerpt_limit_bytes,
|
|
1261
1261
|
output_summary_limit_bytes, output_offload_threshold_bytes,
|
|
1262
1262
|
max_retries, delivery_mode, delivery_channel,
|
|
1263
1263
|
delivery_to, delivery_guarantee, delete_after_run, ttl_hours,
|
|
1264
1264
|
parent_id, trigger_on, trigger_delay_s, trigger_condition,
|
|
1265
|
-
resource_pool, auth_profile,
|
|
1265
|
+
resource_pool, auth_profile, auth_profile_fallback,
|
|
1266
1266
|
approval_required, approval_timeout_s, approval_auto,
|
|
1267
1267
|
context_retrieval, context_retrieval_limit,
|
|
1268
1268
|
preferred_session_key, job_type, watchdog_target_label,
|
package/cli.js
CHANGED
|
@@ -271,19 +271,23 @@ switch (command) {
|
|
|
271
271
|
const isWatchdog = args.includes('--watchdog');
|
|
272
272
|
const profileIdx = args.indexOf('--profile');
|
|
273
273
|
const profileValue = profileIdx >= 0 ? args[profileIdx + 1] : undefined;
|
|
274
|
+
const fallbackProfileIdx = args.indexOf('--fallback-profile');
|
|
275
|
+
const fallbackProfileValue = fallbackProfileIdx >= 0 ? args[fallbackProfileIdx + 1] : undefined;
|
|
274
276
|
const atIdx = args.indexOf('--at');
|
|
275
277
|
const atValue = atIdx >= 0 ? args[atIdx + 1] : undefined;
|
|
276
278
|
const inIdx = args.indexOf('--in');
|
|
277
279
|
const inValue = inIdx >= 0 ? args[inIdx + 1] : undefined;
|
|
278
280
|
const skipArgs = new Set(['--dry-run', '--watchdog']);
|
|
279
281
|
if (profileIdx >= 0) { skipArgs.add(args[profileIdx]); skipArgs.add(args[profileIdx + 1]); }
|
|
282
|
+
if (fallbackProfileIdx >= 0) { skipArgs.add(args[fallbackProfileIdx]); skipArgs.add(args[fallbackProfileIdx + 1]); }
|
|
280
283
|
if (atIdx >= 0) { skipArgs.add(args[atIdx]); skipArgs.add(args[atIdx + 1]); }
|
|
281
284
|
if (inIdx >= 0) { skipArgs.add(args[inIdx]); skipArgs.add(args[inIdx + 1]); }
|
|
282
285
|
const payload = args.find(a => !skipArgs.has(a));
|
|
283
|
-
if (!payload) fail('Usage: jobs add <json> [--dry-run] [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>]');
|
|
286
|
+
if (!payload) fail('Usage: jobs add <json> [--dry-run] [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>] [--fallback-profile <id>]');
|
|
284
287
|
let spec;
|
|
285
288
|
try { spec = JSON.parse(payload); } catch { fail('Invalid JSON. Usage: jobs add \'{"name":"..."}\''); }
|
|
286
289
|
if (profileValue !== undefined) spec.auth_profile = profileValue;
|
|
290
|
+
if (fallbackProfileValue !== undefined) spec.auth_profile_fallback = fallbackProfileValue;
|
|
287
291
|
|
|
288
292
|
// One-shot scheduling via --at or --in
|
|
289
293
|
if (atValue || inValue) {
|
|
@@ -351,14 +355,18 @@ switch (command) {
|
|
|
351
355
|
const dryRun = args.includes('--dry-run');
|
|
352
356
|
const updateProfileIdx = args.indexOf('--profile');
|
|
353
357
|
const updateProfileValue = updateProfileIdx >= 0 ? args[updateProfileIdx + 1] : undefined;
|
|
358
|
+
const updateFallbackProfileIdx = args.indexOf('--fallback-profile');
|
|
359
|
+
const updateFallbackProfileValue = updateFallbackProfileIdx >= 0 ? args[updateFallbackProfileIdx + 1] : undefined;
|
|
354
360
|
const updateFilterArgs = new Set(['--dry-run']);
|
|
355
361
|
if (updateProfileIdx >= 0) { updateFilterArgs.add(args[updateProfileIdx]); updateFilterArgs.add(args[updateProfileIdx + 1]); }
|
|
362
|
+
if (updateFallbackProfileIdx >= 0) { updateFilterArgs.add(args[updateFallbackProfileIdx]); updateFilterArgs.add(args[updateFallbackProfileIdx + 1]); }
|
|
356
363
|
const updateArgs = args.filter(a => !updateFilterArgs.has(a));
|
|
357
364
|
const current = getJob(updateArgs[0]);
|
|
358
365
|
if (!current) fail(`Job not found: ${updateArgs[0]}`);
|
|
359
366
|
let patch;
|
|
360
367
|
try { patch = JSON.parse(updateArgs[1]); } catch { fail('Invalid JSON. Usage: jobs update <id> \'{"key":"value"}\''); }
|
|
361
368
|
if (updateProfileValue !== undefined) patch.auth_profile = updateProfileValue;
|
|
369
|
+
if (updateFallbackProfileValue !== undefined) patch.auth_profile_fallback = updateFallbackProfileValue;
|
|
362
370
|
const normalized = validateJobSpec(patch, current, 'update');
|
|
363
371
|
if (dryRun) {
|
|
364
372
|
emit({ ok: true, dry_run: true, valid: true, normalized });
|
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
* 1 -- error
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync, writeFileSync, renameSync } from 'fs';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
23
23
|
import { execFileSync } from 'child_process';
|
|
24
24
|
import { dirname, join } from 'path';
|
|
25
25
|
import { fileURLToPath } from 'url';
|
|
26
|
+
import { homedir } from 'os';
|
|
26
27
|
import { resolveLabelsPath } from './paths.mjs';
|
|
27
28
|
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const HOME_DIR = process.env.HOME || homedir();
|
|
29
31
|
const LABELS_PATH = resolveLabelsPath({ legacyCandidates: [join(__dirname, 'labels.json')] });
|
|
30
32
|
const INDEX_PATH = process.env.DISPATCH_INDEX_PATH || join(__dirname, 'index.mjs');
|
|
31
33
|
|
|
@@ -64,9 +66,26 @@ function saveLabels(labels) {
|
|
|
64
66
|
renameSync(tmp, LABELS_PATH);
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
function resolveSchedulerCliPath() {
|
|
70
|
+
const candidates = [
|
|
71
|
+
process.env.OPENCLAW_SCHEDULER_CLI,
|
|
72
|
+
process.env.SCHEDULER_CLI,
|
|
73
|
+
join(__dirname, '..', 'cli.js'),
|
|
74
|
+
join(HOME_DIR, '.openclaw', 'packages', 'openclaw-scheduler', 'node_modules', 'openclaw-scheduler', 'cli.js'),
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
if (existsSync(candidate)) return candidate;
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return join(__dirname, '..', 'cli.js');
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
function notify(message) {
|
|
68
87
|
try {
|
|
69
|
-
const cliPath =
|
|
88
|
+
const cliPath = resolveSchedulerCliPath();
|
|
70
89
|
execFileSync(process.execPath, [cliPath, 'msg', 'send', 'scheduler', 'main', message], {
|
|
71
90
|
encoding: 'utf-8',
|
|
72
91
|
timeout: 10000,
|
package/dispatch/completion.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const HUMAN_SUMMARY_SECTION_RE = /(?:^|\n)\s*(?:Human-readable summary|Human sum
|
|
|
39
39
|
const TECHNICAL_DETAILS_SECTION_RE = /(?:^|\n)\s*(?:Technical details?|Details(?:_technical)?)\s*:\s*/i;
|
|
40
40
|
const HUMAN_SUMMARY_LABEL_RE = /^(?:human-readable summary|human summary)\s*:\s*/i;
|
|
41
41
|
const TECHNICAL_DETAILS_LABEL_RE = /^(?:technical details?|details(?:_technical)?)\s*:\s*/i;
|
|
42
|
+
const FORMATTER_META_SUMMARY_RE = /^Final completion updates now (?:start with a short plain-English summary|arrive as one clean plain-English summary)\./i;
|
|
42
43
|
const FINAL_REPORT_HEADING_RE = /^(?:#{1,6}\s*)?(?:root cause|files? changed|changes|validation|tests?(?: run| passed)?|sacrificial(?: delivery)?(?: result)?|deployment(?:\/live-runtime)?(?: step)?|live-runtime(?: step)?|result|results|summary|highlights?|notes?|follow[- ]ups?|next steps?|blockers?|implementation|what changed|verification)\s*:?$/i;
|
|
43
44
|
const FINAL_REPORT_CUE_RE = /\b(?:root cause|files? changed|tests? run|validation|sacrificial(?: delivery)?(?: result)?|deployment(?:\/live-runtime)?(?: step)?|live-runtime(?: step)?|final report|human-readable report|files changed|tests passed)\b/i;
|
|
44
45
|
|
|
@@ -751,6 +752,42 @@ function buildCompletionLeadFromThemes(themes) {
|
|
|
751
752
|
return truncateText(sentences.join(' '), MAX_DELIVERY_CHARS);
|
|
752
753
|
}
|
|
753
754
|
|
|
755
|
+
function looksLikeFormatterMetaSummary(text) {
|
|
756
|
+
const normalized = normalizeCompletionText(text);
|
|
757
|
+
return Boolean(normalized && FORMATTER_META_SUMMARY_RE.test(normalized));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function getCompletionRawSummary(completion) {
|
|
761
|
+
const details = getCompletionTechnicalDetails(completion);
|
|
762
|
+
if (!details || typeof details !== 'object') return null;
|
|
763
|
+
return normalizeCompletionText(details.raw_summary ?? details.rawSummary);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function looksLikeCompletionFormatterImplementation(text) {
|
|
767
|
+
const normalized = normalizeCompletionText(text);
|
|
768
|
+
if (!normalized) return false;
|
|
769
|
+
|
|
770
|
+
const fragments = extractTechnicalFragments(normalized);
|
|
771
|
+
const themes = detectTechnicalThemes(normalized, fragments);
|
|
772
|
+
if (!themes.completionFlow) return false;
|
|
773
|
+
|
|
774
|
+
return TECHNICAL_COMMIT_PREFIX_RE.test(cleanMarkdown(normalized).replace(/\s+/g, ' ').trim())
|
|
775
|
+
|| /\b(?:completion|deliveryText|summary_human|summaryHuman|details_technical|resolveCompletionDelivery|buildTerminalCompletionPayload|payload-precedence|watcher path|completion watcher|final completion updates?)\b/i.test(normalized);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function getRawSummaryOverrideForFormatterMeta(completion, structuredTexts) {
|
|
779
|
+
if (!structuredTexts.some(looksLikeFormatterMetaSummary)) return null;
|
|
780
|
+
|
|
781
|
+
const rawSummary = getCompletionRawSummary(completion);
|
|
782
|
+
if (!rawSummary) return null;
|
|
783
|
+
if (isGenericOrTrivial(rawSummary)) return null;
|
|
784
|
+
if (isInternalTransportNoiseText(rawSummary)) return null;
|
|
785
|
+
if (looksLikeRawPayloadText(rawSummary)) return null;
|
|
786
|
+
if (looksLikeCompletionFormatterImplementation(rawSummary)) return null;
|
|
787
|
+
|
|
788
|
+
return truncateText(rawSummary, MAX_DELIVERY_CHARS);
|
|
789
|
+
}
|
|
790
|
+
|
|
754
791
|
function buildHumanizedTechnicalSummary(rawText, fallbackSummary) {
|
|
755
792
|
const cleanedRaw = normalizeCompletionText(rawText);
|
|
756
793
|
const fallback = normalizeCompletionText(fallbackSummary);
|
|
@@ -1306,6 +1343,18 @@ export function resolveCompletionDelivery({ lastReply, completion, fallbackSumma
|
|
|
1306
1343
|
const authoritativeStructuredSummary = completionDeliverySource && completionDeliverySource !== 'technical-synthesis'
|
|
1307
1344
|
? preferredSummary
|
|
1308
1345
|
: null;
|
|
1346
|
+
const rawSummaryOverride = getRawSummaryOverrideForFormatterMeta(completion, [
|
|
1347
|
+
rawCompletionSummaryHuman,
|
|
1348
|
+
rawCompletionSummary,
|
|
1349
|
+
rawCompletionDelivery,
|
|
1350
|
+
]);
|
|
1351
|
+
if (rawSummaryOverride) {
|
|
1352
|
+
return {
|
|
1353
|
+
deliveryText: rawSummaryOverride,
|
|
1354
|
+
summary: rawSummaryOverride,
|
|
1355
|
+
source: 'raw-summary',
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1309
1358
|
const noisyTexts = [
|
|
1310
1359
|
rawReply,
|
|
1311
1360
|
rawCompletionSummaryHuman,
|
package/dispatch/index.mjs
CHANGED
|
@@ -123,6 +123,86 @@ function sleep(ms) {
|
|
|
123
123
|
return new Promise(r => setTimeout(r, ms));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
function deployedSchedulerDispatchPath(fileName) {
|
|
127
|
+
return join(
|
|
128
|
+
HOME_DIR,
|
|
129
|
+
'.openclaw',
|
|
130
|
+
'packages',
|
|
131
|
+
'openclaw-scheduler',
|
|
132
|
+
'node_modules',
|
|
133
|
+
'openclaw-scheduler',
|
|
134
|
+
'dispatch',
|
|
135
|
+
fileName,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveSchedulerCliPath() {
|
|
140
|
+
const candidates = [
|
|
141
|
+
process.env.OPENCLAW_SCHEDULER_CLI,
|
|
142
|
+
process.env.SCHEDULER_CLI,
|
|
143
|
+
join(__dirname, '..', 'cli.js'),
|
|
144
|
+
join(HOME_DIR, '.openclaw', 'packages', 'openclaw-scheduler', 'node_modules', 'openclaw-scheduler', 'cli.js'),
|
|
145
|
+
].filter(Boolean);
|
|
146
|
+
|
|
147
|
+
for (const candidate of candidates) {
|
|
148
|
+
try {
|
|
149
|
+
if (existsSync(candidate)) return candidate;
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return join(__dirname, '..', 'cli.js');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function currentDirLooksLikeBrandWrapper() {
|
|
157
|
+
return !existsSync(join(__dirname, '..', 'cli.js'));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveDispatchScriptPath(fileName) {
|
|
161
|
+
const localPath = join(__dirname, fileName);
|
|
162
|
+
const deployedPath = deployedSchedulerDispatchPath(fileName);
|
|
163
|
+
const preferDeployed = currentDirLooksLikeBrandWrapper();
|
|
164
|
+
const candidates = [
|
|
165
|
+
fileName === 'index.mjs' ? process.env.DISPATCH_INDEX_PATH : null,
|
|
166
|
+
preferDeployed ? deployedPath : localPath,
|
|
167
|
+
preferDeployed ? localPath : deployedPath,
|
|
168
|
+
].filter(Boolean);
|
|
169
|
+
|
|
170
|
+
for (const candidate of candidates) {
|
|
171
|
+
try {
|
|
172
|
+
if (existsSync(candidate)) return candidate;
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return localPath;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolvePersistentNodePath() {
|
|
180
|
+
const explicit = process.env.OPENCLAW_NODE_PATH ||
|
|
181
|
+
process.env.OPENCLAW_NODE ||
|
|
182
|
+
process.env.NODE_BINARY;
|
|
183
|
+
if (explicit) return explicit;
|
|
184
|
+
|
|
185
|
+
const execPath = process.execPath || 'node';
|
|
186
|
+
const homebrewNode = execPath.startsWith('/opt/homebrew/')
|
|
187
|
+
? '/opt/homebrew/bin/node'
|
|
188
|
+
: execPath.startsWith('/usr/local/')
|
|
189
|
+
? '/usr/local/bin/node'
|
|
190
|
+
: null;
|
|
191
|
+
const isVersionedHomebrewPath = /\/(?:Cellar|opt)\/node(?:@[^/]+)?\//.test(execPath);
|
|
192
|
+
|
|
193
|
+
if (homebrewNode && isVersionedHomebrewPath) {
|
|
194
|
+
try {
|
|
195
|
+
if (existsSync(homebrewNode)) return homebrewNode;
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return execPath;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function dispatchConfigDirForChild() {
|
|
203
|
+
return process.env.DISPATCH_CONFIG_DIR || INVOKE_DIR;
|
|
204
|
+
}
|
|
205
|
+
|
|
126
206
|
function toTimestampMs(value) {
|
|
127
207
|
if (value == null) return null;
|
|
128
208
|
if (typeof value === 'number') {
|
|
@@ -559,6 +639,62 @@ function agentFromSessionKey(sessionKey) {
|
|
|
559
639
|
return 'main';
|
|
560
640
|
}
|
|
561
641
|
|
|
642
|
+
function getSessionJsonlMtimeMs(agent, sessionId) {
|
|
643
|
+
const jsonlPath = getSessionJsonlPath(agent, sessionId);
|
|
644
|
+
if (!jsonlPath) return null;
|
|
645
|
+
try {
|
|
646
|
+
return statSync(jsonlPath).mtimeMs;
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function getJsonlPendingToolReason(entries) {
|
|
653
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
654
|
+
const last = entries[entries.length - 1];
|
|
655
|
+
|
|
656
|
+
if (last?.role === 'assistant') {
|
|
657
|
+
const content = Array.isArray(last.content) ? last.content : [];
|
|
658
|
+
const toolUse = content.find(c => c?.type === 'tool_use');
|
|
659
|
+
if (toolUse) {
|
|
660
|
+
return `last assistant entry has tool_use (${toolUse.name || 'unknown'}) -- awaiting tool result`;
|
|
661
|
+
}
|
|
662
|
+
if (last.type === 'tool_use') {
|
|
663
|
+
return `last entry is tool_use (${last.name || 'unknown'}) -- awaiting tool result`;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (last?.role === 'user') {
|
|
668
|
+
const content = Array.isArray(last.content) ? last.content : [];
|
|
669
|
+
if (content.some(c => c?.type === 'tool_result')) {
|
|
670
|
+
return 'last entry is tool_result (tool executed, awaiting assistant reply)';
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (last?.type === 'tool_result') {
|
|
675
|
+
return 'last entry is tool_result (tool executed, awaiting assistant reply)';
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function getJsonlTerminalReplyReason(entries) {
|
|
682
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
683
|
+
const terminalReply = extractTerminalAssistantReplyFromEntries(entries);
|
|
684
|
+
if (!terminalReply) return null;
|
|
685
|
+
|
|
686
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
687
|
+
const entry = entries[i];
|
|
688
|
+
if (entry?.role === 'assistant') {
|
|
689
|
+
return entry.stop_reason === 'end_turn'
|
|
690
|
+
? 'terminal assistant reply observed in JSONL'
|
|
691
|
+
: null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
562
698
|
// -- Gateway Session State Check ------------------------------
|
|
563
699
|
|
|
564
700
|
/**
|
|
@@ -647,9 +783,35 @@ function checkSessionDone(sessionKey, sessionsStore, thresholdMs, sessionEverFou
|
|
|
647
783
|
}
|
|
648
784
|
|
|
649
785
|
// 2. Session exists in store, check idle time.
|
|
650
|
-
const entry
|
|
651
|
-
const
|
|
652
|
-
const
|
|
786
|
+
const entry = sessionsStore[sessionKey];
|
|
787
|
+
const agent = agentFromSessionKey(sessionKey) || 'main';
|
|
788
|
+
const updatedAtMs = toTimestampMs(entry.updatedAt);
|
|
789
|
+
const lastActivityAtMs = toTimestampMs(entry.lastActivityAt);
|
|
790
|
+
const jsonlMtimeMs = getSessionJsonlMtimeMs(agent, entry.sessionId);
|
|
791
|
+
const activityTimes = [updatedAtMs, lastActivityAtMs, jsonlMtimeMs].filter(t => typeof t === 'number');
|
|
792
|
+
const lastActivity = activityTimes.length ? Math.max(...activityTimes) : null;
|
|
793
|
+
const silenceMs = lastActivity === null ? Infinity : Date.now() - lastActivity;
|
|
794
|
+
|
|
795
|
+
if (entry.sessionId) {
|
|
796
|
+
const entries = readJsonlTailEntries(entry.sessionId, agent, 20);
|
|
797
|
+
const pendingToolReason = getJsonlPendingToolReason(entries);
|
|
798
|
+
if (pendingToolReason) {
|
|
799
|
+
return {
|
|
800
|
+
shouldResolve: false,
|
|
801
|
+
reason: `session JSONL shows pending work: ${pendingToolReason}`,
|
|
802
|
+
lastActivity,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const terminalReplyReason = getJsonlTerminalReplyReason(entries);
|
|
807
|
+
if (terminalReplyReason) {
|
|
808
|
+
return {
|
|
809
|
+
shouldResolve: false,
|
|
810
|
+
reason: `${terminalReplyReason}; watcher/result path should deliver completion`,
|
|
811
|
+
lastActivity,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
653
815
|
|
|
654
816
|
if (silenceMs >= thresholdMs) {
|
|
655
817
|
return {
|
|
@@ -681,7 +843,7 @@ function disarmWatchdog(label) {
|
|
|
681
843
|
const entry = getLabel(label);
|
|
682
844
|
if (!entry?.watchdogJobId) return;
|
|
683
845
|
try {
|
|
684
|
-
const schedulerCli =
|
|
846
|
+
const schedulerCli = resolveSchedulerCliPath();
|
|
685
847
|
execFileSync(process.execPath, [schedulerCli, 'jobs', 'delete', entry.watchdogJobId], {
|
|
686
848
|
encoding: 'utf-8',
|
|
687
849
|
timeout: 5000,
|
|
@@ -715,15 +877,18 @@ function scheduleDeliveryWatcherJob({
|
|
|
715
877
|
if (!label) throw new Error('label is required');
|
|
716
878
|
if (!deliverTo) throw new Error('deliverTo is required');
|
|
717
879
|
|
|
718
|
-
const schedulerCli =
|
|
719
|
-
const watcherPath =
|
|
880
|
+
const schedulerCli = resolveSchedulerCliPath();
|
|
881
|
+
const watcherPath = resolveDispatchScriptPath('watcher.mjs');
|
|
882
|
+
const dispatchIndexPath = resolveDispatchScriptPath('index.mjs');
|
|
883
|
+
const nodePath = resolvePersistentNodePath();
|
|
720
884
|
const watcherTimeoutS = Number(timeoutSeconds) + 120;
|
|
721
885
|
const idleThresholdS = Number(idleThresholdSeconds) || 300;
|
|
722
886
|
const sq = quoteForSingleQuotedShell;
|
|
723
887
|
const watcherCmd =
|
|
888
|
+
`DISPATCH_CONFIG_DIR='${sq(dispatchConfigDirForChild())}' ` +
|
|
724
889
|
`DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' ` +
|
|
725
|
-
`DISPATCH_INDEX_PATH='${sq(
|
|
726
|
-
`'${sq(
|
|
890
|
+
`DISPATCH_INDEX_PATH='${sq(dispatchIndexPath)}' ` +
|
|
891
|
+
`'${sq(nodePath)}' '${sq(watcherPath)}' ` +
|
|
727
892
|
`--label '${sq(label)}' --timeout ${watcherTimeoutS} ` +
|
|
728
893
|
`--poll-interval 20 --idle-threshold ${idleThresholdS} --once`;
|
|
729
894
|
|
|
@@ -962,7 +1127,7 @@ async function cmdEnqueue(flags) {
|
|
|
962
1127
|
// -- Checkpoint notify command (mid-run status messages) -----
|
|
963
1128
|
// Agents can call this command at logical checkpoints to send status updates
|
|
964
1129
|
// that will be delivered to the inbox consumer (and ultimately Telegram).
|
|
965
|
-
const schedulerCliPath =
|
|
1130
|
+
const schedulerCliPath = resolveSchedulerCliPath();
|
|
966
1131
|
const checkpointNotifyCmd = `node '${schedulerCliPath}' messages send --from '${label.replace(/'/g, "'\\''")}' --to main --kind status --body`;
|
|
967
1132
|
// TODO: Inject CHECKPOINT_NOTIFY_CMD as an env var into the agent session so
|
|
968
1133
|
// agents can discover the checkpoint command programmatically (not just from
|
|
@@ -1142,7 +1307,10 @@ async function cmdEnqueue(flags) {
|
|
|
1142
1307
|
let watchdogJobId = null;
|
|
1143
1308
|
if (monitorEnabled && deliverTo) {
|
|
1144
1309
|
try {
|
|
1145
|
-
const checkCmd =
|
|
1310
|
+
const checkCmd =
|
|
1311
|
+
`DISPATCH_CONFIG_DIR='${sq(dispatchConfigDirForChild())}' ` +
|
|
1312
|
+
`DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' ` +
|
|
1313
|
+
`'${sq(resolvePersistentNodePath())}' '${sq(resolveDispatchScriptPath('index.mjs'))}' result --label '${sq(label)}'`;
|
|
1146
1314
|
const alertChannel = deliverChannel || 'telegram';
|
|
1147
1315
|
const alertTarget = deliverTo;
|
|
1148
1316
|
const watchdogSpec = JSON.stringify({
|
|
@@ -1164,7 +1332,7 @@ async function cmdEnqueue(flags) {
|
|
|
1164
1332
|
delete_after_run: 1, // auto-delete after watchdog fires
|
|
1165
1333
|
origin: origin || 'system',
|
|
1166
1334
|
});
|
|
1167
|
-
const schedulerCli =
|
|
1335
|
+
const schedulerCli = resolveSchedulerCliPath();
|
|
1168
1336
|
const addResult = execFileSync(process.execPath, [schedulerCli, 'jobs', 'add', watchdogSpec, '--watchdog', '--json'], {
|
|
1169
1337
|
encoding: 'utf-8',
|
|
1170
1338
|
timeout: 10000,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve as pathResolve } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
function schedulerHome() {
|
|
6
|
+
return process.env.OPENCLAW_SCHEDULER_HOME ||
|
|
7
|
+
join(process.env.HOME || homedir(), '.openclaw', 'scheduler');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveDispatchStateDir() {
|
|
11
|
+
return process.env.DISPATCH_STATE_DIR ||
|
|
12
|
+
join(schedulerHome(), 'dispatch');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveLabelsPath({ legacyCandidates = [] } = {}) {
|
|
16
|
+
if (process.env.DISPATCH_LABELS_PATH) {
|
|
17
|
+
mkdirSync(dirname(process.env.DISPATCH_LABELS_PATH), { recursive: true });
|
|
18
|
+
return process.env.DISPATCH_LABELS_PATH;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const labelsPath = join(resolveDispatchStateDir(), 'labels.json');
|
|
22
|
+
mkdirSync(dirname(labelsPath), { recursive: true });
|
|
23
|
+
|
|
24
|
+
if (!existsSync(labelsPath)) {
|
|
25
|
+
const normalizedTarget = pathResolve(labelsPath);
|
|
26
|
+
const legacyPath = legacyCandidates
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((candidate) => pathResolve(candidate))
|
|
29
|
+
.find((candidate) => candidate !== normalizedTarget && existsSync(candidate));
|
|
30
|
+
if (legacyPath) {
|
|
31
|
+
copyFileSync(legacyPath, labelsPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return labelsPath;
|
|
36
|
+
}
|