@inetafrica/open-claudia 3.4.23 → 3.4.24
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 +10 -0
- package/bin/schedule.js +45 -11
- package/core/loopback.js +13 -2
- package/core/runner.js +2 -2
- package/core/scheduler.js +197 -35
- package/package.json +1 -1
- package/test-provider-compaction.js +93 -0
- package/test-provider-language.js +7 -1
- package/test-provider-pack-review.js +25 -2
- package/test-provider-parity-e2e.js +5 -3
- package/test-provider-scheduler.js +490 -3
- package/test-run-lock.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.24
|
|
4
|
+
|
|
5
|
+
- A repeating watch keeps polling while its triggered delivery waits for a busy room, waits to retry after a failed or `/stop`-killed run, or runs. A new hit folds into that one pending delivery instead of queueing another, and the delivered prompt says how many more times the check passed and whether its latest run had cleared. On 24 Sep the event-bus watch went unpolled for 7.5 h behind a busy room, and after a killed delivery attempt its polling never resumed. One-shot watches are unchanged, and the 15-minute "⏳ Watch … waiting" line still goes out once per occurrence.
|
|
6
|
+
- A watch whose check times out 3 times in a row now wakes the agent once, marked "Watch check timing out", and keeps polling; any completed check resets the count. A hanging health check used to be logged `idle (check timed out)` and never alarm. The alarm does not use up a one-shot watch.
|
|
7
|
+
- Wakeups more than ~24.8 days out no longer fire at once. `setTimeout` overflows past 2^31-1 ms, which fired a 2028 reminder 61 s after it was set (17 Sep); far wakeups now sleep in chunks and re-plan until due, then fire once. `schedule-wakeup` refuses a `<when>` more than 10 years out, in the CLI and in the loopback route.
|
|
8
|
+
- A queued delivery is no longer stranded when a jobs-store write throws (a full disk, a torn journal). Those paths returned without a retry timer, so the alarm was never tried again; they now keep the retry armed and say so in watch.log. This matches the watch alarms that triggered during long turns on 21 and 23 Sep and were never delivered. A regression test also pins that a delivery queued behind a long turn survives the turn's compaction and runs once at the next idle moment, on the compacted session; that path already worked.
|
|
9
|
+
- `cron-list` and `watch-list` say where a fired job stands (delivery waiting, running, retrying or dead) instead of listing it as still to come. A triggered watch whose delivery was queued read "EXPIRED (fires on next poll)" (21 Sep), and a fired 2028 reminder still read "fires 2028-08-01" (17 Sep).
|
|
10
|
+
- `open-claudia say` and the send-file/voice/photo routes now say which step failed when the channel adapter refuses the upload, instead of a bare `loopback 500: {"ok":false}` (10 Sep). The adapter's own error stays in bot.log.
|
|
11
|
+
- Release gate: `test-provider-pack-review.js` gives each probe child 120 s instead of 30 s and reports a timeout as a timeout rather than "failed (null)" (v3.4.19, job 43703). The parity suite's cap for it rises to 8 minutes so it cannot undercut its children.
|
|
12
|
+
|
|
3
13
|
## 3.4.23
|
|
4
14
|
|
|
5
15
|
- Watch, wakeup and cron turns now stream their progress (interim narration, and thinking under `/verbose`) like chat turns. The live preview was gated to chat turns only, so a watch-woken turn showed the bell notice and "thinking…" and then nothing until its final answer, however long it worked.
|
package/bin/schedule.js
CHANGED
|
@@ -8,7 +8,8 @@ Background scheduling — manage wakeups and crons.
|
|
|
8
8
|
|
|
9
9
|
Wakeups (one-shot, resumes the same conversation):
|
|
10
10
|
open-claudia schedule-wakeup <when> "<prompt>" [--label "<label>"]
|
|
11
|
-
<when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z)
|
|
11
|
+
<when>: duration (e.g. 30s, 5m, 2h, 1d) OR an ISO datetime (e.g. 2026-05-16T15:00:00Z),
|
|
12
|
+
at most 10 years out
|
|
12
13
|
|
|
13
14
|
Crons (recurring):
|
|
14
15
|
open-claudia cron-add "<5-field cron>" "<prompt>" [--label "<label>"] [--project <name>]
|
|
@@ -25,6 +26,8 @@ Watches (silent background monitor — polls a shell check, wakes you only on a
|
|
|
25
26
|
datetime. Default 2h. A watch that reaches its timeout without
|
|
26
27
|
the check ever passing FIRES anyway on the next poll, with the
|
|
27
28
|
prompt marked "Watch expired" — no watch can end silently.
|
|
29
|
+
A check that times out (30s) 3 times in a row wakes the agent once, marked
|
|
30
|
+
"Watch check timing out"; the watch keeps polling.
|
|
28
31
|
open-claudia watch-list # watches on this channel + master switch state
|
|
29
32
|
open-claudia watch-remove <id> # remove a watch by id
|
|
30
33
|
open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
|
|
@@ -37,6 +40,10 @@ switching providers starts a fresh thread.
|
|
|
37
40
|
`;
|
|
38
41
|
|
|
39
42
|
const DUR = /^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/i;
|
|
43
|
+
// Mirrors core/scheduler MAX_WAKEUP_HORIZON_MS: a wakeup further out than this
|
|
44
|
+
// is a typo or a mis-parsed <when> (one was set 1273 years out).
|
|
45
|
+
const MAX_WAKEUP_YEARS = 10;
|
|
46
|
+
const MAX_WAKEUP_HORIZON_MS = MAX_WAKEUP_YEARS * 365.25 * 24 * 60 * 60 * 1000;
|
|
40
47
|
|
|
41
48
|
function parseWhen(s) {
|
|
42
49
|
if (!s) return null;
|
|
@@ -63,13 +70,41 @@ function takeFlag(args, name) {
|
|
|
63
70
|
return v;
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
// Where a job stands once it has fired. A fired job used to stay listed as a
|
|
74
|
+
// future one ("fires 2028-08-01", "EXPIRED (fires on next poll)") while its
|
|
75
|
+
// delivery was queued, running or dead (17 and 21 Sep).
|
|
76
|
+
function jobProgress(j) {
|
|
77
|
+
const since = j.check && j.watchTriggeredOccurrenceId && j.watchTriggeredOccurrenceId === j.occurrenceId && Number(j.watchTriggeredAt) > 0
|
|
78
|
+
? `TRIGGERED ${new Date(Number(j.watchTriggeredAt)).toISOString()}, ` : "";
|
|
79
|
+
const state = {
|
|
80
|
+
running: "delivery running",
|
|
81
|
+
deferred: "delivery waiting for a busy room",
|
|
82
|
+
failed: `delivery failed (${j.lastError?.code || "error"}), retrying`,
|
|
83
|
+
"dead-letter": `delivery failed (${j.lastError?.code || "error"}), DEAD: will not fire`,
|
|
84
|
+
}[j.status];
|
|
85
|
+
return state ? `${since}${state}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
function fmtJob(j) {
|
|
67
89
|
const when = j.kind === "wakeup"
|
|
68
90
|
? `fires ${new Date(j.fireAt).toISOString()}`
|
|
69
91
|
: `schedule "${j.schedule}"`;
|
|
70
92
|
const proj = j.project ? ` [${j.project}]` : "";
|
|
71
93
|
const src = j.source ? ` (${j.source})` : "";
|
|
72
|
-
|
|
94
|
+
const progress = jobProgress(j);
|
|
95
|
+
return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}${progress ? ` [${progress}]` : ""}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fmtWatch(w, now = Date.now()) {
|
|
99
|
+
const mode = w.repeat ? "repeating" : "one-shot";
|
|
100
|
+
const progress = jobProgress(w);
|
|
101
|
+
const expiry = progress ? ` ${progress}`
|
|
102
|
+
: Number(w.expiresAt) > 0
|
|
103
|
+
? (now >= Number(w.expiresAt)
|
|
104
|
+
? " EXPIRED (fires on next poll)"
|
|
105
|
+
: ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
|
|
106
|
+
: " no expiry (pre-timeout watch)";
|
|
107
|
+
return `${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`;
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
async function runScheduleWakeup(args) {
|
|
@@ -82,6 +117,11 @@ async function runScheduleWakeup(args) {
|
|
|
82
117
|
console.error(`Could not parse <when>: "${rest[0]}". Use a duration (5m, 2h, 1d) or ISO datetime.`);
|
|
83
118
|
process.exit(2);
|
|
84
119
|
}
|
|
120
|
+
if (when - Date.now() > MAX_WAKEUP_HORIZON_MS) {
|
|
121
|
+
const at = new Date(when);
|
|
122
|
+
console.error(`<when> "${rest[0]}" is ${Number.isNaN(at.getTime()) ? "past any valid date" : at.toISOString()}, more than ${MAX_WAKEUP_YEARS} years out. Check the date; for a far reminder use a yearly cron-add.`);
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
85
125
|
const prompt = rest.slice(1).join(" ");
|
|
86
126
|
if (!prompt) { console.error("Missing prompt."); process.exit(2); }
|
|
87
127
|
try {
|
|
@@ -178,15 +218,7 @@ async function runWatchList() {
|
|
|
178
218
|
const list = res.watches || [];
|
|
179
219
|
console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
|
|
180
220
|
if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
|
|
181
|
-
for (const w of list)
|
|
182
|
-
const mode = w.repeat ? "repeating" : "one-shot";
|
|
183
|
-
const expiry = Number(w.expiresAt) > 0
|
|
184
|
-
? (Date.now() >= Number(w.expiresAt)
|
|
185
|
-
? " EXPIRED (fires on next poll)"
|
|
186
|
-
: ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
|
|
187
|
-
: " no expiry (pre-timeout watch)";
|
|
188
|
-
console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
|
|
189
|
-
}
|
|
221
|
+
for (const w of list) console.log(fmtWatch(w));
|
|
190
222
|
process.exit(0);
|
|
191
223
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
192
224
|
}
|
|
@@ -211,6 +243,8 @@ async function runWatchToggle(enabled) {
|
|
|
211
243
|
|
|
212
244
|
module.exports = {
|
|
213
245
|
HELP,
|
|
246
|
+
fmtJob,
|
|
247
|
+
fmtWatch,
|
|
214
248
|
runScheduleWakeup,
|
|
215
249
|
runCronAdd,
|
|
216
250
|
runCronList,
|
package/core/loopback.js
CHANGED
|
@@ -58,6 +58,14 @@ function reply(res, code, body) {
|
|
|
58
58
|
res.end(data);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// An adapter's send* resolves false after logging its own error, so the JSON
|
|
62
|
+
// used to say only {"ok":false} (10 Sep: every `say` failed like that through
|
|
63
|
+
// a Kazee outage). Name the step that failed; the adapter's message may carry
|
|
64
|
+
// request details, so it stays in bot.log.
|
|
65
|
+
function undeliveredReason(adapter, what) {
|
|
66
|
+
return `${adapter.type || adapter.id} adapter did not deliver the ${what}: the channel rejected it or was unreachable (details in bot.log)`;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function authOk(req) {
|
|
62
70
|
return (req.headers["authorization"] || "") === `Bearer ${token}`;
|
|
63
71
|
}
|
|
@@ -154,7 +162,7 @@ async function handleSend(req, res, url, kind) {
|
|
|
154
162
|
} finally {
|
|
155
163
|
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
156
164
|
}
|
|
157
|
-
return reply(res, ok
|
|
165
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, kind === "send-voice" ? "voice note" : kind.slice(5)) });
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
async function handleJson(req, res, url, kind) {
|
|
@@ -215,7 +223,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
215
223
|
return reply(res, 500, { error: `voice send failed: ${e.message}` });
|
|
216
224
|
}
|
|
217
225
|
try { fs.unlinkSync(voicePath); } catch (e) {}
|
|
218
|
-
return reply(res, ok
|
|
226
|
+
return ok ? reply(res, 200, { ok }) : reply(res, 500, { ok, error: undeliveredReason(adapter, "voice note") });
|
|
219
227
|
}
|
|
220
228
|
|
|
221
229
|
if (kind === "tts") {
|
|
@@ -271,6 +279,9 @@ async function handleJson(req, res, url, kind) {
|
|
|
271
279
|
if (kind === "schedule-wakeup") {
|
|
272
280
|
const fireAt = Number(payload.fireAt);
|
|
273
281
|
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
282
|
+
if (fireAt - Date.now() > scheduler.MAX_WAKEUP_HORIZON_MS) {
|
|
283
|
+
return reply(res, 400, { error: "fireAt is more than 10 years out; check the date (a far reminder belongs in a yearly cron)" });
|
|
284
|
+
}
|
|
274
285
|
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
275
286
|
const job = scheduler.addJob({
|
|
276
287
|
kind: "wakeup",
|
package/core/runner.js
CHANGED
|
@@ -2286,8 +2286,8 @@ function queuedNotice(activeRunContext, at = Date.now()) {
|
|
|
2286
2286
|
const since = Number(activeRunContext?.admittedAt);
|
|
2287
2287
|
if (!Number.isFinite(since) || at - since < QUEUED_DETAIL_AFTER_MS) return "Queued.";
|
|
2288
2288
|
const minutes = Math.floor((at - since) / 60000);
|
|
2289
|
-
const header = String(activeRunContext.userPrompt || "").match(/^\[(Watch triggered|Watch expired|Scheduled cron|Scheduled wakeup) "([^"]*)"/);
|
|
2290
|
-
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2289
|
+
const header = String(activeRunContext.userPrompt || "").match(/^\[(Watch triggered|Watch expired|Watch check timing out|Scheduled cron|Scheduled wakeup) "([^"]*)"/);
|
|
2290
|
+
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Watch check timing out": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2291
2291
|
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2292
2292
|
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2293
2293
|
return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
|
package/core/scheduler.js
CHANGED
|
@@ -48,6 +48,18 @@ const BUSY_DELAY_MS = 30 * 1000;
|
|
|
48
48
|
const BUSY_NOTICE_AFTER_MS = 15 * 60 * 1000;
|
|
49
49
|
// A watch's `check` command is capped so a hung poll can't wedge the tick.
|
|
50
50
|
const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
51
|
+
// A hanging check is itself a signal (24 Sep: the Phone health check timed out
|
|
52
|
+
// twice and was only logged idle). This many timeouts in a row wake the agent
|
|
53
|
+
// once, marked "check timing out"; the watch keeps polling, and any completed
|
|
54
|
+
// check resets the count.
|
|
55
|
+
const WATCH_TIMEOUT_ALARM_AFTER = 3;
|
|
56
|
+
// setTimeout holds at most 2^31-1 ms (~24.8 days); a longer delay overflows
|
|
57
|
+
// and fires at once (17 Sep: a 2028 reminder fired 61 s after it was set).
|
|
58
|
+
// Far wakeups sleep in chunks of this size and re-plan until due.
|
|
59
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1 - 1000;
|
|
60
|
+
// schedule-wakeup refuses a fire time further out than this: past it the
|
|
61
|
+
// <when> is a typo or a mis-parse (one wakeup was set 1273 years out).
|
|
62
|
+
const MAX_WAKEUP_HORIZON_MS = 10 * 365.25 * 24 * 60 * 60 * 1000;
|
|
51
63
|
// Every watch carries a hard expiry so none can idle-poll forever: when the
|
|
52
64
|
// deadline lapses with the check still failing, the next poll tick fires the
|
|
53
65
|
// wake anyway (marked expired) instead of skipping silently.
|
|
@@ -75,9 +87,25 @@ function terminalRunSucceeded(result) {
|
|
|
75
87
|
|
|
76
88
|
// A one-shot fires exactly once then is removed after a successful run: a
|
|
77
89
|
// wakeup, or a watch (a cron carrying a shell `check`) created without repeat
|
|
78
|
-
// polling. A repeat watch stays pending and keeps polling like any cron
|
|
90
|
+
// polling. A repeat watch stays pending and keeps polling like any cron, and
|
|
91
|
+
// so does any watch whose fire only reported a hanging check.
|
|
79
92
|
function isOneShot(job) {
|
|
80
|
-
return job.kind === "wakeup" || (!!job.check && !job.repeat);
|
|
93
|
+
return job.kind === "wakeup" || (!!job.check && !job.repeat && !job.watchCheckTimingOut);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A repeating watch keeps polling on its schedule while a triggered delivery
|
|
97
|
+
// waits for a busy room, waits for a retry after a failed or /stop-killed run,
|
|
98
|
+
// or runs. New hits fold into that one delivery. Everything else parks on its
|
|
99
|
+
// retry timer alone (24 Sep: the event-bus watch went unpolled for 7.5 h).
|
|
100
|
+
function pollsWhilePending(job) {
|
|
101
|
+
return !!job?.check && !!job.repeat;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The watch's trigger is latched and its delivery has not completed.
|
|
105
|
+
function deliveryPending(job) {
|
|
106
|
+
return pollsWhilePending(job)
|
|
107
|
+
&& job.watchTriggeredOccurrenceId === job.occurrenceId
|
|
108
|
+
&& ["deferred", "failed", "running"].includes(job.status);
|
|
81
109
|
}
|
|
82
110
|
|
|
83
111
|
function providerIsRunnable(status) {
|
|
@@ -288,16 +316,37 @@ function createSchedulerService(dependencies = {}) {
|
|
|
288
316
|
return null;
|
|
289
317
|
}
|
|
290
318
|
|
|
319
|
+
// What a repeating watch's polls saw while its delivery waited.
|
|
320
|
+
function pendingPollNote(job) {
|
|
321
|
+
const hits = Number(job.watchExtraHits) || 0;
|
|
322
|
+
const lastHitAt = Number(job.watchLastHitAt) || 0;
|
|
323
|
+
const lastClearAt = Number(job.watchLastClearAt) || 0;
|
|
324
|
+
if (!hits && !lastClearAt) return null;
|
|
325
|
+
const at = (ms) => new Date(ms).toISOString();
|
|
326
|
+
const seen = [];
|
|
327
|
+
if (hits) seen.push(`the check passed ${hits} more time${hits === 1 ? "" : "s"}, the latest at ${at(lastHitAt)}`);
|
|
328
|
+
seen.push(lastClearAt > lastHitAt
|
|
329
|
+
? `its latest run, at ${at(lastClearAt)}, did not pass, so the condition may have cleared`
|
|
330
|
+
: "the condition was still true at the latest check");
|
|
331
|
+
return `[This watch kept polling while this delivery waited (queued at ${at(Number(job.watchTriggeredAt) || now())}): ${seen.join("; ")}.]`;
|
|
332
|
+
}
|
|
333
|
+
|
|
291
334
|
function wrappedPrompt(job, target) {
|
|
292
335
|
const firedAt = new Date(now()).toISOString();
|
|
293
336
|
const expired = !!(job.check && job.watchExpired);
|
|
337
|
+
const timingOut = !!(job.check && !expired && job.watchCheckTimingOut);
|
|
294
338
|
const header = job.check
|
|
295
|
-
? (expired ? "Watch expired" : "Watch triggered")
|
|
339
|
+
? (expired ? "Watch expired" : timingOut ? "Watch check timing out" : "Watch triggered")
|
|
296
340
|
: job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
297
341
|
const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
|
|
298
342
|
if (expired) {
|
|
299
343
|
lines.push("[This watch reached its timeout without its check ever passing — the watched condition did NOT happen. This is a deadline wake-up, not a trigger: investigate why the condition never became true, and arm a new watch if it is still worth waiting for.]");
|
|
300
344
|
}
|
|
345
|
+
if (timingOut) {
|
|
346
|
+
lines.push(`[This watch's check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row (each run is capped at ${WATCH_CHECK_TIMEOUT_MS / 1000} s), so the watched condition could not be read. That is itself a signal: find out why the check hangs. This is not a trigger, and the watch keeps polling.]`);
|
|
347
|
+
}
|
|
348
|
+
const polled = job.check ? pendingPollNote(job) : null;
|
|
349
|
+
if (polled) lines.push(polled);
|
|
301
350
|
if (target.fallbackFrom) {
|
|
302
351
|
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
303
352
|
} else if (target.revertedFrom) {
|
|
@@ -317,14 +366,36 @@ function createSchedulerService(dependencies = {}) {
|
|
|
317
366
|
timers.delete(id);
|
|
318
367
|
}
|
|
319
368
|
|
|
369
|
+
// Drop only a job's delivery (retry) timer; a watch's poll schedule stays.
|
|
370
|
+
function clearDeliveryTimer(id) {
|
|
371
|
+
const timer = timers.get(id);
|
|
372
|
+
if (!timer?.timeout) return;
|
|
373
|
+
try { clearTimer(timer.timeout); } catch (_) {}
|
|
374
|
+
if (timer.stop) timers.set(id, { stop: timer.stop });
|
|
375
|
+
else timers.delete(id);
|
|
376
|
+
}
|
|
377
|
+
|
|
320
378
|
function armRetry(job, delay = null) {
|
|
321
|
-
unscheduleJob(job.id);
|
|
322
|
-
if (job
|
|
379
|
+
if (job.disabled || job.status === "dead-letter" || job.status === "succeeded") return unscheduleJob(job.id);
|
|
380
|
+
if (pollsWhilePending(job)) clearDeliveryTimer(job.id);
|
|
381
|
+
else unscheduleJob(job.id);
|
|
323
382
|
const wait = delay === null
|
|
324
383
|
? Math.max(0, Number(job.nextAttemptAt || now()) - now())
|
|
325
384
|
: Math.max(0, delay);
|
|
326
385
|
const timeout = setTimer(() => fireJob(job.id, { reason: "retry" }), wait);
|
|
327
|
-
timers.set(job.id, { timeout });
|
|
386
|
+
timers.set(job.id, { ...timers.get(job.id), timeout });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// A jobs-store write that throws (a full disk, a torn journal) must not
|
|
390
|
+
// strand a queued delivery: the retry stays armed in memory and the next
|
|
391
|
+
// attempt re-reads the store. Such a throw used to return with no timer,
|
|
392
|
+
// so the delivery was never tried again (21 and 23 Sep: watch alarms that
|
|
393
|
+
// triggered during long turns were never delivered).
|
|
394
|
+
function keepRetrying(job, error, delay = retryDelayMs) {
|
|
395
|
+
const failure = stableFailure(error);
|
|
396
|
+
try { armRetry(job, delay); } catch (_) {}
|
|
397
|
+
if (job.check) logWatch(job, `delivery bookkeeping failed (${failure.code}) — retrying in ${Math.round(delay / 1000)} s`);
|
|
398
|
+
return { ok: false, status: "failed", error: failure };
|
|
328
399
|
}
|
|
329
400
|
|
|
330
401
|
function markFailure(job, failure, options = {}) {
|
|
@@ -367,7 +438,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
367
438
|
armRetry(updated, busyDelayMs);
|
|
368
439
|
if (noticeDue) {
|
|
369
440
|
const minutes = Math.floor(waited / 60000);
|
|
370
|
-
const what = updated.watchExpired ? "timed out" : "triggered";
|
|
441
|
+
const what = updated.watchExpired ? "timed out" : updated.watchCheckTimingOut ? "raised a check-timeout alarm" : "triggered";
|
|
371
442
|
try {
|
|
372
443
|
Promise.resolve(send(`⏳ Watch "${updated.label}" ${what} ${minutes} min ago and is waiting for the current run to end. /stop ends that run so the watch can go.`)).catch(() => {});
|
|
373
444
|
} catch (_) { /* a notice only */ }
|
|
@@ -416,6 +487,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
416
487
|
watchTriggeredOccurrenceId: null,
|
|
417
488
|
watchTriggeredAt: null,
|
|
418
489
|
watchExpired: false,
|
|
490
|
+
watchCheckTimingOut: false,
|
|
491
|
+
watchExtraHits: 0,
|
|
492
|
+
watchLastHitAt: null,
|
|
493
|
+
watchLastClearAt: null,
|
|
419
494
|
advisoryOccurrenceId: null,
|
|
420
495
|
});
|
|
421
496
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
@@ -440,6 +515,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
440
515
|
return { ok: false, status: job.status, skipped: true };
|
|
441
516
|
}
|
|
442
517
|
if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
|
|
518
|
+
// fireJob already turned away a fire in flight here, so this lease was
|
|
519
|
+
// left by an earlier process: look again when it lapses. Returning with
|
|
520
|
+
// no timer stranded the job until the next restart.
|
|
521
|
+
armRetry(job);
|
|
443
522
|
return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
444
523
|
}
|
|
445
524
|
if (job.status === "succeeded" && isOneShot(job)) {
|
|
@@ -469,15 +548,28 @@ function createSchedulerService(dependencies = {}) {
|
|
|
469
548
|
// persisted before expiry existed (no expiresAt) keep polling forever.
|
|
470
549
|
const expiresAt = Number(job.expiresAt || 0);
|
|
471
550
|
if (!(expiresAt > 0 && now() >= expiresAt)) {
|
|
472
|
-
|
|
473
|
-
|
|
551
|
+
// Timeouts in a row; a completed check resets the count. Only the
|
|
552
|
+
// WATCH_TIMEOUT_ALARM_AFTER-th wakes the agent, so a check that stays
|
|
553
|
+
// hung alarms once rather than on every later poll.
|
|
554
|
+
const timedOut = !!check?.timedOut;
|
|
555
|
+
const previous = Number(job.watchTimeoutStreak) || 0;
|
|
556
|
+
const streak = timedOut ? previous + 1 : 0;
|
|
557
|
+
if (streak !== WATCH_TIMEOUT_ALARM_AFTER) {
|
|
558
|
+
if (streak !== previous) {
|
|
559
|
+
try { jobsStore.update(job.id, { watchTimeoutStreak: streak }); } catch (_) { /* best effort */ }
|
|
560
|
+
}
|
|
561
|
+
logWatch(job, timedOut ? `idle (check timed out, ${streak} in a row)` : `idle (check exit ${check ? check.code : "?"})`);
|
|
562
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
563
|
+
}
|
|
564
|
+
job = { ...job, watchExpired: false, watchCheckTimingOut: true, watchTimeoutStreak: streak };
|
|
565
|
+
} else {
|
|
566
|
+
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
567
|
+
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
568
|
+
// failed expiry fire keeps the same semantics across restarts.
|
|
569
|
+
job = { ...job, repeat: false, watchExpired: true, watchCheckTimingOut: false };
|
|
474
570
|
}
|
|
475
|
-
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
476
|
-
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
477
|
-
// failed expiry fire keeps the same semantics across restarts.
|
|
478
|
-
job = { ...job, repeat: false, watchExpired: true };
|
|
479
571
|
} else {
|
|
480
|
-
job = { ...job, watchExpired: false };
|
|
572
|
+
job = { ...job, watchExpired: false, watchCheckTimingOut: false, watchTimeoutStreak: 0 };
|
|
481
573
|
}
|
|
482
574
|
// Latch the condition before admission or notification. Delivery retries
|
|
483
575
|
// and restarts must not run the check again or change an expiry's meaning.
|
|
@@ -487,6 +579,8 @@ function createSchedulerService(dependencies = {}) {
|
|
|
487
579
|
nextAttemptAt: now(),
|
|
488
580
|
repeat: job.repeat,
|
|
489
581
|
watchExpired: job.watchExpired,
|
|
582
|
+
watchCheckTimingOut: job.watchCheckTimingOut,
|
|
583
|
+
watchTimeoutStreak: Number(job.watchTimeoutStreak) || 0,
|
|
490
584
|
watchTriggeredOccurrenceId: job.occurrenceId,
|
|
491
585
|
watchTriggeredAt: now(),
|
|
492
586
|
});
|
|
@@ -495,13 +589,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
495
589
|
} catch (error) {
|
|
496
590
|
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
497
591
|
}
|
|
498
|
-
logWatch(job, job.watchExpired ? "expired without firing — delivery queued"
|
|
592
|
+
logWatch(job, job.watchExpired ? "expired without firing — delivery queued"
|
|
593
|
+
: job.watchCheckTimingOut ? `check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row — delivery queued`
|
|
594
|
+
: "triggered (check passed) — delivery queued");
|
|
499
595
|
}
|
|
500
596
|
|
|
501
597
|
const adapter = resolveAdapter(job);
|
|
502
598
|
if (!adapter) {
|
|
503
599
|
try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
|
|
504
|
-
catch (error) { return
|
|
600
|
+
catch (error) { return keepRetrying(job, error); }
|
|
505
601
|
}
|
|
506
602
|
const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
|
|
507
603
|
const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
|
|
@@ -531,7 +627,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
531
627
|
const state = stateForUser(canonicalUserId);
|
|
532
628
|
if (state.runningProcess || state.preparingRun || state.isCompacting) {
|
|
533
629
|
try { return markBusy(job); }
|
|
534
|
-
catch (error) { return
|
|
630
|
+
catch (error) { return keepRetrying(job, error, busyDelayMs); }
|
|
535
631
|
}
|
|
536
632
|
|
|
537
633
|
const target = executionTarget(job, state);
|
|
@@ -541,7 +637,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
541
637
|
code: "SCHEDULED_PROVIDER_UNAVAILABLE",
|
|
542
638
|
message: `Saved provider ${job.provider} is unavailable and no explicit fallback is configured`,
|
|
543
639
|
});
|
|
544
|
-
} catch (error) { return
|
|
640
|
+
} catch (error) { return keepRetrying(job, error); }
|
|
545
641
|
}
|
|
546
642
|
|
|
547
643
|
const project = projectDescriptor(job);
|
|
@@ -559,8 +655,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
559
655
|
scheduledJobId: job.id,
|
|
560
656
|
originRunId: job.originRunId || null,
|
|
561
657
|
};
|
|
562
|
-
// Admission is synchronous and precedes all notification delivery.
|
|
563
|
-
|
|
658
|
+
// Admission is synchronous and precedes all notification delivery. A
|
|
659
|
+
// refusal is a failed attempt, never a rejection that drops the job.
|
|
660
|
+
let runContext;
|
|
661
|
+
try { runContext = admit(prompt, project.dir, null, runOptions); }
|
|
662
|
+
catch (error) {
|
|
663
|
+
try { return markFailure(job, error); }
|
|
664
|
+
catch (persistError) { return keepRetrying(job, persistError); }
|
|
665
|
+
}
|
|
564
666
|
|
|
565
667
|
let running;
|
|
566
668
|
try {
|
|
@@ -575,11 +677,13 @@ function createSchedulerService(dependencies = {}) {
|
|
|
575
677
|
});
|
|
576
678
|
if (!running) throw new Error("scheduled attempt could not be persisted");
|
|
577
679
|
} catch (error) {
|
|
578
|
-
return
|
|
680
|
+
return keepRetrying(job, error);
|
|
579
681
|
}
|
|
580
682
|
|
|
581
683
|
const advisory = job.check
|
|
582
|
-
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
|
|
684
|
+
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
|
|
685
|
+
: job.watchCheckTimingOut ? `⚠️ Watch "${job.label}": its check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row`
|
|
686
|
+
: `🔔 Watch "${job.label}" triggered`)
|
|
583
687
|
: `Wakeup: ${job.label}`;
|
|
584
688
|
// Say so when the run is NOT on the provider this room is set to: an
|
|
585
689
|
// unexplained error from a provider the room never selected is worse than
|
|
@@ -601,16 +705,70 @@ function createSchedulerService(dependencies = {}) {
|
|
|
601
705
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
602
706
|
catch (error) {
|
|
603
707
|
try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
|
|
604
|
-
catch (persistError) { return
|
|
708
|
+
catch (persistError) { return keepRetrying(running, persistError); }
|
|
605
709
|
}
|
|
606
710
|
if (!terminalRunSucceeded(result)) {
|
|
607
711
|
try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
|
|
608
|
-
catch (error) { return {
|
|
712
|
+
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
609
713
|
}
|
|
610
714
|
return settleSuccess(running, result, target);
|
|
611
715
|
});
|
|
612
716
|
}
|
|
613
717
|
|
|
718
|
+
// Poll a repeating watch whose delivery is still pending. Nothing new is
|
|
719
|
+
// queued: a hit is counted into that delivery (its prompt says so), a
|
|
720
|
+
// completed miss is noted as a possible clear, a timeout is only logged.
|
|
721
|
+
async function pollPendingWatch(job) {
|
|
722
|
+
if (!watchesEnabled()) {
|
|
723
|
+
logWatch(job, "skipped (watches disabled globally)");
|
|
724
|
+
return { ok: false, status: "watch-disabled", skipped: true };
|
|
725
|
+
}
|
|
726
|
+
if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
|
|
727
|
+
checksInFlight.add(job.id);
|
|
728
|
+
let check;
|
|
729
|
+
try { check = await runWatchCheck(job.check, job); }
|
|
730
|
+
catch (_) { check = { code: 1, timedOut: false }; }
|
|
731
|
+
finally { checksInFlight.delete(job.id); }
|
|
732
|
+
try {
|
|
733
|
+
const latest = jobsStore.get(job.id);
|
|
734
|
+
// The delivery may have finished while the check ran; the result then
|
|
735
|
+
// belongs to no pending occurrence.
|
|
736
|
+
if (!latest || latest.occurrenceId !== job.occurrenceId || !deliveryPending(latest)) {
|
|
737
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
738
|
+
}
|
|
739
|
+
if (check?.timedOut) {
|
|
740
|
+
logWatch(latest, "idle (check timed out) — delivery still pending");
|
|
741
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
742
|
+
}
|
|
743
|
+
const hit = check?.code === 0;
|
|
744
|
+
const extraHits = (Number(latest.watchExtraHits) || 0) + (hit ? 1 : 0);
|
|
745
|
+
jobsStore.update(job.id, {
|
|
746
|
+
...(hit ? { watchExtraHits: extraHits, watchLastHitAt: now() } : { watchLastClearAt: now() }),
|
|
747
|
+
...(latest.watchTimeoutStreak ? { watchTimeoutStreak: 0 } : {}),
|
|
748
|
+
});
|
|
749
|
+
logWatch(latest, hit
|
|
750
|
+
? `triggered again (check passed) — folded into the pending delivery (${extraHits} more)`
|
|
751
|
+
: `idle (check exit ${check ? check.code : "?"}) — delivery still pending`);
|
|
752
|
+
return { ok: false, status: hit ? "watch-folded" : "watch-idle", skipped: true, extraHits };
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// A cron's schedule. A pending job fires on each tick; a repeating watch
|
|
759
|
+
// whose delivery is still pending is only polled.
|
|
760
|
+
function armCron(job) {
|
|
761
|
+
if (!validateCron(job.schedule)) return false;
|
|
762
|
+
const task = scheduleCron(job.schedule, () => {
|
|
763
|
+
const current = jobsStore.get(job.id);
|
|
764
|
+
if (!current || current.disabled) return;
|
|
765
|
+
if (current.status === "pending") fireJob(current.id, { reason: "cron" });
|
|
766
|
+
else if (deliveryPending(current)) pollPendingWatch(current);
|
|
767
|
+
});
|
|
768
|
+
timers.set(job.id, { stop: () => task.stop?.() });
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
|
|
614
772
|
function scheduleJob(job) {
|
|
615
773
|
unscheduleJob(job.id);
|
|
616
774
|
if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
|
|
@@ -619,6 +777,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
619
777
|
return false;
|
|
620
778
|
}
|
|
621
779
|
if (["failed", "deferred", "running"].includes(job.status)) {
|
|
780
|
+
if (pollsWhilePending(job)) armCron(job);
|
|
622
781
|
armRetry(job, Math.max(0, Number(job.nextAttemptAt || now()) - now()));
|
|
623
782
|
return true;
|
|
624
783
|
}
|
|
@@ -636,20 +795,20 @@ function createSchedulerService(dependencies = {}) {
|
|
|
636
795
|
} catch (_) {}
|
|
637
796
|
return false;
|
|
638
797
|
}
|
|
639
|
-
const
|
|
798
|
+
const wait = Math.max(0, fireAt - now());
|
|
799
|
+
// Too far out for one timer: sleep a chunk, then re-plan from the store.
|
|
800
|
+
const timeout = wait > MAX_TIMER_DELAY_MS
|
|
801
|
+
? setTimer(() => {
|
|
802
|
+
try {
|
|
803
|
+
const current = jobsStore.get(job.id);
|
|
804
|
+
if (current) scheduleJob(current);
|
|
805
|
+
} catch (_) { /* init() re-plans on the next boot */ }
|
|
806
|
+
}, MAX_TIMER_DELAY_MS)
|
|
807
|
+
: setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), wait);
|
|
640
808
|
timers.set(job.id, { timeout });
|
|
641
809
|
return true;
|
|
642
810
|
}
|
|
643
|
-
if (job.kind === "cron")
|
|
644
|
-
if (!validateCron(job.schedule)) return false;
|
|
645
|
-
const task = scheduleCron(job.schedule, () => {
|
|
646
|
-
const current = jobsStore.get(job.id);
|
|
647
|
-
if (!current || current.disabled || current.status !== "pending") return;
|
|
648
|
-
fireJob(current.id, { reason: "cron" });
|
|
649
|
-
});
|
|
650
|
-
timers.set(job.id, { stop: () => task.stop?.() });
|
|
651
|
-
return true;
|
|
652
|
-
}
|
|
811
|
+
if (job.kind === "cron") return armCron(job);
|
|
653
812
|
return false;
|
|
654
813
|
}
|
|
655
814
|
|
|
@@ -809,6 +968,9 @@ module.exports = {
|
|
|
809
968
|
RETRY_DELAY_MS,
|
|
810
969
|
BUSY_DELAY_MS,
|
|
811
970
|
DEFAULT_WATCH_TIMEOUT_MS,
|
|
971
|
+
WATCH_TIMEOUT_ALARM_AFTER,
|
|
972
|
+
MAX_TIMER_DELAY_MS,
|
|
973
|
+
MAX_WAKEUP_HORIZON_MS,
|
|
812
974
|
createSchedulerService,
|
|
813
975
|
parseFallbacks,
|
|
814
976
|
terminalRunSucceeded,
|