openclaw-scheduler 0.2.9 → 0.2.11

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.11] -- 2026-06-23
6
+
7
+ ### Fixed
8
+ - fix(dispatch): clarify that completion markers must run in the originating local dispatch shell, and allow watcher delivery from clean terminal `stop_reason=end_turn` replies without broadening plain `lastReply` success detection
9
+ - fix(watcher): use the fatal idle threshold for stalled-session errors while keeping quiet high-thinking sessions pending at the probe threshold
10
+
5
11
  ## [0.2.5] -- 2026-04-27
6
12
 
7
13
  ### Fixed
@@ -75,6 +81,7 @@ All notable changes to this project will be documented in this file.
75
81
  - feat: x-openclaw-env-inject header for agent task credentials (PR #5)
76
82
  - feat: [IMAGE:path] marker protocol for shell job image attachments
77
83
  - feat: auto-delete watcher and watchdog jobs after completion (delete_after_run)
84
+ - feat(jobs): add durable payload_model_fallback/auth_profile_fallback fields with same-run fallback retry
78
85
  - feat: enforce delivery_to as required field on job INSERT
79
86
  - feat: multi-platform CI (Linux, macOS, Windows)
80
87
  - 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);
@@ -1097,6 +1134,7 @@ export function buildCompletionSignalInstructions({ label, taskPrompt, doneScrip
1097
1134
  readinessChecks.forEach((line, idx) => lines.push(` ${idx + 1}. ${line}`));
1098
1135
  lines.push('');
1099
1136
  lines.push('Call this as your ABSOLUTE FINAL action -- nothing else runs after this:');
1137
+ lines.push(' IMPORTANT: run this command in the dispatch session shell on this host. Do not run it inside ssh, docker, tmux, or any remote shell; remote label stores cannot mark this dispatch complete.');
1100
1138
  lines.push(` node '${doneScriptPath}' done --label '${escapedLabel}' \\`);
1101
1139
  lines.push(' --summary "<human-readable summary of what you actually did>" \\');
1102
1140
  lines.push(` --checklist '${checklistExample}' \\`);
@@ -1306,6 +1344,18 @@ export function resolveCompletionDelivery({ lastReply, completion, fallbackSumma
1306
1344
  const authoritativeStructuredSummary = completionDeliverySource && completionDeliverySource !== 'technical-synthesis'
1307
1345
  ? preferredSummary
1308
1346
  : null;
1347
+ const rawSummaryOverride = getRawSummaryOverrideForFormatterMeta(completion, [
1348
+ rawCompletionSummaryHuman,
1349
+ rawCompletionSummary,
1350
+ rawCompletionDelivery,
1351
+ ]);
1352
+ if (rawSummaryOverride) {
1353
+ return {
1354
+ deliveryText: rawSummaryOverride,
1355
+ summary: rawSummaryOverride,
1356
+ source: 'raw-summary',
1357
+ };
1358
+ }
1309
1359
  const noisyTexts = [
1310
1360
  rawReply,
1311
1361
  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,