@inetafrica/open-claudia 3.4.23 → 3.5.0

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/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.
@@ -61,6 +73,84 @@ function uniqueId(prefix = "occ") {
61
73
  : `${prefix}-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
62
74
  }
63
75
 
76
+ // ── watch-check environment ────────────────────────────────────────────────
77
+ // A watch's `check` runs a shell command. It used to inherit `env:
78
+ // process.env` — every secret the bot process holds (provider credentials,
79
+ // bot tokens, the loopback token), so a staff member's check could read them
80
+ // all. Build the env explicitly instead: keep the harmless shell essentials
81
+ // and the Open Claudia config dir (so `open-claudia ...` resolves its config),
82
+ // then drop anything that looks like a credential (a broad denylist pattern)
83
+ // plus the known-secret names the provider env-isolation layer already
84
+ // strips. A short-lived, creator-scoped run is stamped on top (below) so the
85
+ // check's reusable tools still attribute to whoever created the watch.
86
+ const WATCH_CHECK_ENV_KEEP = new Set([
87
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL",
88
+ "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
89
+ "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR",
90
+ "TMPDIR", "TMP", "TEMP",
91
+ "OPEN_CLAUDIA_CONFIG_DIR",
92
+ "SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
93
+ ]);
94
+ const WATCH_CHECK_SECRET_PATTERN = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|OAUTH|CREDENTIAL|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|SESSION[_-]?KEY|BEARER)/i;
95
+ let KNOWN_SECRET_ENV_KEYS = null;
96
+ function knownSecretEnvKeys() {
97
+ if (KNOWN_SECRET_ENV_KEYS) return KNOWN_SECRET_ENV_KEYS;
98
+ const set = new Set();
99
+ try { for (const key of require("./providers/env").ALWAYS_STRIPPED_KEYS) set.add(key); } catch (_) {}
100
+ KNOWN_SECRET_ENV_KEYS = set;
101
+ return set;
102
+ }
103
+ function isSecretCheckEnvKey(key) {
104
+ if (WATCH_CHECK_ENV_KEEP.has(key)) return false;
105
+ if (knownSecretEnvKeys().has(key)) return true;
106
+ return WATCH_CHECK_SECRET_PATTERN.test(key);
107
+ }
108
+ function buildWatchCheckEnv(source, stamp = {}) {
109
+ const env = {};
110
+ for (const [key, value] of Object.entries(source || {})) {
111
+ if (value == null) continue;
112
+ if (isSecretCheckEnvKey(key)) continue;
113
+ env[key] = String(value);
114
+ }
115
+ for (const [key, value] of Object.entries(stamp)) {
116
+ if (value == null || value === "") continue;
117
+ env[key] = String(value);
118
+ }
119
+ return env;
120
+ }
121
+
122
+ // The principal a watch-check's short-lived run acts for: the job's creator,
123
+ // resolved from its stored channel/user the same way runPrincipal resolves a
124
+ // turn's speaker. Owner → the check attributes as the owner (unchanged); a
125
+ // staff creator → the check runs at their level. Fail closed to non-owner.
126
+ function watchCheckPrincipal(job) {
127
+ const adapterType = job && job.adapterType ? String(job.adapterType) : null;
128
+ const channelId = job && job.channelId != null ? String(job.channelId) : null;
129
+ const userId = job && job.userId != null ? String(job.userId) : null;
130
+ let owner = false;
131
+ let name = null;
132
+ let room = null;
133
+ if (adapterType && channelId) {
134
+ try {
135
+ const sp = require("./relationship").speakerFor(adapterType, channelId, userId);
136
+ owner = !!(sp && sp.isOwner === true);
137
+ name = (sp && sp.name) || null;
138
+ room = (sp && sp.room) || null;
139
+ } catch (_) { owner = false; }
140
+ }
141
+ return {
142
+ kind: "watch-check",
143
+ owner,
144
+ canonicalUserId: job && job.canonicalUserId ? String(job.canonicalUserId) : null,
145
+ name,
146
+ adapterId: job && job.adapter ? String(job.adapter) : null,
147
+ adapterType,
148
+ channelId,
149
+ userId,
150
+ room,
151
+ };
152
+ }
153
+
64
154
  function parseFallbacks(value) {
65
155
  if (Array.isArray(value)) return [...new Set(value.map((item) => String(item).trim().toLowerCase()).filter(Boolean))];
66
156
  return [...new Set(String(value || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
@@ -75,9 +165,25 @@ function terminalRunSucceeded(result) {
75
165
 
76
166
  // A one-shot fires exactly once then is removed after a successful run: a
77
167
  // 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.
168
+ // polling. A repeat watch stays pending and keeps polling like any cron, and
169
+ // so does any watch whose fire only reported a hanging check.
79
170
  function isOneShot(job) {
80
- return job.kind === "wakeup" || (!!job.check && !job.repeat);
171
+ return job.kind === "wakeup" || (!!job.check && !job.repeat && !job.watchCheckTimingOut);
172
+ }
173
+
174
+ // A repeating watch keeps polling on its schedule while a triggered delivery
175
+ // waits for a busy room, waits for a retry after a failed or /stop-killed run,
176
+ // or runs. New hits fold into that one delivery. Everything else parks on its
177
+ // retry timer alone (24 Sep: the event-bus watch went unpolled for 7.5 h).
178
+ function pollsWhilePending(job) {
179
+ return !!job?.check && !!job.repeat;
180
+ }
181
+
182
+ // The watch's trigger is latched and its delivery has not completed.
183
+ function deliveryPending(job) {
184
+ return pollsWhilePending(job)
185
+ && job.watchTriggeredOccurrenceId === job.occurrenceId
186
+ && ["deferred", "failed", "running"].includes(job.status);
81
187
  }
82
188
 
83
189
  function providerIsRunnable(status) {
@@ -127,23 +233,49 @@ function createSchedulerService(dependencies = {}) {
127
233
  const timers = new Map();
128
234
  const checksInFlight = new Set();
129
235
  const firesInFlight = new Set();
236
+ // Jobs already on disk before the creator-binding fix carry no `createdBy`.
237
+ // We keep running them AS THEY ARE (their stored channel/user is what the
238
+ // fire path resolves the speaker from — the owner's own watches and wakeups
239
+ // must not silently break), but log the first time each fires so an audit
240
+ // can spot a pre-fix job that outranks a non-owner. Logged once per id/boot.
241
+ const loggedLegacyCreators = new Set();
242
+ function noteLegacyCreatorOnce(job) {
243
+ if (!job || job.createdBy || loggedLegacyCreators.has(job.id)) return;
244
+ loggedLegacyCreators.add(job.id);
245
+ try {
246
+ console.warn(`[scheduler] job ${job.id} (${job.kind}${job.check ? "/watch" : ""}) predates creator binding — running with its stored identity ${job.canonicalUserId || "?"} in ${job.adapterType || "?"}:${job.channelId || "?"}`);
247
+ } catch (_) { /* logging is best-effort */ }
248
+ }
130
249
  // Poll a watch's `check`: exit 0 → the watched condition is met, wake the
131
250
  // agent; non-zero → still idle, skip silently. Runs in the job's project
132
- // dir with the process env, output discarded (exit code is the contract),
251
+ // dir with a HARDENED env — the bot's secrets are dropped (buildWatchCheckEnv)
252
+ // and a short-lived run is registered for the job's creator so reusable
253
+ // tools attribute correctly — output discarded (exit code is the contract),
133
254
  // hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
134
255
  const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
135
256
  const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
257
+ const attest = require("./speaker-attest");
258
+ const checkRunId = uniqueId("watch-check");
259
+ let runAttest = "";
260
+ try { runAttest = attest.registerRun(checkRunId, watchCheckPrincipal(job)); } catch (_) { runAttest = ""; }
261
+ let lb = null;
262
+ try { lb = require("./loopback").info(); } catch (_) { lb = null; }
263
+ const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
264
+ if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
265
+ const env = buildWatchCheckEnv(process.env, stamp);
136
266
  let settled = false;
137
267
  const finish = (code, timedOut) => {
138
268
  if (settled) return;
139
269
  settled = true;
140
270
  try { clearTimer(timer); } catch (_) {}
271
+ try { attest.endRun(checkRunId); } catch (_) {}
141
272
  resolve({ code, timedOut: !!timedOut });
142
273
  };
143
274
  let child;
144
275
  try {
145
- child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
276
+ child = spawn("sh", ["-c", String(command)], { cwd, env, stdio: "ignore" });
146
277
  } catch (error) {
278
+ try { attest.endRun(checkRunId); } catch (_) {}
147
279
  return resolve({ code: 127, timedOut: false });
148
280
  }
149
281
  const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
@@ -288,16 +420,37 @@ function createSchedulerService(dependencies = {}) {
288
420
  return null;
289
421
  }
290
422
 
423
+ // What a repeating watch's polls saw while its delivery waited.
424
+ function pendingPollNote(job) {
425
+ const hits = Number(job.watchExtraHits) || 0;
426
+ const lastHitAt = Number(job.watchLastHitAt) || 0;
427
+ const lastClearAt = Number(job.watchLastClearAt) || 0;
428
+ if (!hits && !lastClearAt) return null;
429
+ const at = (ms) => new Date(ms).toISOString();
430
+ const seen = [];
431
+ if (hits) seen.push(`the check passed ${hits} more time${hits === 1 ? "" : "s"}, the latest at ${at(lastHitAt)}`);
432
+ seen.push(lastClearAt > lastHitAt
433
+ ? `its latest run, at ${at(lastClearAt)}, did not pass, so the condition may have cleared`
434
+ : "the condition was still true at the latest check");
435
+ return `[This watch kept polling while this delivery waited (queued at ${at(Number(job.watchTriggeredAt) || now())}): ${seen.join("; ")}.]`;
436
+ }
437
+
291
438
  function wrappedPrompt(job, target) {
292
439
  const firedAt = new Date(now()).toISOString();
293
440
  const expired = !!(job.check && job.watchExpired);
441
+ const timingOut = !!(job.check && !expired && job.watchCheckTimingOut);
294
442
  const header = job.check
295
- ? (expired ? "Watch expired" : "Watch triggered")
443
+ ? (expired ? "Watch expired" : timingOut ? "Watch check timing out" : "Watch triggered")
296
444
  : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
297
445
  const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
298
446
  if (expired) {
299
447
  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
448
  }
449
+ if (timingOut) {
450
+ 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.]`);
451
+ }
452
+ const polled = job.check ? pendingPollNote(job) : null;
453
+ if (polled) lines.push(polled);
301
454
  if (target.fallbackFrom) {
302
455
  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
456
  } else if (target.revertedFrom) {
@@ -317,14 +470,36 @@ function createSchedulerService(dependencies = {}) {
317
470
  timers.delete(id);
318
471
  }
319
472
 
473
+ // Drop only a job's delivery (retry) timer; a watch's poll schedule stays.
474
+ function clearDeliveryTimer(id) {
475
+ const timer = timers.get(id);
476
+ if (!timer?.timeout) return;
477
+ try { clearTimer(timer.timeout); } catch (_) {}
478
+ if (timer.stop) timers.set(id, { stop: timer.stop });
479
+ else timers.delete(id);
480
+ }
481
+
320
482
  function armRetry(job, delay = null) {
321
- unscheduleJob(job.id);
322
- if (job.disabled || job.status === "dead-letter" || job.status === "succeeded") return;
483
+ if (job.disabled || job.status === "dead-letter" || job.status === "succeeded") return unscheduleJob(job.id);
484
+ if (pollsWhilePending(job)) clearDeliveryTimer(job.id);
485
+ else unscheduleJob(job.id);
323
486
  const wait = delay === null
324
487
  ? Math.max(0, Number(job.nextAttemptAt || now()) - now())
325
488
  : Math.max(0, delay);
326
489
  const timeout = setTimer(() => fireJob(job.id, { reason: "retry" }), wait);
327
- timers.set(job.id, { timeout });
490
+ timers.set(job.id, { ...timers.get(job.id), timeout });
491
+ }
492
+
493
+ // A jobs-store write that throws (a full disk, a torn journal) must not
494
+ // strand a queued delivery: the retry stays armed in memory and the next
495
+ // attempt re-reads the store. Such a throw used to return with no timer,
496
+ // so the delivery was never tried again (21 and 23 Sep: watch alarms that
497
+ // triggered during long turns were never delivered).
498
+ function keepRetrying(job, error, delay = retryDelayMs) {
499
+ const failure = stableFailure(error);
500
+ try { armRetry(job, delay); } catch (_) {}
501
+ if (job.check) logWatch(job, `delivery bookkeeping failed (${failure.code}) — retrying in ${Math.round(delay / 1000)} s`);
502
+ return { ok: false, status: "failed", error: failure };
328
503
  }
329
504
 
330
505
  function markFailure(job, failure, options = {}) {
@@ -367,7 +542,7 @@ function createSchedulerService(dependencies = {}) {
367
542
  armRetry(updated, busyDelayMs);
368
543
  if (noticeDue) {
369
544
  const minutes = Math.floor(waited / 60000);
370
- const what = updated.watchExpired ? "timed out" : "triggered";
545
+ const what = updated.watchExpired ? "timed out" : updated.watchCheckTimingOut ? "raised a check-timeout alarm" : "triggered";
371
546
  try {
372
547
  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
548
  } catch (_) { /* a notice only */ }
@@ -416,6 +591,10 @@ function createSchedulerService(dependencies = {}) {
416
591
  watchTriggeredOccurrenceId: null,
417
592
  watchTriggeredAt: null,
418
593
  watchExpired: false,
594
+ watchCheckTimingOut: false,
595
+ watchExtraHits: 0,
596
+ watchLastHitAt: null,
597
+ watchLastClearAt: null,
419
598
  advisoryOccurrenceId: null,
420
599
  });
421
600
  if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
@@ -440,6 +619,10 @@ function createSchedulerService(dependencies = {}) {
440
619
  return { ok: false, status: job.status, skipped: true };
441
620
  }
442
621
  if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
622
+ // fireJob already turned away a fire in flight here, so this lease was
623
+ // left by an earlier process: look again when it lapses. Returning with
624
+ // no timer stranded the job until the next restart.
625
+ armRetry(job);
443
626
  return { ok: false, status: "running", skipped: true, inFlight: true };
444
627
  }
445
628
  if (job.status === "succeeded" && isOneShot(job)) {
@@ -469,15 +652,28 @@ function createSchedulerService(dependencies = {}) {
469
652
  // persisted before expiry existed (no expiresAt) keep polling forever.
470
653
  const expiresAt = Number(job.expiresAt || 0);
471
654
  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 };
655
+ // Timeouts in a row; a completed check resets the count. Only the
656
+ // WATCH_TIMEOUT_ALARM_AFTER-th wakes the agent, so a check that stays
657
+ // hung alarms once rather than on every later poll.
658
+ const timedOut = !!check?.timedOut;
659
+ const previous = Number(job.watchTimeoutStreak) || 0;
660
+ const streak = timedOut ? previous + 1 : 0;
661
+ if (streak !== WATCH_TIMEOUT_ALARM_AFTER) {
662
+ if (streak !== previous) {
663
+ try { jobsStore.update(job.id, { watchTimeoutStreak: streak }); } catch (_) { /* best effort */ }
664
+ }
665
+ logWatch(job, timedOut ? `idle (check timed out, ${streak} in a row)` : `idle (check exit ${check ? check.code : "?"})`);
666
+ return { ok: false, status: "watch-idle", skipped: true };
667
+ }
668
+ job = { ...job, watchExpired: false, watchCheckTimingOut: true, watchTimeoutStreak: streak };
669
+ } else {
670
+ // Expiry ends the watch's window: one-shot removal even for --repeat
671
+ // (the woken agent re-arms if still wanted). Persisted so a retry of a
672
+ // failed expiry fire keeps the same semantics across restarts.
673
+ job = { ...job, repeat: false, watchExpired: true, watchCheckTimingOut: false };
474
674
  }
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
675
  } else {
480
- job = { ...job, watchExpired: false };
676
+ job = { ...job, watchExpired: false, watchCheckTimingOut: false, watchTimeoutStreak: 0 };
481
677
  }
482
678
  // Latch the condition before admission or notification. Delivery retries
483
679
  // and restarts must not run the check again or change an expiry's meaning.
@@ -487,6 +683,8 @@ function createSchedulerService(dependencies = {}) {
487
683
  nextAttemptAt: now(),
488
684
  repeat: job.repeat,
489
685
  watchExpired: job.watchExpired,
686
+ watchCheckTimingOut: job.watchCheckTimingOut,
687
+ watchTimeoutStreak: Number(job.watchTimeoutStreak) || 0,
490
688
  watchTriggeredOccurrenceId: job.occurrenceId,
491
689
  watchTriggeredAt: now(),
492
690
  });
@@ -495,13 +693,15 @@ function createSchedulerService(dependencies = {}) {
495
693
  } catch (error) {
496
694
  return { ok: false, status: "failed", error: stableFailure(error) };
497
695
  }
498
- logWatch(job, job.watchExpired ? "expired without firing — delivery queued" : "triggered (check passed) — delivery queued");
696
+ logWatch(job, job.watchExpired ? "expired without firing — delivery queued"
697
+ : job.watchCheckTimingOut ? `check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row — delivery queued`
698
+ : "triggered (check passed) — delivery queued");
499
699
  }
500
700
 
501
701
  const adapter = resolveAdapter(job);
502
702
  if (!adapter) {
503
703
  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) }; }
704
+ catch (error) { return keepRetrying(job, error); }
505
705
  }
506
706
  const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
507
707
  const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
@@ -528,10 +728,11 @@ function createSchedulerService(dependencies = {}) {
528
728
  };
529
729
 
530
730
  return runInChat(chat, async () => {
731
+ noteLegacyCreatorOnce(job);
531
732
  const state = stateForUser(canonicalUserId);
532
733
  if (state.runningProcess || state.preparingRun || state.isCompacting) {
533
734
  try { return markBusy(job); }
534
- catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
735
+ catch (error) { return keepRetrying(job, error, busyDelayMs); }
535
736
  }
536
737
 
537
738
  const target = executionTarget(job, state);
@@ -541,7 +742,7 @@ function createSchedulerService(dependencies = {}) {
541
742
  code: "SCHEDULED_PROVIDER_UNAVAILABLE",
542
743
  message: `Saved provider ${job.provider} is unavailable and no explicit fallback is configured`,
543
744
  });
544
- } catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
745
+ } catch (error) { return keepRetrying(job, error); }
545
746
  }
546
747
 
547
748
  const project = projectDescriptor(job);
@@ -559,8 +760,14 @@ function createSchedulerService(dependencies = {}) {
559
760
  scheduledJobId: job.id,
560
761
  originRunId: job.originRunId || null,
561
762
  };
562
- // Admission is synchronous and precedes all notification delivery.
563
- const runContext = admit(prompt, project.dir, null, runOptions);
763
+ // Admission is synchronous and precedes all notification delivery. A
764
+ // refusal is a failed attempt, never a rejection that drops the job.
765
+ let runContext;
766
+ try { runContext = admit(prompt, project.dir, null, runOptions); }
767
+ catch (error) {
768
+ try { return markFailure(job, error); }
769
+ catch (persistError) { return keepRetrying(job, persistError); }
770
+ }
564
771
 
565
772
  let running;
566
773
  try {
@@ -575,11 +782,13 @@ function createSchedulerService(dependencies = {}) {
575
782
  });
576
783
  if (!running) throw new Error("scheduled attempt could not be persisted");
577
784
  } catch (error) {
578
- return { ok: false, status: "failed", error: stableFailure(error) };
785
+ return keepRetrying(job, error);
579
786
  }
580
787
 
581
788
  const advisory = job.check
582
- ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
789
+ ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway`
790
+ : job.watchCheckTimingOut ? `⚠️ Watch "${job.label}": its check timed out ${WATCH_TIMEOUT_ALARM_AFTER} times in a row`
791
+ : `🔔 Watch "${job.label}" triggered`)
583
792
  : `Wakeup: ${job.label}`;
584
793
  // Say so when the run is NOT on the provider this room is set to: an
585
794
  // unexplained error from a provider the room never selected is worse than
@@ -601,16 +810,70 @@ function createSchedulerService(dependencies = {}) {
601
810
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
602
811
  catch (error) {
603
812
  try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
604
- catch (persistError) { return { ok: false, status: "failed", error: stableFailure(persistError) }; }
813
+ catch (persistError) { return keepRetrying(running, persistError); }
605
814
  }
606
815
  if (!terminalRunSucceeded(result)) {
607
816
  try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
608
- catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
817
+ catch (error) { return { ...keepRetrying(running, error), result }; }
609
818
  }
610
819
  return settleSuccess(running, result, target);
611
820
  });
612
821
  }
613
822
 
823
+ // Poll a repeating watch whose delivery is still pending. Nothing new is
824
+ // queued: a hit is counted into that delivery (its prompt says so), a
825
+ // completed miss is noted as a possible clear, a timeout is only logged.
826
+ async function pollPendingWatch(job) {
827
+ if (!watchesEnabled()) {
828
+ logWatch(job, "skipped (watches disabled globally)");
829
+ return { ok: false, status: "watch-disabled", skipped: true };
830
+ }
831
+ if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
832
+ checksInFlight.add(job.id);
833
+ let check;
834
+ try { check = await runWatchCheck(job.check, job); }
835
+ catch (_) { check = { code: 1, timedOut: false }; }
836
+ finally { checksInFlight.delete(job.id); }
837
+ try {
838
+ const latest = jobsStore.get(job.id);
839
+ // The delivery may have finished while the check ran; the result then
840
+ // belongs to no pending occurrence.
841
+ if (!latest || latest.occurrenceId !== job.occurrenceId || !deliveryPending(latest)) {
842
+ return { ok: false, status: "watch-idle", skipped: true };
843
+ }
844
+ if (check?.timedOut) {
845
+ logWatch(latest, "idle (check timed out) — delivery still pending");
846
+ return { ok: false, status: "watch-idle", skipped: true };
847
+ }
848
+ const hit = check?.code === 0;
849
+ const extraHits = (Number(latest.watchExtraHits) || 0) + (hit ? 1 : 0);
850
+ jobsStore.update(job.id, {
851
+ ...(hit ? { watchExtraHits: extraHits, watchLastHitAt: now() } : { watchLastClearAt: now() }),
852
+ ...(latest.watchTimeoutStreak ? { watchTimeoutStreak: 0 } : {}),
853
+ });
854
+ logWatch(latest, hit
855
+ ? `triggered again (check passed) — folded into the pending delivery (${extraHits} more)`
856
+ : `idle (check exit ${check ? check.code : "?"}) — delivery still pending`);
857
+ return { ok: false, status: hit ? "watch-folded" : "watch-idle", skipped: true, extraHits };
858
+ } catch (error) {
859
+ return { ok: false, status: "failed", error: stableFailure(error) };
860
+ }
861
+ }
862
+
863
+ // A cron's schedule. A pending job fires on each tick; a repeating watch
864
+ // whose delivery is still pending is only polled.
865
+ function armCron(job) {
866
+ if (!validateCron(job.schedule)) return false;
867
+ const task = scheduleCron(job.schedule, () => {
868
+ const current = jobsStore.get(job.id);
869
+ if (!current || current.disabled) return;
870
+ if (current.status === "pending") fireJob(current.id, { reason: "cron" });
871
+ else if (deliveryPending(current)) pollPendingWatch(current);
872
+ });
873
+ timers.set(job.id, { stop: () => task.stop?.() });
874
+ return true;
875
+ }
876
+
614
877
  function scheduleJob(job) {
615
878
  unscheduleJob(job.id);
616
879
  if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
@@ -619,6 +882,7 @@ function createSchedulerService(dependencies = {}) {
619
882
  return false;
620
883
  }
621
884
  if (["failed", "deferred", "running"].includes(job.status)) {
885
+ if (pollsWhilePending(job)) armCron(job);
622
886
  armRetry(job, Math.max(0, Number(job.nextAttemptAt || now()) - now()));
623
887
  return true;
624
888
  }
@@ -636,20 +900,20 @@ function createSchedulerService(dependencies = {}) {
636
900
  } catch (_) {}
637
901
  return false;
638
902
  }
639
- const timeout = setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), Math.max(0, fireAt - now()));
903
+ const wait = Math.max(0, fireAt - now());
904
+ // Too far out for one timer: sleep a chunk, then re-plan from the store.
905
+ const timeout = wait > MAX_TIMER_DELAY_MS
906
+ ? setTimer(() => {
907
+ try {
908
+ const current = jobsStore.get(job.id);
909
+ if (current) scheduleJob(current);
910
+ } catch (_) { /* init() re-plans on the next boot */ }
911
+ }, MAX_TIMER_DELAY_MS)
912
+ : setTimer(() => fireJob(job.id, { reason: fireAt < now() ? "missed" : "timer" }), wait);
640
913
  timers.set(job.id, { timeout });
641
914
  return true;
642
915
  }
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
- }
916
+ if (job.kind === "cron") return armCron(job);
653
917
  return false;
654
918
  }
