@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 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
- return `${j.id} ${j.kind} ${j.label || "(no label)"}${proj}${src} ${when}`;
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 ? 200 : 500, { 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 ? 200 : 500, { 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.disabled || job.status === "dead-letter" || job.status === "succeeded") return;
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
- logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
473
- return { ok: false, status: "watch-idle", skipped: true };
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" : "triggered (check passed) — 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 { ok: false, status: "failed", error: stableFailure(error) }; }
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 { ok: false, status: "failed", error: stableFailure(error) }; }
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 { ok: false, status: "failed", error: stableFailure(error) }; }
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
- const runContext = admit(prompt, project.dir, null, runOptions);
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 { ok: false, status: "failed", error: stableFailure(error) };
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` : `🔔 Watch "${job.label}" triggered`)
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 { ok: false, status: "failed", error: stableFailure(persistError) }; }
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 { ok: false, status: "failed", error: stableFailure(error), result }; }
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 timeout = setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), Math.max(0, fireAt - now()));
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.23",
3
+ "version": "3.4.24",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {