openclaw-scheduler 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -0
- package/BEST-PRACTICES.md +20 -3
- package/CHANGELOG.md +20 -0
- package/INSTALL.md +23 -0
- package/README.md +24 -12
- package/UPGRADING.md +31 -0
- package/dispatch/index.mjs +73 -28
- package/dispatch/watcher.mjs +101 -38
- package/dispatcher-strategies.js +35 -1
- package/dispatcher.js +19 -4
- package/gateway.js +117 -1
- package/jobs.js +1 -1
- package/package.json +2 -1
- package/provider-registry.js +94 -0
- package/scripts/inbox-consumer.mjs +28 -1
package/AGENTS.md
CHANGED
|
@@ -27,6 +27,27 @@ For manifest authoring, validation, and identity/authorization profiles, use
|
|
|
27
27
|
- Shell jobs (`session_target: "shell"`) run without the gateway. Agent jobs
|
|
28
28
|
(`session_target: "isolated"` or `"main"`) require a running gateway.
|
|
29
29
|
|
|
30
|
+
## Checking Job Status — Always Poll, Never Infer
|
|
31
|
+
|
|
32
|
+
When reporting on whether a dispatched job is running, done, or stuck, **always call the status command directly** — never infer from check-in messages or notifications that appeared in your conversation.
|
|
33
|
+
|
|
34
|
+
Check-in messages are delivered asynchronously. By the time they appear, the job may already be finished, failed, or on a later step. Conversation messages are stale by definition.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# For chilisaus-dispatched jobs:
|
|
38
|
+
node ~/.openclaw/chilisaus/index.mjs status --label <label>
|
|
39
|
+
|
|
40
|
+
# For scheduler jobs:
|
|
41
|
+
openclaw-scheduler runs list <job-id> --json
|
|
42
|
+
openclaw-scheduler runs running --json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `status` output gives authoritative `status` (`accepted` / `running` / `done` / `error`), `updatedAt` timestamp, and final `summary`. Use that.
|
|
46
|
+
|
|
47
|
+
**Rule: if you haven't polled status, you don't know the status.**
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
30
51
|
## Error Handling
|
|
31
52
|
|
|
32
53
|
CLI errors exit non-zero. In plain-text mode, the message goes to stderr. With
|
package/BEST-PRACTICES.md
CHANGED
|
@@ -134,7 +134,7 @@ Use `isolated` when:
|
|
|
134
134
|
|
|
135
135
|
| Rule | Bad | Good |
|
|
136
136
|
|------|-----|------|
|
|
137
|
-
| Be imperative and specific | "check kubernetes" | "Check k8s pods in
|
|
137
|
+
| Be imperative and specific | "check kubernetes" | "Check k8s pods in myapp-prod and myapp-staging. List any non-Running pods." |
|
|
138
138
|
| Include a success signal | *(nothing)* | "If all pods Running, reply with exactly: HEARTBEAT_OK" |
|
|
139
139
|
| Specify output format | *(nothing)* | "Format issues as: ⚠️ \<namespace\>/\<pod\>: \<status\>" |
|
|
140
140
|
| State available resources | *(implicit)* | "Your memory files are in ~/.openclaw/workspace/memory/" |
|
|
@@ -147,7 +147,7 @@ Check everything and let me know if anything is wrong
|
|
|
147
147
|
|
|
148
148
|
**Good prompt:**
|
|
149
149
|
```
|
|
150
|
-
Check k8s pod health across
|
|
150
|
+
Check k8s pod health across myapp-prod and myapp-staging namespaces.
|
|
151
151
|
List any non-Running pods. If all pods are Running, reply with exactly: HEARTBEAT_OK
|
|
152
152
|
Format any issues as: ⚠️ <namespace>/<pod>: <status>
|
|
153
153
|
```
|
|
@@ -282,7 +282,7 @@ When the dispatcher fires an isolated job, the agent receives a message structur
|
|
|
282
282
|
From: scheduler | result | Previous backup: 3 files committed, pushed to origin
|
|
283
283
|
---
|
|
284
284
|
|
|
285
|
-
Check k8s pod health across
|
|
285
|
+
Check k8s pod health across myapp-prod and myapp-staging.
|
|
286
286
|
If all pods are Running, reply with exactly: HEARTBEAT_OK
|
|
287
287
|
Format any issues as: ⚠️ <namespace>/<pod>: <status>
|
|
288
288
|
```
|
|
@@ -360,6 +360,23 @@ The dispatcher picks this up on its next tick (within 15s) and creates and immed
|
|
|
360
360
|
|
|
361
361
|
---
|
|
362
362
|
|
|
363
|
+
### Checking Job Status — Always Poll, Never Infer
|
|
364
|
+
|
|
365
|
+
When reporting on whether a dispatched job is running, done, or stuck, **always call `status` directly** — never infer from check-in messages that appeared in the conversation.
|
|
366
|
+
|
|
367
|
+
Check-in messages (from `agent-checkin.mjs` or similar) are delivered asynchronously via the scheduler inbox. By the time they appear in your conversation, the job may have already finished, failed, or moved on to a later step. Treating them as real-time signals produces stale or incorrect status reports.
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
# Always do this before reporting job status:
|
|
371
|
+
node ~/.openclaw/chilisaus/index.mjs status --label <label>
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The `status` output gives you the authoritative `status` field (`accepted` / `running` / `done` / `error`), the last `updatedAt` timestamp, and the final `summary`. Use that — not the most recent check-in message.
|
|
375
|
+
|
|
376
|
+
**Rule: if you haven't polled `status`, you don't know the status.**
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
363
380
|
### Communicating Between Jobs
|
|
364
381
|
|
|
365
382
|
Jobs can pass data to each other using the inter-agent message queue. Messages injected into a job's context appear in the `--- Pending Messages ---` block at the top of the prompt.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.2] -- 2026-04-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- fix(dispatch): make completion delivery deterministic
|
|
9
|
+
- fix(dispatch): suppress junk completion fallbacks
|
|
10
|
+
- fix(package): include provider registry in npm tarball
|
|
11
|
+
- fix(scheduler): canonicalize isolated session auth overrides
|
|
12
|
+
- fix(dispatch): delete watchdog jobs on disarm instead of disable to prevent accumulation
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- feat(watcher): add stop_reason-based early delivery (Path 2a)
|
|
16
|
+
- feat(dispatch): auto-inject ORIGIN_CHAT_ID from deliverTo into prompt
|
|
17
|
+
- fix(dispatch): prefer group sessions over DM in auto-detected origin
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- ci: upgrade checkout and setup-node actions to v5
|
|
21
|
+
- docs: align packaged runtime path with host layout
|
|
22
|
+
- docs: document local npm pack install and upgrade flow
|
|
23
|
+
- test: remove dead locals in watcher coverage
|
|
24
|
+
|
|
5
25
|
## [0.2.1] -- 2026-04-01
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/INSTALL.md
CHANGED
|
@@ -39,6 +39,21 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- help
|
|
|
39
39
|
|
|
40
40
|
Runtime state for npm installs defaults to `~/.openclaw/scheduler/`, not the package directory under `node_modules/`.
|
|
41
41
|
|
|
42
|
+
To test the published package layout from local source before publishing, build a tarball and install it into a separate runtime prefix:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
|
|
46
|
+
cd /tmp/openclaw-scheduler
|
|
47
|
+
npm ci
|
|
48
|
+
npm run verify:local
|
|
49
|
+
npm pack
|
|
50
|
+
|
|
51
|
+
mkdir -p ~/.openclaw/packages/openclaw-scheduler
|
|
52
|
+
npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
In that setup, run the service from `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js` and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
|
|
56
|
+
|
|
42
57
|
---
|
|
43
58
|
|
|
44
59
|
## Step 2: Install Dependencies
|
|
@@ -223,6 +238,14 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mo
|
|
|
223
238
|
npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mode daemon
|
|
224
239
|
```
|
|
225
240
|
|
|
241
|
+
If you installed from a locally packed tarball:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode agent
|
|
245
|
+
# or:
|
|
246
|
+
npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode daemon
|
|
247
|
+
```
|
|
248
|
+
|
|
226
249
|
What each mode does:
|
|
227
250
|
|
|
228
251
|
- `agent` writes `~/Library/LaunchAgents/ai.openclaw.scheduler.plist` and bootstraps `gui/$UID/ai.openclaw.scheduler`
|
package/README.md
CHANGED
|
@@ -206,6 +206,23 @@ npm run verify:smoke # lightweight smoke gate used by GitHub Act
|
|
|
206
206
|
|
|
207
207
|
GitHub Actions runs the smoke gate plus the in-memory test suite on Linux, macOS, and Windows with Node 20. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
|
|
208
208
|
|
|
209
|
+
### Option C: local npm pack (simulate the published package from source)
|
|
210
|
+
|
|
211
|
+
Use this when you want to test the exact package layout end users get from npm without publishing to npmjs.org yet.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
|
|
215
|
+
cd /tmp/openclaw-scheduler
|
|
216
|
+
npm ci
|
|
217
|
+
npm run verify:local
|
|
218
|
+
npm pack
|
|
219
|
+
|
|
220
|
+
mkdir -p ~/.openclaw/packages/openclaw-scheduler
|
|
221
|
+
npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Point your service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`, and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
|
|
225
|
+
|
|
209
226
|
The package also exports a small safe programmatic API surface for tooling:
|
|
210
227
|
|
|
211
228
|
```js
|
|
@@ -1140,18 +1157,13 @@ Use this when you want scripts to enqueue only actionable signals, then a single
|
|
|
1140
1157
|
# 1) Enqueue a signal
|
|
1141
1158
|
openclaw-scheduler msg send monitor-agent main "Found 3 critical errors in prod logs"
|
|
1142
1159
|
|
|
1143
|
-
# 2)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
"delivery_channel": "telegram",
|
|
1151
|
-
"delivery_to": "YOUR_CHAT_ID",
|
|
1152
|
-
"run_timeout_ms": 60000,
|
|
1153
|
-
"origin": "system"
|
|
1154
|
-
}'
|
|
1160
|
+
# 2) Run the inbox consumer daemon (event-driven, watches SQLite WAL)
|
|
1161
|
+
# Delivers within ~250ms of a message landing in the DB.
|
|
1162
|
+
# No polling cron needed — the daemon watches for WAL changes.
|
|
1163
|
+
node scripts/inbox-consumer.mjs --watch --to YOUR_CHAT_ID --channel telegram
|
|
1164
|
+
|
|
1165
|
+
# Or as a one-shot drain (useful for testing):
|
|
1166
|
+
node scripts/inbox-consumer.mjs --to YOUR_CHAT_ID --channel telegram
|
|
1155
1167
|
```
|
|
1156
1168
|
|
|
1157
1169
|
---
|
package/UPGRADING.md
CHANGED
|
@@ -105,6 +105,24 @@ npm install --prefix ~/.openclaw/scheduler openclaw-scheduler@latest
|
|
|
105
105
|
npm install --prefix $env:USERPROFILE\.openclaw\scheduler openclaw-scheduler@latest
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
### Local source tarball upgrade (`npm pack`)
|
|
109
|
+
|
|
110
|
+
Use this when you want to upgrade a host to a locally built package before publishing to npmjs.org.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
|
|
114
|
+
cd /tmp/openclaw-scheduler
|
|
115
|
+
git pull
|
|
116
|
+
npm ci
|
|
117
|
+
npm run verify:local
|
|
118
|
+
npm pack
|
|
119
|
+
|
|
120
|
+
mkdir -p ~/.openclaw/packages/openclaw-scheduler
|
|
121
|
+
npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Keep `SCHEDULER_HOME=~/.openclaw/scheduler` and `SCHEDULER_DB=~/.openclaw/scheduler/scheduler.db`, and point the service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`.
|
|
125
|
+
|
|
108
126
|
---
|
|
109
127
|
|
|
110
128
|
## Step 2: Install Dependencies
|
|
@@ -277,6 +295,19 @@ sleep 3
|
|
|
277
295
|
ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && cd ~/.openclaw/scheduler && node cli.js status"
|
|
278
296
|
```
|
|
279
297
|
|
|
298
|
+
#### macOS host over SSH using a local tarball
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
HOST=youruser@your-mac-host.lan
|
|
302
|
+
TARBALL=./openclaw-scheduler-*.tgz
|
|
303
|
+
|
|
304
|
+
scp $TARBALL $HOST:~/.openclaw/
|
|
305
|
+
ssh $HOST "mkdir -p ~/.openclaw/packages/openclaw-scheduler && npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ~/.openclaw/$(basename $TARBALL)"
|
|
306
|
+
ssh $HOST "launchctl kickstart -k gui/\$(id -u)/ai.openclaw.scheduler"
|
|
307
|
+
sleep 3
|
|
308
|
+
ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && launchctl print gui/\$(id -u)/ai.openclaw.scheduler | sed -n '1,20p'"
|
|
309
|
+
```
|
|
310
|
+
|
|
280
311
|
#### Linux / Windows WSL2 host over SSH
|
|
281
312
|
|
|
282
313
|
```bash
|
package/dispatch/index.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { randomUUID } from 'crypto';
|
|
|
32
32
|
import { execFileSync } from 'child_process';
|
|
33
33
|
import { homedir } from 'os';
|
|
34
34
|
import Database from 'better-sqlite3';
|
|
35
|
+
import { buildTerminalCompletionPayload } from './completion.mjs';
|
|
35
36
|
import { onStarted, onFinished, onStuck } from './hooks.mjs';
|
|
36
37
|
|
|
37
38
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -334,14 +335,28 @@ function readSessionsStore(agent = 'main') {
|
|
|
334
335
|
*
|
|
335
336
|
* @returns {string|null} - e.g. "telegram:-100200000000", or null if not found
|
|
336
337
|
*/
|
|
338
|
+
/**
|
|
339
|
+
* Infer the chat type ("group" | "direct" | "") from a session object and its key.
|
|
340
|
+
* Checks session.chatType first, then falls back to key pattern matching.
|
|
341
|
+
* Key patterns: agent:main:<channel>:group:<id> → group
|
|
342
|
+
* agent:main:<channel>:direct:<id> → direct
|
|
343
|
+
*/
|
|
344
|
+
function inferChatType(key, session) {
|
|
345
|
+
if (session.chatType) return session.chatType;
|
|
346
|
+
if (key.includes(":group:")) return "group";
|
|
347
|
+
if (key.includes(":direct:")) return "direct";
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
|
|
337
351
|
function getActiveOriginFromSessions() {
|
|
338
352
|
const store = readSessionsStore("main");
|
|
339
353
|
if (!store) return null;
|
|
340
354
|
|
|
341
|
-
let best = null;
|
|
342
|
-
let bestTime = 0;
|
|
343
355
|
const TEN_MIN_MS = 10 * 60 * 1000;
|
|
344
356
|
|
|
357
|
+
/** @type {Array<{origin: string, updatedAt: number, chatType: string}>} */
|
|
358
|
+
const candidates = [];
|
|
359
|
+
|
|
345
360
|
for (const [key, session] of Object.entries(store)) {
|
|
346
361
|
// Only consider main sessions, not subagents
|
|
347
362
|
// Pattern: agent:main:<channel>:<type>:<id> but NOT agent:main:subagent:*
|
|
@@ -357,19 +372,37 @@ function getActiveOriginFromSessions() {
|
|
|
357
372
|
// Must be recently active
|
|
358
373
|
if (Date.now() - updatedAt > TEN_MIN_MS) continue;
|
|
359
374
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
375
|
+
// Prefer deliveryContext.to if available
|
|
376
|
+
const deliveryTo = session.deliveryContext?.to || null;
|
|
377
|
+
if (!deliveryTo) continue;
|
|
378
|
+
|
|
379
|
+
candidates.push({
|
|
380
|
+
origin: deliveryTo,
|
|
381
|
+
updatedAt,
|
|
382
|
+
chatType: inferChatType(key, session),
|
|
383
|
+
});
|
|
370
384
|
}
|
|
371
385
|
|
|
372
|
-
return
|
|
386
|
+
if (candidates.length === 0) return null;
|
|
387
|
+
|
|
388
|
+
// Tiebreaker: prefer group sessions over direct/DM sessions.
|
|
389
|
+
// When both a DM and a group session are recently active, the DM session
|
|
390
|
+
// often has a more recent updatedAt (agent just replied there), but the
|
|
391
|
+
// triggering context was the group chat. Within the same chat type, prefer
|
|
392
|
+
// the most recently updated session.
|
|
393
|
+
const typeScore = (chatType) => {
|
|
394
|
+
if (chatType === "group") return 2;
|
|
395
|
+
if (chatType === "direct") return 0;
|
|
396
|
+
return 1; // unknown / other
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
candidates.sort((a, b) => {
|
|
400
|
+
const scoreDiff = typeScore(b.chatType) - typeScore(a.chatType);
|
|
401
|
+
if (scoreDiff !== 0) return scoreDiff;
|
|
402
|
+
return b.updatedAt - a.updatedAt;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return candidates[0].origin;
|
|
373
406
|
}
|
|
374
407
|
|
|
375
408
|
/**
|
|
@@ -507,12 +540,12 @@ function disarmWatchdog(label) {
|
|
|
507
540
|
if (!entry?.watchdogJobId) return;
|
|
508
541
|
try {
|
|
509
542
|
const schedulerCli = join(__dirname, '..', 'cli.js');
|
|
510
|
-
execFileSync(process.execPath, [schedulerCli, 'jobs', '
|
|
543
|
+
execFileSync(process.execPath, [schedulerCli, 'jobs', 'delete', entry.watchdogJobId], {
|
|
511
544
|
encoding: 'utf-8',
|
|
512
545
|
timeout: 5000,
|
|
513
546
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
514
547
|
});
|
|
515
|
-
process.stderr.write(`[${BRAND}] watchdog
|
|
548
|
+
process.stderr.write(`[${BRAND}] watchdog deleted for ${label}\n`);
|
|
516
549
|
} catch (err) {
|
|
517
550
|
process.stderr.write(`[${BRAND}] watchdog disarm failed for ${label}: ${err.message}\n`);
|
|
518
551
|
}
|
|
@@ -599,6 +632,14 @@ async function cmdEnqueue(flags) {
|
|
|
599
632
|
const deliverMode = flags['delivery-mode'] || 'announce';
|
|
600
633
|
const mode = flags.mode || 'fresh';
|
|
601
634
|
|
|
635
|
+
// -- Auto-inject ORIGIN_CHAT_ID into prompt message ---------
|
|
636
|
+
// Ensures the spawned agent always knows where to send message tool calls,
|
|
637
|
+
// matching the delivery target. Skip if already present (caller is explicit)
|
|
638
|
+
// or if there's no delivery target.
|
|
639
|
+
if (deliverTo && !message.includes('ORIGIN_CHAT_ID:')) {
|
|
640
|
+
message = `ORIGIN_CHAT_ID: ${deliverTo}\n\n${message}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
602
643
|
// -- Verify command flag -----------------------------------
|
|
603
644
|
const verifyCmd = flags['verify-cmd'] || null;
|
|
604
645
|
|
|
@@ -849,7 +890,8 @@ async function cmdEnqueue(flags) {
|
|
|
849
890
|
const watcherPath = join(__dirname, 'watcher.mjs');
|
|
850
891
|
// Watcher timeout = session timeout + 120s buffer for startup/polling
|
|
851
892
|
const watcherTimeoutS = timeoutS + 120;
|
|
852
|
-
const
|
|
893
|
+
const idleThresholdS = flags['idle-threshold'] || '300';
|
|
894
|
+
const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20 --idle-threshold ${idleThresholdS}`;
|
|
853
895
|
|
|
854
896
|
const nowUtc = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
855
897
|
const jobSpec = JSON.stringify({
|
|
@@ -1116,6 +1158,7 @@ function cmdStatus(flags) {
|
|
|
1116
1158
|
spawnedAt: current.spawnedAt,
|
|
1117
1159
|
updatedAt: current.updatedAt,
|
|
1118
1160
|
summary: current.summary || null,
|
|
1161
|
+
completion: current.completion || null,
|
|
1119
1162
|
error: current.error || null,
|
|
1120
1163
|
liveness,
|
|
1121
1164
|
...(syncAction ? { syncAction } : {}),
|
|
@@ -1405,6 +1448,7 @@ function cmdResult(flags) {
|
|
|
1405
1448
|
status: entry.status,
|
|
1406
1449
|
spawnedAt: entry.spawnedAt,
|
|
1407
1450
|
summary: entry.summary || (lastReply ? lastReply.slice(0, 500) : null),
|
|
1451
|
+
completion: entry.completion || null,
|
|
1408
1452
|
lastReply: lastReply || null,
|
|
1409
1453
|
error: entry.error || null,
|
|
1410
1454
|
});
|
|
@@ -1474,15 +1518,15 @@ async function cmdDone(flags) {
|
|
|
1474
1518
|
}
|
|
1475
1519
|
}
|
|
1476
1520
|
|
|
1477
|
-
//
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1521
|
+
// Summary passes through as-is for raw diagnostics, but we also persist a
|
|
1522
|
+
// first-class completion payload with deterministic delivery text so the
|
|
1523
|
+
// watcher/post-office path never depends solely on transcript recovery.
|
|
1524
|
+
const completion = buildTerminalCompletionPayload({
|
|
1525
|
+
summary: rawSummary,
|
|
1526
|
+
checklist,
|
|
1527
|
+
sha,
|
|
1528
|
+
});
|
|
1529
|
+
const summary = completion.summary || rawSummary;
|
|
1486
1530
|
|
|
1487
1531
|
const existing = getLabel(label);
|
|
1488
1532
|
|
|
@@ -1598,7 +1642,7 @@ async function cmdDone(flags) {
|
|
|
1598
1642
|
// Label was never registered (e.g. direct subagent spawn, not via enqueue).
|
|
1599
1643
|
// This is not an error -- the work completed, the label just wasn't tracked.
|
|
1600
1644
|
process.stderr.write(`[${BRAND}] warn: no session found for label "${label}" -- registering as done\n`);
|
|
1601
|
-
setLabel(label, { status: 'done', summary, ...(sha ? { sha } : {}) });
|
|
1645
|
+
setLabel(label, { status: 'done', summary, completion, ...(sha ? { sha } : {}) });
|
|
1602
1646
|
|
|
1603
1647
|
// No watcher is polling for this label, so actively notify via the gateway
|
|
1604
1648
|
// post office using delivery config from config.json as fallback target.
|
|
@@ -1622,13 +1666,14 @@ async function cmdDone(flags) {
|
|
|
1622
1666
|
process.stderr.write(`[${BRAND}] warn: no deliverTo in config -- completion not delivered for "${label}"\n`);
|
|
1623
1667
|
}
|
|
1624
1668
|
|
|
1625
|
-
out({ ok: true, label, status: 'done', summary, message: 'Label not previously registered; marked done.' });
|
|
1669
|
+
out({ ok: true, label, status: 'done', summary, completion, message: 'Label not previously registered; marked done.' });
|
|
1626
1670
|
return;
|
|
1627
1671
|
}
|
|
1628
1672
|
|
|
1629
1673
|
setLabel(label, {
|
|
1630
1674
|
status: 'done',
|
|
1631
1675
|
summary,
|
|
1676
|
+
completion,
|
|
1632
1677
|
...(sha ? { sha } : {}),
|
|
1633
1678
|
});
|
|
1634
1679
|
|
|
@@ -1647,7 +1692,7 @@ async function cmdDone(flags) {
|
|
|
1647
1692
|
session_key: existing.sessionKey || null,
|
|
1648
1693
|
}).catch(() => {});
|
|
1649
1694
|
|
|
1650
|
-
out({ ok: true, label, status: 'done', summary, message: 'Label marked done via agent signal.' });
|
|
1695
|
+
out({ ok: true, label, status: 'done', summary, completion, message: 'Label marked done via agent signal.' });
|
|
1651
1696
|
}
|
|
1652
1697
|
|
|
1653
1698
|
/**
|
package/dispatch/watcher.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { readFileSync, writeFileSync, renameSync, statSync } from 'fs';
|
|
|
31
31
|
import { dirname, join } from 'path';
|
|
32
32
|
import { homedir } from 'os';
|
|
33
33
|
import { fileURLToPath } from 'url';
|
|
34
|
+
import { resolveCompletionDelivery } from './completion.mjs';
|
|
34
35
|
import { sendMessage } from '../messages.js';
|
|
35
36
|
|
|
36
37
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -53,6 +54,7 @@ const ACTIVITY_POLL_MS = 30_000;
|
|
|
53
54
|
* so PING_INTERVAL_MS must stay well below PING_STALE_MS (3 * 60_000). */
|
|
54
55
|
const PING_INTERVAL_MS = 60_000; // 60 seconds
|
|
55
56
|
|
|
57
|
+
|
|
56
58
|
function getGatewayToken() {
|
|
57
59
|
if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
58
60
|
try {
|
|
@@ -656,6 +658,49 @@ function getJsonlMidTurnReason(sessionId, agentDir = 'main') {
|
|
|
656
658
|
return null; // Last assistant entry appears to be a complete text reply -- safe to proceed
|
|
657
659
|
}
|
|
658
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Read the last assistant entry's stop_reason from the session JSONL.
|
|
663
|
+
* Returns the stop_reason string (e.g. 'end_turn', 'tool_use') or null if unavailable.
|
|
664
|
+
*
|
|
665
|
+
* Uses readJsonlLastLines with n=10 to scan enough history to find the last
|
|
666
|
+
* assistant message even if several tool_result entries follow it.
|
|
667
|
+
*
|
|
668
|
+
* @param {string} sessionId - Internal session UUID
|
|
669
|
+
* @param {string} agentDir - Agent directory (default: 'main')
|
|
670
|
+
* @returns {string|null} stop_reason string or null
|
|
671
|
+
*/
|
|
672
|
+
function getSessionStopReason(sessionId, agentDir = 'main') {
|
|
673
|
+
const lastLines = readJsonlLastLines(sessionId, agentDir, 10);
|
|
674
|
+
if (!lastLines) return null;
|
|
675
|
+
// Walk backwards to find last role=assistant entry
|
|
676
|
+
for (let i = lastLines.length - 1; i >= 0; i--) {
|
|
677
|
+
const entry = lastLines[i];
|
|
678
|
+
if (entry?.role === 'assistant') {
|
|
679
|
+
return entry?.stop_reason ?? null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Returns true if the session has cleanly finished with stop_reason=end_turn.
|
|
687
|
+
* Requires:
|
|
688
|
+
* - stop_reason === 'end_turn' on the last assistant entry
|
|
689
|
+
* - getJsonlMidTurnReason() returns null (no in-flight tool calls or pending results)
|
|
690
|
+
*
|
|
691
|
+
* Used for Path 2a early delivery: skip FLAT_WINDOW_MS wait when session is
|
|
692
|
+
* verifiably done via JSONL stop_reason signal.
|
|
693
|
+
*
|
|
694
|
+
* @param {string} sessionId - Internal session UUID
|
|
695
|
+
* @param {string} agentDir - Agent directory (default: 'main')
|
|
696
|
+
* @returns {boolean}
|
|
697
|
+
*/
|
|
698
|
+
function isSessionCleanlyFinished(sessionId, agentDir = 'main') {
|
|
699
|
+
if (getJsonlMidTurnReason(sessionId, agentDir) !== null) return false;
|
|
700
|
+
const stopReason = getSessionStopReason(sessionId, agentDir);
|
|
701
|
+
return stopReason === 'end_turn';
|
|
702
|
+
}
|
|
703
|
+
|
|
659
704
|
/**
|
|
660
705
|
* Update labels.json to mark the watched label as done (best-effort, atomic write).
|
|
661
706
|
* Called before exit to ensure labels.json is reconciled even if sync fails.
|
|
@@ -698,7 +743,7 @@ function markLabelError(label, errorSummary) {
|
|
|
698
743
|
* If the verify command exits non-zero, the job is marked as error and
|
|
699
744
|
* an alert is written to stdout (delivery target receives the failure notice).
|
|
700
745
|
*/
|
|
701
|
-
function deliverResult(label, lastReply, fallbackSummary) {
|
|
746
|
+
function deliverResult(label, lastReply, fallbackSummary, completionPayload = null) {
|
|
702
747
|
// -- verify-cmd check -----------------------------------------------------
|
|
703
748
|
// Run the stored verify-cmd (if any) before declaring the job done.
|
|
704
749
|
// A non-zero exit flips the job to error state and sends an alert instead.
|
|
@@ -732,20 +777,21 @@ function deliverResult(label, lastReply, fallbackSummary) {
|
|
|
732
777
|
}
|
|
733
778
|
|
|
734
779
|
// Update labels.json before exiting -- prevents stuck detector false positives
|
|
735
|
-
const
|
|
736
|
-
|
|
780
|
+
const completion = resolveCompletionDelivery({
|
|
781
|
+
lastReply,
|
|
782
|
+
completion: completionPayload,
|
|
783
|
+
fallbackSummary,
|
|
784
|
+
});
|
|
785
|
+
markLabelDone(label, completion.summary);
|
|
737
786
|
|
|
738
|
-
if (
|
|
787
|
+
if (completion.deliveryText) {
|
|
739
788
|
const maxLen = 3500;
|
|
740
|
-
const reply =
|
|
741
|
-
?
|
|
742
|
-
:
|
|
789
|
+
const reply = completion.deliveryText.length > maxLen
|
|
790
|
+
? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
|
|
791
|
+
: completion.deliveryText;
|
|
743
792
|
process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
|
|
744
793
|
} else {
|
|
745
|
-
process.
|
|
746
|
-
`🌶️ *dispatch* [${label}] completed (no reply captured)\n` +
|
|
747
|
-
`Summary: ${fallbackSummary || 'none'}\n`
|
|
748
|
-
);
|
|
794
|
+
process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
|
|
749
795
|
}
|
|
750
796
|
process.exit(0);
|
|
751
797
|
}
|
|
@@ -821,6 +867,7 @@ let recoverySessionKey = null; // captured during polling for steer/kill
|
|
|
821
867
|
|
|
822
868
|
// Module-level state accessible by SIGTERM handler
|
|
823
869
|
let lastKnownReply = null;
|
|
870
|
+
let lastKnownCompletion = null;
|
|
824
871
|
|
|
825
872
|
// -- SIGTERM handler (scheduler kills watcher with SIGTERM before SIGKILL) --
|
|
826
873
|
// Ensures labels.json is updated and a delivery attempt is made even when killed.
|
|
@@ -830,9 +877,10 @@ process.on('SIGTERM', () => {
|
|
|
830
877
|
try {
|
|
831
878
|
const result = dispatch('result', ['--label', label]);
|
|
832
879
|
if (result?.lastReply) lastKnownReply = result.lastReply;
|
|
880
|
+
if (result?.completion) lastKnownCompletion = result.completion;
|
|
833
881
|
} catch {}
|
|
834
882
|
// deliverResult calls process.exit(0) internally
|
|
835
|
-
deliverResult(label, lastKnownReply, 'interrupted by watcher timeout');
|
|
883
|
+
deliverResult(label, lastKnownReply, 'interrupted by watcher timeout', lastKnownCompletion);
|
|
836
884
|
});
|
|
837
885
|
|
|
838
886
|
// -- Rolling deadline vars ------------------------------------
|
|
@@ -1007,7 +1055,7 @@ while (Date.now() < deadline) {
|
|
|
1007
1055
|
// If the session DID produce a lastReply before being killed, deliver it normally.
|
|
1008
1056
|
if (sessionEverFound && isGatewayRestartKill(status.summary)) {
|
|
1009
1057
|
const gwCheckResult = dispatch('result', ['--label', label]);
|
|
1010
|
-
if (!gwCheckResult?.lastReply) {
|
|
1058
|
+
if (!gwCheckResult?.lastReply && !gwCheckResult?.completion?.deliveryText) {
|
|
1011
1059
|
// No result captured -- session was killed before completing
|
|
1012
1060
|
const retryCount = getGwRestartRetryCount(label);
|
|
1013
1061
|
if (retryCount >= MAX_GW_RESTART_RETRIES) {
|
|
@@ -1046,7 +1094,7 @@ while (Date.now() < deadline) {
|
|
|
1046
1094
|
process.exit(1);
|
|
1047
1095
|
}
|
|
1048
1096
|
}
|
|
1049
|
-
// lastReply present -- session completed before/during kill; fall through to normal delivery
|
|
1097
|
+
// lastReply or completion payload present -- session completed before/during kill; fall through to normal delivery
|
|
1050
1098
|
}
|
|
1051
1099
|
|
|
1052
1100
|
// Reset gw-restart retry count on successful completion
|
|
@@ -1082,7 +1130,23 @@ while (Date.now() < deadline) {
|
|
|
1082
1130
|
}
|
|
1083
1131
|
}
|
|
1084
1132
|
const result = dispatch('result', ['--label', label]);
|
|
1085
|
-
deliverResult(label, result?.lastReply, status.summary);
|
|
1133
|
+
deliverResult(label, result?.lastReply, status.summary, result?.completion || status?.completion || null);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// -- Path 2a: stop_reason early delivery (clean end_turn) --
|
|
1137
|
+
// If the last assistant message has stop_reason=end_turn and no tool calls
|
|
1138
|
+
// are in flight, deliver immediately without waiting for FLAT_WINDOW_MS.
|
|
1139
|
+
// This is the fast path for sessions that write stop_reason to JSONL.
|
|
1140
|
+
if (status.sessionKey) {
|
|
1141
|
+
const _e2a = getSessionStoreEntry(status.sessionKey);
|
|
1142
|
+
const _sid2a = _e2a?.sessionId || null;
|
|
1143
|
+
const _adir2a = (status.sessionKey.split(':')[1]) || 'main';
|
|
1144
|
+
if (_sid2a && isSessionCleanlyFinished(_sid2a, _adir2a)) {
|
|
1145
|
+
process.stderr.write(`[watcher] stop_reason=end_turn detected -- delivering early\n`);
|
|
1146
|
+
const result = dispatch('result', ['--label', label]);
|
|
1147
|
+
deliverResult(label, result?.lastReply, 'completed (stop_reason=end_turn)', result?.completion || null);
|
|
1148
|
+
// deliverResult exits
|
|
1149
|
+
}
|
|
1086
1150
|
}
|
|
1087
1151
|
|
|
1088
1152
|
// -- Path 2: status says 'running' but session may be idle -
|
|
@@ -1094,8 +1158,8 @@ while (Date.now() < deadline) {
|
|
|
1094
1158
|
const ageMs = status.liveness?.ageMs;
|
|
1095
1159
|
if (ageMs != null && ageMs >= IDLE_RESULT_CHECK_MS) {
|
|
1096
1160
|
const result = dispatch('result', ['--label', label]);
|
|
1097
|
-
if (result?.lastReply) {
|
|
1098
|
-
deliverResult(label, result
|
|
1161
|
+
if (result?.lastReply || result?.completion?.deliveryText) {
|
|
1162
|
+
deliverResult(label, result?.lastReply || null, null, result?.completion || null);
|
|
1099
1163
|
}
|
|
1100
1164
|
}
|
|
1101
1165
|
|
|
@@ -1106,16 +1170,15 @@ while (Date.now() < deadline) {
|
|
|
1106
1170
|
// Timed out -- try one last result check
|
|
1107
1171
|
const finalResult = dispatch('result', ['--label', label]);
|
|
1108
1172
|
const finalStatus = dispatch('status', ['--label', label]);
|
|
1109
|
-
if (
|
|
1173
|
+
if (finalStatus?.status === 'done') {
|
|
1110
1174
|
const rc = getRetryCount(label);
|
|
1111
1175
|
if (rc > 0) setRetryCount(label, 0);
|
|
1112
|
-
deliverResult(
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
process.exit(0);
|
|
1176
|
+
deliverResult(
|
|
1177
|
+
label,
|
|
1178
|
+
finalResult?.lastReply || null,
|
|
1179
|
+
finalStatus?.summary || null,
|
|
1180
|
+
finalResult?.completion || finalStatus?.completion || null,
|
|
1181
|
+
);
|
|
1119
1182
|
}
|
|
1120
1183
|
// If status is interrupted (auto-resolved as incomplete), exit non-zero
|
|
1121
1184
|
if (finalStatus?.status === 'interrupted') {
|
|
@@ -1174,9 +1237,9 @@ if (sessionInternalId) {
|
|
|
1174
1237
|
// If the session already completed (gateway pruned it -> null tokens), exit cleanly.
|
|
1175
1238
|
if (statusAtDeadline?.status === 'done' || baselineTokens === null) {
|
|
1176
1239
|
const r = dispatch('result', ['--label', label]);
|
|
1177
|
-
if (r?.lastReply) {
|
|
1240
|
+
if (r?.lastReply || r?.completion?.deliveryText) {
|
|
1178
1241
|
// deliverResult calls process.exit(0) internally
|
|
1179
|
-
deliverResult(label, r
|
|
1242
|
+
deliverResult(label, r?.lastReply || null, statusAtDeadline?.summary || null, r?.completion || null);
|
|
1180
1243
|
}
|
|
1181
1244
|
// Status is explicitly done -- exit cleanly, no timeout noise
|
|
1182
1245
|
if (statusAtDeadline?.status === 'done') {
|
|
@@ -1211,12 +1274,12 @@ while (Date.now() - flatSince < FLAT_WINDOW_MS) {
|
|
|
1211
1274
|
if (st?.status === 'done') {
|
|
1212
1275
|
const r = dispatch('result', ['--label', label]);
|
|
1213
1276
|
// deliverResult calls process.exit(0) internally
|
|
1214
|
-
deliverResult(label, r?.lastReply, st.summary);
|
|
1277
|
+
deliverResult(label, r?.lastReply || null, st.summary, r?.completion || st?.completion || null);
|
|
1215
1278
|
}
|
|
1216
1279
|
const r2 = dispatch('result', ['--label', label]);
|
|
1217
|
-
if (r2?.lastReply) {
|
|
1280
|
+
if (r2?.lastReply || r2?.completion?.deliveryText) {
|
|
1218
1281
|
// deliverResult calls process.exit(0) internally
|
|
1219
|
-
deliverResult(label, r2
|
|
1282
|
+
deliverResult(label, r2?.lastReply || null, null, r2?.completion || null);
|
|
1220
1283
|
}
|
|
1221
1284
|
|
|
1222
1285
|
// Token growth?
|
|
@@ -1305,12 +1368,12 @@ if (sessionInternalId) {
|
|
|
1305
1368
|
if (stExt?.status === 'done') {
|
|
1306
1369
|
const rExt = dispatch('result', ['--label', label]);
|
|
1307
1370
|
// deliverResult calls process.exit(0) internally
|
|
1308
|
-
deliverResult(label, rExt?.lastReply, stExt.summary);
|
|
1371
|
+
deliverResult(label, rExt?.lastReply || null, stExt.summary, rExt?.completion || stExt?.completion || null);
|
|
1309
1372
|
}
|
|
1310
1373
|
const rExt2 = dispatch('result', ['--label', label]);
|
|
1311
|
-
if (rExt2?.lastReply) {
|
|
1374
|
+
if (rExt2?.lastReply || rExt2?.completion?.deliveryText) {
|
|
1312
1375
|
// deliverResult calls process.exit(0) internally
|
|
1313
|
-
deliverResult(label, rExt2
|
|
1376
|
+
deliverResult(label, rExt2?.lastReply || null, null, rExt2?.completion || null);
|
|
1314
1377
|
}
|
|
1315
1378
|
|
|
1316
1379
|
// JSONL mtime check during extended wait
|
|
@@ -1362,12 +1425,12 @@ for (const round of steerRounds) {
|
|
|
1362
1425
|
if (st2?.status === 'done') {
|
|
1363
1426
|
const r3 = dispatch('result', ['--label', label]);
|
|
1364
1427
|
// deliverResult calls process.exit(0) internally
|
|
1365
|
-
deliverResult(label, r3?.lastReply, st2.summary);
|
|
1428
|
+
deliverResult(label, r3?.lastReply || null, st2.summary, r3?.completion || st2?.completion || null);
|
|
1366
1429
|
}
|
|
1367
1430
|
const r3 = dispatch('result', ['--label', label]);
|
|
1368
|
-
if (r3?.lastReply) {
|
|
1431
|
+
if (r3?.lastReply || r3?.completion?.deliveryText) {
|
|
1369
1432
|
// deliverResult calls process.exit(0) internally
|
|
1370
|
-
deliverResult(label, r3
|
|
1433
|
+
deliverResult(label, r3?.lastReply || null, null, r3?.completion || null);
|
|
1371
1434
|
}
|
|
1372
1435
|
|
|
1373
1436
|
if (!round.msg && steerSessionKey) {
|
|
@@ -1380,8 +1443,8 @@ for (const round of steerRounds) {
|
|
|
1380
1443
|
if (st3?.status === 'done') {
|
|
1381
1444
|
// Check if a result was captured before marking as error
|
|
1382
1445
|
const r4 = dispatch('result', ['--label', label]);
|
|
1383
|
-
if (r4?.lastReply) {
|
|
1384
|
-
deliverResult(label, r4
|
|
1446
|
+
if (r4?.lastReply || r4?.completion?.deliveryText) {
|
|
1447
|
+
deliverResult(label, r4?.lastReply || null, st3.summary, r4?.completion || st3?.completion || null); // deliverResult calls process.exit(0)
|
|
1385
1448
|
}
|
|
1386
1449
|
markLabelError(label, 'timed out -- killed after steer attempts (no result captured)');
|
|
1387
1450
|
process.stdout.write(`⏱ dispatch [${label}] killed after steer attempts -- no result captured\n`);
|
package/dispatcher-strategies.js
CHANGED
|
@@ -1083,7 +1083,10 @@ export async function executeAgent(job, ctx, deps) {
|
|
|
1083
1083
|
// the warm session. This avoids full agent bootstrap on every dispatch --
|
|
1084
1084
|
// memory search, plugin init, and context loading only happen on the first
|
|
1085
1085
|
// run. Later runs get a pre-warmed session with context already loaded.
|
|
1086
|
-
const
|
|
1086
|
+
const requestedSessionKey = job.preferred_session_key || `scheduler:${job.id}`;
|
|
1087
|
+
const sessionKey = requestedSessionKey.startsWith('agent:')
|
|
1088
|
+
? requestedSessionKey
|
|
1089
|
+
: `agent:${job.agent_id || 'main'}:${requestedSessionKey}`;
|
|
1087
1090
|
updateRunSession(ctx.run.id, sessionKey, null);
|
|
1088
1091
|
|
|
1089
1092
|
// Mark agent as busy
|
|
@@ -1119,6 +1122,37 @@ export async function executeAgent(job, ctx, deps) {
|
|
|
1119
1122
|
}
|
|
1120
1123
|
}
|
|
1121
1124
|
|
|
1125
|
+
// Always sync the live auth store to the agent's auth-profiles.json BEFORE
|
|
1126
|
+
// every agent turn. This ensures sessions that reuse a stable key (scheduler:<jobId>)
|
|
1127
|
+
// always have fresh credentials -- token refreshes, order changes, and new
|
|
1128
|
+
// profiles are picked up automatically without requiring an explicit auth_profile
|
|
1129
|
+
// on every job.
|
|
1130
|
+
const { syncAuthStoreToSession: syncAuth } = deps;
|
|
1131
|
+
if (typeof syncAuth === 'function') {
|
|
1132
|
+
const syncResult = syncAuth(job.agent_id || 'main');
|
|
1133
|
+
if (syncResult.ok) {
|
|
1134
|
+
log('debug', `Synced live auth store to agent '${job.agent_id || 'main'}'`, { jobId: job.id });
|
|
1135
|
+
} else {
|
|
1136
|
+
log('warn', `Failed to sync auth store: ${syncResult.error}`, { jobId: job.id });
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Apply auth profile to session store BEFORE the agent turn.
|
|
1141
|
+
// The x-openclaw-auth-profile HTTP header is not read by the gateway (dead header).
|
|
1142
|
+
// Writing authProfileOverride directly to sessions.json is the effective mechanism
|
|
1143
|
+
// for auth profile propagation to isolated/embedded sessions.
|
|
1144
|
+
if (resolvedAuthProfile && resolvedAuthProfile !== 'inherit') {
|
|
1145
|
+
const { applyAuthProfileToSessionStore: applyAuthProfile } = deps;
|
|
1146
|
+
if (typeof applyAuthProfile === 'function') {
|
|
1147
|
+
const applyResult = applyAuthProfile(sessionKey, resolvedAuthProfile, job.agent_id || 'main');
|
|
1148
|
+
if (applyResult.ok) {
|
|
1149
|
+
log('debug', `Applied auth profile '${resolvedAuthProfile}' to session store for ${sessionKey}`, { jobId: job.id });
|
|
1150
|
+
} else {
|
|
1151
|
+
log('warn', `Failed to apply auth profile to session store: ${applyResult.error}`, { jobId: job.id, sessionKey });
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1122
1156
|
const turnResult = await runAgentTurnWithActivityTimeout({
|
|
1123
1157
|
message: prompt,
|
|
1124
1158
|
agentId: job.agent_id || 'main',
|
package/dispatcher.js
CHANGED
|
@@ -53,6 +53,8 @@ import { upsertAgent, setAgentStatus } from './agents.js';
|
|
|
53
53
|
import {
|
|
54
54
|
runAgentTurnWithActivityTimeout, sendSystemEvent, getAllSubAgentSessions, listSessions,
|
|
55
55
|
deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
|
|
56
|
+
applyAuthProfileToSessionStore,
|
|
57
|
+
syncAuthStoreToSession,
|
|
56
58
|
} from './gateway.js';
|
|
57
59
|
import { normalizeShellResult } from './shell-result.js';
|
|
58
60
|
import {
|
|
@@ -307,6 +309,8 @@ function buildDispatchDeps() {
|
|
|
307
309
|
updateContextSummary, releaseIdempotencyKey,
|
|
308
310
|
matchesSentinel, detectTransientError,
|
|
309
311
|
listSessions,
|
|
312
|
+
applyAuthProfileToSessionStore,
|
|
313
|
+
syncAuthStoreToSession,
|
|
310
314
|
// Finalize
|
|
311
315
|
updateIdempotencyResultHash,
|
|
312
316
|
shouldRetry, scheduleRetry,
|
|
@@ -378,11 +382,22 @@ function buildJobPrompt(job, run) {
|
|
|
378
382
|
);
|
|
379
383
|
}
|
|
380
384
|
|
|
381
|
-
// Include any pending messages for this agent
|
|
385
|
+
// Include any pending messages for this agent.
|
|
386
|
+
// getInbox() without includeDelivered already filters to status='pending' only,
|
|
387
|
+
// but we add an explicit guard here to log and skip any message that slipped
|
|
388
|
+
// through with status='delivered' or 'read' -- re-displaying such messages
|
|
389
|
+
// would cause duplicate notifications when the inbox-consumer later picks them up.
|
|
382
390
|
const inbox = getInbox(job.agent_id || 'main', { limit: 5 });
|
|
383
|
-
|
|
391
|
+
const injectableMessages = inbox.filter(msg => {
|
|
392
|
+
if (msg.status && msg.status !== 'pending') {
|
|
393
|
+
log('warn', `buildJobPrompt: skipping non-pending message ${msg.id} (status=${msg.status}) for agent ${job.agent_id || 'main'}`);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
if (injectableMessages.length > 0) {
|
|
384
399
|
parts.push('\n--- Pending Messages ---');
|
|
385
|
-
for (const msg of
|
|
400
|
+
for (const msg of injectableMessages) {
|
|
386
401
|
const kindLabel = msg.kind && !['text', 'result', 'status', 'system', 'spawn'].includes(msg.kind)
|
|
387
402
|
? `[${msg.kind}]${msg.owner ? ` (owner: ${msg.owner})` : ''} `
|
|
388
403
|
: '';
|
|
@@ -402,7 +417,7 @@ function buildJobPrompt(job, run) {
|
|
|
402
417
|
|
|
403
418
|
// Collect context metadata
|
|
404
419
|
const contextMeta = {
|
|
405
|
-
messages_injected:
|
|
420
|
+
messages_injected: injectableMessages.length,
|
|
406
421
|
scope: job.payload_scope || 'own',
|
|
407
422
|
job_class: job.job_class || 'standard',
|
|
408
423
|
delivery_guarantee: job.delivery_guarantee || 'at-most-once',
|
package/gateway.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Gateway API client -- independent dispatch via chat completions + system events
|
|
2
2
|
import { execFileSync } from 'child_process';
|
|
3
|
-
import { readFileSync } from 'fs';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { getDb } from './db.js';
|
|
@@ -471,3 +471,119 @@ export async function waitForGateway(timeoutMs = 30000, intervalMs = 2000) {
|
|
|
471
471
|
}
|
|
472
472
|
return false;
|
|
473
473
|
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Write authProfileOverride directly to the gateway's sessions.json store.
|
|
477
|
+
*
|
|
478
|
+
* The gateway reads sessions.json on each agent turn (with mtime-based cache
|
|
479
|
+
* invalidation), so writing here before dispatch ensures the embedded runner
|
|
480
|
+
* picks up the correct auth profile.
|
|
481
|
+
*
|
|
482
|
+
* The x-openclaw-auth-profile HTTP header sent by runAgentTurnWithActivityTimeout
|
|
483
|
+
* is NOT read by the gateway (dead header). This direct store write is the
|
|
484
|
+
* effective mechanism for auth profile propagation to isolated sessions.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} sessionKey - Session key as used in the HTTP request (e.g. 'scheduler:<jobId>')
|
|
487
|
+
* @param {string} authProfile - Auth profile ID (e.g. 'anthropic:gmail')
|
|
488
|
+
* @param {string} [agentId='main'] - Agent ID for store path resolution
|
|
489
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
490
|
+
*/
|
|
491
|
+
export function applyAuthProfileToSessionStore(sessionKey, authProfile, agentId = 'main') {
|
|
492
|
+
if (!sessionKey || !authProfile) {
|
|
493
|
+
return { ok: false, error: 'sessionKey and authProfile are required' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// The gateway may persist session state under either the canonical agent-scoped
|
|
497
|
+
// key or the flat transport key, depending on which path created the session.
|
|
498
|
+
// Keep both aliases in sync so isolated scheduler jobs cannot miss the override.
|
|
499
|
+
const canonicalMatch = sessionKey.match(/^agent:[^:]+:(.+)$/);
|
|
500
|
+
const canonicalKey = sessionKey.startsWith('agent:')
|
|
501
|
+
? sessionKey
|
|
502
|
+
: `agent:${agentId}:${sessionKey}`;
|
|
503
|
+
const flatSessionKey = canonicalMatch?.[1] || sessionKey;
|
|
504
|
+
const keyAliases = Array.from(new Set([canonicalKey, flatSessionKey]));
|
|
505
|
+
const sessionsPath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'sessions', 'sessions.json');
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
if (!existsSync(sessionsPath)) {
|
|
509
|
+
return { ok: false, error: `sessions.json not found at ${sessionsPath}` };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const raw = readFileSync(sessionsPath, 'utf-8');
|
|
513
|
+
const store = JSON.parse(raw);
|
|
514
|
+
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
let changed = false;
|
|
517
|
+
|
|
518
|
+
for (const key of keyAliases) {
|
|
519
|
+
const entry = store[key];
|
|
520
|
+
if (!entry) {
|
|
521
|
+
// Session doesn't exist yet -- create a minimal entry.
|
|
522
|
+
// The gateway will populate the rest on the first agent turn.
|
|
523
|
+
store[key] = {
|
|
524
|
+
updatedAt: now,
|
|
525
|
+
authProfileOverride: authProfile,
|
|
526
|
+
authProfileOverrideSource: 'user',
|
|
527
|
+
};
|
|
528
|
+
changed = true;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (entry.authProfileOverride !== authProfile || entry.authProfileOverrideSource !== 'user') {
|
|
533
|
+
// Update existing entry
|
|
534
|
+
entry.authProfileOverride = authProfile;
|
|
535
|
+
entry.authProfileOverrideSource = 'user';
|
|
536
|
+
entry.updatedAt = now;
|
|
537
|
+
// Clear compaction count so the override sticks across compactions
|
|
538
|
+
delete entry.authProfileOverrideCompactionCount;
|
|
539
|
+
changed = true;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!changed) {
|
|
544
|
+
return { ok: true };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
writeFileSync(sessionsPath, JSON.stringify(store), 'utf-8');
|
|
548
|
+
return { ok: true };
|
|
549
|
+
} catch (err) {
|
|
550
|
+
return { ok: false, error: `Failed to update sessions.json: ${err.message}` };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Sync the live auth-profiles.json from ~/.openclaw/credentials/ to the agent's
|
|
556
|
+
* auth store at ~/.openclaw/agents/<agentId>/agent/auth-profiles.json.
|
|
557
|
+
*
|
|
558
|
+
* This ensures scheduler sessions always use fresh credentials (tokens, order,
|
|
559
|
+
* default profile) even when no explicit auth_profile is set on the job.
|
|
560
|
+
* Without this, sessions created from a stable session key inherit a stale
|
|
561
|
+
* copy of the auth store that was snapshotted when the session was first created.
|
|
562
|
+
*
|
|
563
|
+
* This is a fast file-copy operation (~1ms) and is safe to call before every
|
|
564
|
+
* agent turn.
|
|
565
|
+
*
|
|
566
|
+
* @param {string} [agentId='main'] - Agent ID for store path resolution
|
|
567
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
568
|
+
*/
|
|
569
|
+
export function syncAuthStoreToSession(agentId = 'main') {
|
|
570
|
+
const livePath = join(HOME_DIR, '.openclaw', 'credentials', 'auth-profiles.json');
|
|
571
|
+
const agentStorePath = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json');
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
if (!existsSync(livePath)) {
|
|
575
|
+
return { ok: false, error: `Live auth store not found at ${livePath}` };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Ensure the agent directory exists
|
|
579
|
+
const agentDir = join(HOME_DIR, '.openclaw', 'agents', agentId, 'agent');
|
|
580
|
+
if (!existsSync(agentDir)) {
|
|
581
|
+
mkdirSync(agentDir, { recursive: true });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
copyFileSync(livePath, agentStorePath);
|
|
585
|
+
return { ok: true };
|
|
586
|
+
} catch (err) {
|
|
587
|
+
return { ok: false, error: `Failed to sync auth store: ${err.message}` };
|
|
588
|
+
}
|
|
589
|
+
}
|
package/jobs.js
CHANGED
|
@@ -324,7 +324,7 @@ export function validateJobSpec(opts, currentJob = null, mode = 'create') {
|
|
|
324
324
|
if (!_isExempt && (!merged.delivery_to || String(merged.delivery_to).trim() === '')) {
|
|
325
325
|
throw new Error(
|
|
326
326
|
'delivery_to is required on job insert. Set it to the origin chat_id ' +
|
|
327
|
-
'(e.g. -
|
|
327
|
+
'(e.g. -1001234567890 for a group chat, or 987654321 for a personal DM).'
|
|
328
328
|
);
|
|
329
329
|
}
|
|
330
330
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-scheduler",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"migrate.js",
|
|
71
71
|
"migrate-consolidate.js",
|
|
72
72
|
"paths.js",
|
|
73
|
+
"provider-registry.js",
|
|
73
74
|
"prompt-context.js",
|
|
74
75
|
"retrieval.js",
|
|
75
76
|
"runs.js",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readdir, stat as fsStat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const identityProviders = new Map();
|
|
6
|
+
const authorizationProviders = new Map();
|
|
7
|
+
const proofVerifiers = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load provider plugins from a directory. Every *.js file is imported and
|
|
11
|
+
* its default export registered by type (identity / authorization / proof-verifier).
|
|
12
|
+
*
|
|
13
|
+
* TRUST BOUNDARY: This directory is dynamically imported at startup. Only
|
|
14
|
+
* point SCHEDULER_PROVIDER_PATH at operator-controlled directories. The loader
|
|
15
|
+
* refuses world-writable directories as a minimal safety net, but the
|
|
16
|
+
* primary defense is correct deployment configuration.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadProviders(dirPath) {
|
|
19
|
+
if (!dirPath) return;
|
|
20
|
+
const absPath = resolve(dirPath);
|
|
21
|
+
|
|
22
|
+
// Trust boundary: provider plugins run arbitrary code in the scheduler process.
|
|
23
|
+
// Refuse to load from world-writable directories to prevent code injection.
|
|
24
|
+
try {
|
|
25
|
+
const dirStat = await fsStat(absPath);
|
|
26
|
+
if ((dirStat.mode & 0o002) !== 0) {
|
|
27
|
+
console.error(`[provider-registry] REFUSING to load providers: ${absPath} is world-writable (mode 0${(dirStat.mode & 0o777).toString(8)}). Fix permissions or use a trusted directory.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`[provider-registry] Cannot stat provider directory ${absPath}: ${err.message}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const files = await readdir(absPath);
|
|
36
|
+
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
37
|
+
|
|
38
|
+
for (const file of jsFiles) {
|
|
39
|
+
const filePath = join(absPath, file);
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
42
|
+
const provider = mod.default;
|
|
43
|
+
if (!provider || !provider.name || !provider.type) {
|
|
44
|
+
console.warn(`[provider-registry] Skipping ${file}: missing name or type`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (provider.type === 'identity') {
|
|
48
|
+
identityProviders.set(provider.name, provider);
|
|
49
|
+
} else if (provider.type === 'authorization') {
|
|
50
|
+
authorizationProviders.set(provider.name, provider);
|
|
51
|
+
} else if (provider.type === 'proof-verifier') {
|
|
52
|
+
proofVerifiers.set(provider.name, provider);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(`[provider-registry] Skipping ${file}: unknown type "${provider.type}"`);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[provider-registry] Failed to load ${file}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const total = identityProviders.size + authorizationProviders.size + proofVerifiers.size;
|
|
62
|
+
console.log(`[provider-registry] Loaded ${total} provider(s) from ${absPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getIdentityProvider(name) {
|
|
66
|
+
return identityProviders.get(name) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getAuthorizationProvider(name) {
|
|
70
|
+
return authorizationProviders.get(name) || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getProofVerifier(name) {
|
|
74
|
+
return proofVerifiers.get(name) || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function hasProvider(name) {
|
|
78
|
+
return identityProviders.has(name) || authorizationProviders.has(name) || proofVerifiers.has(name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listProviders() {
|
|
82
|
+
const result = [];
|
|
83
|
+
for (const [name, p] of identityProviders) result.push({ name, type: p.type });
|
|
84
|
+
for (const [name, p] of authorizationProviders) result.push({ name, type: p.type });
|
|
85
|
+
for (const [name, p] of proofVerifiers) result.push({ name, type: p.type });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// For testing: reset all registries
|
|
90
|
+
export function _resetForTesting() {
|
|
91
|
+
identityProviders.clear();
|
|
92
|
+
authorizationProviders.clear();
|
|
93
|
+
proofVerifiers.clear();
|
|
94
|
+
}
|
|
@@ -137,12 +137,17 @@ function _formatMessagesDebug(msgs, agentId) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
function selectPendingMessages(db, agentId, limit) {
|
|
140
|
+
// Only fetch 'pending' messages for user-facing delivery.
|
|
141
|
+
// Messages with status='delivered' have already been injected into an AI
|
|
142
|
+
// agent's context prompt (by buildJobPrompt/markDelivered in dispatcher.js)
|
|
143
|
+
// and must NOT be re-delivered to the user via Telegram — doing so causes
|
|
144
|
+
// duplicate notifications on every inbox-consumer run.
|
|
140
145
|
return db.prepare(`
|
|
141
146
|
SELECT id, from_agent, to_agent, subject, body, kind, created_at, priority,
|
|
142
147
|
delivery_to, channel
|
|
143
148
|
FROM messages
|
|
144
149
|
WHERE (to_agent = ? OR to_agent = 'broadcast')
|
|
145
|
-
AND status
|
|
150
|
+
AND status = 'pending'
|
|
146
151
|
ORDER BY
|
|
147
152
|
CASE kind
|
|
148
153
|
WHEN 'constraint' THEN 0
|
|
@@ -273,8 +278,30 @@ try {
|
|
|
273
278
|
}, 250);
|
|
274
279
|
});
|
|
275
280
|
|
|
281
|
+
// Periodic poll fallback — catches messages that slip through WAL checkpoints.
|
|
282
|
+
// When SQLite checkpoints the WAL (merges it back into the main DB), the WAL
|
|
283
|
+
// file is reset and the watcher may miss a subsequent write. This belt-and-
|
|
284
|
+
// suspenders poll ensures delivery within at most INBOX_POLL_INTERVAL_MS.
|
|
285
|
+
const pollIntervalMs = parsePositiveInt(process.env.INBOX_POLL_INTERVAL_MS, 60000);
|
|
286
|
+
const pollInterval = setInterval(async () => {
|
|
287
|
+
if (draining) return;
|
|
288
|
+
draining = true;
|
|
289
|
+
try {
|
|
290
|
+
const n = await drainOnce(db, { to: deliveryTo, channel, agentId, limit, brand });
|
|
291
|
+
if (n > 0) {
|
|
292
|
+
process.stdout.write(`[inbox-consumer] poll fallback delivered ${n} pending message(s)\n`);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
process.stderr.write(`[inbox-consumer] poll fallback error: ${err.message}\n`);
|
|
296
|
+
} finally {
|
|
297
|
+
draining = false;
|
|
298
|
+
}
|
|
299
|
+
}, pollIntervalMs);
|
|
300
|
+
process.stdout.write(`[inbox-consumer] poll fallback enabled (interval=${pollIntervalMs}ms)\n`);
|
|
301
|
+
|
|
276
302
|
const shutdown = (signal) => {
|
|
277
303
|
if (timer) clearTimeout(timer);
|
|
304
|
+
clearInterval(pollInterval);
|
|
278
305
|
watcher.close();
|
|
279
306
|
process.stdout.write(`[inbox-consumer] ${signal}; exiting\n`);
|
|
280
307
|
process.exit(0);
|