655
919
 
@@ -809,9 +1073,15 @@ module.exports = {
809
1073
  RETRY_DELAY_MS,
810
1074
  BUSY_DELAY_MS,
811
1075
  DEFAULT_WATCH_TIMEOUT_MS,
1076
+ WATCH_TIMEOUT_ALARM_AFTER,
1077
+ MAX_TIMER_DELAY_MS,
1078
+ MAX_WAKEUP_HORIZON_MS,
812
1079
  createSchedulerService,
813
1080
  parseFallbacks,
814
1081
  terminalRunSucceeded,
1082
+ buildWatchCheckEnv,
1083
+ isSecretCheckEnvKey,
1084
+ watchCheckPrincipal,
815
1085
  setAdapters,
816
1086
  initScheduler,
817
1087
  scheduleJob: service.scheduleJob,
@@ -53,4 +53,85 @@ function verify(canonicalUserId, attest) {
53
53
  try { return crypto.timingSafeEqual(a, b); } catch (e) { return false; }
54
54
  }
55
55
 
56
- module.exports = { mint, verify };
56
+ // ── Run attestation ─────────────────────────────────────────────────────────
57
+ // The id attest above says "the runner stamped this canonical id". It is not
58
+ // bound to a run: a copy lifted from another process's environment stays valid
59
+ // for the whole boot. And the CLIs that decided the speaker locally (tool,
60
+ // keyring, entity) read OC_CHANNEL_* from their own env, so a shell that
61
+ // simply unset or rewrote those variables was treated as the owner.
62
+ //
63
+ // A run attestation binds a LIVE run to the principal it acts for, decided in
64
+ // the bot process: the runner registers every agent run it spawns
65
+ // (runId -> principal) and stamps OC_RUN_ID + OC_RUN_ATTEST into the run's
66
+ // env; the loopback answers "who is this?" only for a registered, still-live
67
+ // run whose attest matches. Anything else (no attest, a wrong one, a run that
68
+ // has ended, a detached process that outlived its turn) is UNATTRIBUTED, and
69
+ // unattributed never carries owner powers.
70
+ const RUN_TTL_MS = 12 * 60 * 60 * 1000;
71
+ const runs = new Map();
72
+
73
+ function runSignature(runId) {
74
+ return crypto.createHmac("sha256", secret()).update(`run\0${runId}`).digest("hex");
75
+ }
76
+
77
+ function principalOf(principal = {}) {
78
+ // Bot-internal work with no speaker (compaction, owner jobs) acts for the
79
+ // owner; a run is guarded exactly when it acts for someone who is not.
80
+ const owner = principal.owner === true;
81
+ const text = (value) => (value == null || value === "" ? null : String(value));
82
+ return {
83
+ kind: text(principal.kind) || "turn",
84
+ owner,
85
+ guarded: !owner,
86
+ canonicalUserId: text(principal.canonicalUserId),
87
+ name: text(principal.name),
88
+ adapterId: text(principal.adapterId),
89
+ adapterType: text(principal.adapterType),
90
+ channelId: text(principal.channelId),
91
+ userId: text(principal.userId),
92
+ room: text(principal.room),
93
+ purpose: text(principal.purpose),
94
+ };
95
+ }
96
+
97
+ function pruneRuns(now = Date.now()) {
98
+ for (const [id, record] of runs) {
99
+ if (now - record.registeredAt > RUN_TTL_MS) runs.delete(id);
100
+ }
101
+ }
102
+
103
+ // Returns the attest to stamp as OC_RUN_ATTEST. Re-registering a run (a retry
104
+ // of the same runId) replaces its principal.
105
+ function registerRun(runId, principal = {}) {
106
+ const id = String(runId || "");
107
+ if (!id) return "";
108
+ pruneRuns();
109
+ runs.set(id, { ...principalOf(principal), runId: id, registeredAt: Date.now() });
110
+ return runSignature(id);
111
+ }
112
+
113
+ function endRun(runId) {
114
+ runs.delete(String(runId || ""));
115
+ }
116
+
117
+ // The principal of a live run whose attest matches, or null (fail closed).
118
+ function verifyRun(runId, attest) {
119
+ const id = String(runId || "");
120
+ const sig = String(attest || "");
121
+ if (!id || !sig) return null;
122
+ const record = runs.get(id);
123
+ if (!record) return null;
124
+ if (Date.now() - record.registeredAt > RUN_TTL_MS) { runs.delete(id); return null; }
125
+ const a = Buffer.from(sig, "utf-8");
126
+ const b = Buffer.from(runSignature(id), "utf-8");
127
+ if (a.length !== b.length) return null;
128
+ try { if (!crypto.timingSafeEqual(a, b)) return null; } catch (e) { return null; }
129
+ return { ...record };
130
+ }
131
+
132
+ function liveRuns() {
133
+ pruneRuns();
134
+ return Array.from(runs.values(), (record) => ({ ...record }));
135
+ }
136
+
137
+ module.exports = { mint, verify, registerRun, endRun, verifyRun, liveRuns, principalOf };