openclaw-scheduler 0.2.9 → 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 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 >= 22 | `node --version` (use full path if needed: `/opt/homebrew/bin/node --version`) |
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 >= 22 | Install via [nvm](https://github.com/nvm-sh/nvm) or [NodeSource](https://github.com/nodesource/distributions) |
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` |
@@ -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 >= 22 | [nodejs.org](https://nodejs.org) -- use the LTS installer |
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 >= 22 | `node --version` |
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
  [![CI](https://github.com/amittell/openclaw-scheduler/actions/workflows/ci.yml/badge.svg)](https://github.com/amittell/openclaw-scheduler/actions/workflows/ci.yml)
4
4
  [![License](https://img.shields.io/badge/license-MIT-blue)]()
5
- [![Node](https://img.shields.io/badge/node-%E2%89%A522-green)](https://nodejs.org)
5
+ [![Node](https://img.shields.io/badge/node-22%20%7C%2024%20%7C%2026-green)](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+ (ESM), SQLite via `better-sqlite3`, cron parsing via `croner`
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 (npm 22+) for OIDC trusted publisher support. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
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 = join(__dirname, '..', 'cli.js');
88
+ const cliPath = resolveSchedulerCliPath();
70
89
  execFileSync(process.execPath, [cliPath, 'msg', 'send', 'scheduler', 'main', message], {
71
90
  encoding: 'utf-8',
72
91
  timeout: 10000,
@@ -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,
@@ -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 = sessionsStore[sessionKey];
651
- const lastActivity = entry.updatedAt || 0;
652
- const silenceMs = Date.now() - lastActivity;
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 = join(__dirname, '..', 'cli.js');
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 = join(__dirname, '..', 'cli.js');
719
- const watcherPath = join(__dirname, 'watcher.mjs');
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(join(__dirname, 'index.mjs'))}' ` +
726
- `'${sq(process.execPath)}' '${sq(watcherPath)}' ` +
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 = join(__dirname, '..', 'cli.js');
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 = `'${sq(process.execPath)}' '${sq(join(__dirname, 'index.mjs'))}' result --label '${sq(label)}'`;
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 = join(__dirname, '..', 'cli.js');
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,
@@ -39,6 +39,7 @@ import {
39
39
  import { getDispatchLivenessPolicy } from './liveness.mjs';
40
40
  import { resolveLabelsPath } from './paths.mjs';
41
41
  import { sendMessage } from '../messages.js';
42
+ import { ensureArtifactsDir, resolveArtifactsDir } from '../paths.js';
42
43
 
43
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
  const INDEX_PATH = process.env.DISPATCH_INDEX_PATH || join(__dirname, 'index.mjs');
@@ -54,12 +55,68 @@ const MAX_GW_RESTART_RETRIES = 2; // Max retries for gateway-restart-kill recove
54
55
 
55
56
  const FLAT_WINDOW_MS = 3 * 60 * 1000; // 3 min flat = genuinely stuck
56
57
  const ACTIVITY_POLL_MS = 30_000;
58
+ const COMPLETION_INLINE_LIMIT_BYTES = parsePositiveEnvInt('DISPATCH_COMPLETION_INLINE_LIMIT_BYTES', 60 * 1024);
57
59
 
58
60
  /** How often the watcher writes lastPing to labels.json (heartbeat signal).
59
61
  * The watchdog guard in index.mjs treats pings older than 3x this as stale,
60
62
  * so PING_INTERVAL_MS must stay well below PING_STALE_MS (3 * 60_000). */
61
63
  const PING_INTERVAL_MS = 60_000; // 60 seconds
62
64
 
65
+ function parsePositiveEnvInt(name, fallback) {
66
+ const value = Number.parseInt(String(process.env[name] ?? ''), 10);
67
+ return Number.isFinite(value) && value > 0 ? value : fallback;
68
+ }
69
+
70
+ function byteLength(text) {
71
+ return Buffer.byteLength(String(text ?? ''), 'utf8');
72
+ }
73
+
74
+ function sliceUtf8Bytes(text, maxBytes) {
75
+ const source = String(text ?? '');
76
+ if (byteLength(source) <= maxBytes) return source;
77
+
78
+ let usedBytes = 0;
79
+ let endIndex = 0;
80
+ for (const char of source) {
81
+ const charBytes = byteLength(char);
82
+ if (usedBytes + charBytes > maxBytes) break;
83
+ usedBytes += charBytes;
84
+ endIndex += char.length;
85
+ }
86
+ return source.slice(0, endIndex).trimEnd();
87
+ }
88
+
89
+ function completionArtifactPath(label) {
90
+ const safeLabel = String(label || 'completion')
91
+ .replace(/[^a-z0-9._-]+/gi, '-')
92
+ .replace(/^-+|-+$/g, '')
93
+ .slice(0, 80) || 'completion';
94
+ const dir = ensureArtifactsDir(join(resolveArtifactsDir({ env: process.env }), 'dispatch-completions'));
95
+ return join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${safeLabel}.txt`);
96
+ }
97
+
98
+ function formatCompletionStdout(label, deliveryText) {
99
+ const header = `🌶️ *dispatch* [${label}] completed:\n\n`;
100
+ const body = String(deliveryText ?? '');
101
+ const bodyBytes = byteLength(body);
102
+
103
+ if (bodyBytes <= COMPLETION_INLINE_LIMIT_BYTES) {
104
+ return `${header}${body}\n`;
105
+ }
106
+
107
+ let artifactNote;
108
+ try {
109
+ const artifactPath = completionArtifactPath(label);
110
+ writeFileSync(artifactPath, body, 'utf8');
111
+ artifactNote = `\n\nFull completion report saved to ${artifactPath} (${bodyBytes} bytes). Inline delivery capped at ${COMPLETION_INLINE_LIMIT_BYTES} bytes to avoid dumping an oversized report.`;
112
+ } catch (err) {
113
+ artifactNote = `\n\nFull completion report was ${bodyBytes} bytes, but saving the oversized report failed: ${err.message}. Inline delivery capped at ${COMPLETION_INLINE_LIMIT_BYTES} bytes.`;
114
+ }
115
+
116
+ const bodyBudget = Math.max(0, COMPLETION_INLINE_LIMIT_BYTES - byteLength(artifactNote));
117
+ const inlineBody = sliceUtf8Bytes(body, bodyBudget);
118
+ return `${header}${inlineBody}${artifactNote}\n`;
119
+ }
63
120
 
64
121
  function getGatewayToken() {
65
122
  if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
@@ -922,11 +979,7 @@ function deliverResult(label, lastReply, fallbackSummary, completionPayload = nu
922
979
  markLabelDone(label, completion.summary);
923
980
 
924
981
  if (completion.deliveryText) {
925
- const maxLen = 3500;
926
- const reply = completion.deliveryText.length > maxLen
927
- ? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
928
- : completion.deliveryText;
929
- process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
982
+ process.stdout.write(formatCompletionStdout(label, completion.deliveryText));
930
983
  process.exit(0);
931
984
  }
932
985
 
@@ -1120,14 +1173,18 @@ function runOnceAndExit() {
1120
1173
  }
1121
1174
 
1122
1175
  const ageMs = status.liveness?.ageMs;
1123
- const idleResultCheckMs = getCurrentLivenessPolicy().idleProbeMs;
1176
+ const livenessPolicy = getCurrentLivenessPolicy();
1177
+ const idleResultCheckMs = livenessPolicy.idleProbeMs;
1178
+ const idleFailureMs = livenessPolicy.idleFailureMs;
1124
1179
  if (ageMs != null && ageMs >= idleResultCheckMs) {
1125
1180
  const result = dispatch('result', ['--label', label]);
1126
1181
  if (hasStructuredCompletion(result)) {
1127
1182
  deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1128
1183
  }
1129
1184
 
1130
- const stallReason = getRunningSessionStallReason(status, idleResultCheckMs);
1185
+ const stallReason = ageMs >= idleFailureMs
1186
+ ? getRunningSessionStallReason(status, idleFailureMs)
1187
+ : null;
1131
1188
  if (stallReason) {
1132
1189
  process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1133
1190
  markLabelError(label, stallReason);
@@ -1504,14 +1561,18 @@ while (Date.now() < deadline) {
1504
1561
  // while this watcher's lastPing heartbeat is fresh (written every 60s);
1505
1562
  // this path handles normal completion before the ping goes stale.
1506
1563
  const ageMs = status.liveness?.ageMs;
1507
- const idleResultCheckMs = getCurrentLivenessPolicy().idleProbeMs;
1564
+ const livenessPolicy = getCurrentLivenessPolicy();
1565
+ const idleResultCheckMs = livenessPolicy.idleProbeMs;
1566
+ const idleFailureMs = livenessPolicy.idleFailureMs;
1508
1567
  if (ageMs != null && ageMs >= idleResultCheckMs) {
1509
1568
  const result = dispatch('result', ['--label', label]);
1510
1569
  if (hasStructuredCompletion(result)) {
1511
1570
  deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1512
1571
  }
1513
1572
 
1514
- const stallReason = getRunningSessionStallReason(status, idleResultCheckMs);
1573
+ const stallReason = ageMs >= idleFailureMs
1574
+ ? getRunningSessionStallReason(status, idleFailureMs)
1575
+ : null;
1515
1576
  if (stallReason) {
1516
1577
  process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1517
1578
  markLabelError(label, stallReason);
@@ -1530,6 +1591,14 @@ while (Date.now() < deadline) {
1530
1591
  // Timed out -- try one last result check
1531
1592
  const finalResult = dispatch('result', ['--label', label]);
1532
1593
  const finalStatus = dispatch('status', ['--label', label]);
1594
+ if (hasStructuredCompletion(finalResult)) {
1595
+ deliverResult(
1596
+ label,
1597
+ finalResult?.lastReply || null,
1598
+ finalStatus?.summary || null,
1599
+ finalResult?.completion || finalStatus?.completion || null,
1600
+ );
1601
+ }
1533
1602
  if (finalStatus?.status === 'done') {
1534
1603
  const rc = getRetryCount(label);
1535
1604
  if (rc > 0) setRetryCount(label, 0);