@inetafrica/open-claudia 3.4.22 → 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/core/scheduler.js CHANGED
@@ -41,8 +41,25 @@ const { redactSensitive } = require("./redact");
41
41
  const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
42
42
  const RETRY_DELAY_MS = 30 * 1000;
43
43
  const BUSY_DELAY_MS = 30 * 1000;
44
+ // A watch alarm that has waited this long behind a busy room says so in the
45
+ // chat, once per occurrence, so a busy room never hides an alarm for hours
46
+ // (24 Sep: the Phone alarm waited 2 h through 244 deferrals, the event-bus
47
+ // alarm 7.5 h through 902).
48
+ const BUSY_NOTICE_AFTER_MS = 15 * 60 * 1000;
44
49
  // A watch's `check` command is capped so a hung poll can't wedge the tick.
45
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;
46
63
  // Every watch carries a hard expiry so none can idle-poll forever: when the
47
64
  // deadline lapses with the check still failing, the next poll tick fires the
48
65
  // wake anyway (marked expired) instead of skipping silently.
@@ -70,9 +87,25 @@ function terminalRunSucceeded(result) {
70
87
 
71
88
  // A one-shot fires exactly once then is removed after a successful run: a
72
89
  // wakeup, or a watch (a cron carrying a shell `check`) created without repeat
73
- // 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.
74
92
  function isOneShot(job) {
75
- 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);
76
109
  }
77
110
 
78
111
  function providerIsRunnable(status) {
@@ -110,6 +143,7 @@ function createSchedulerService(dependencies = {}) {
110
143
  );
111
144
  const retryDelayMs = Number.isFinite(dependencies.retryDelayMs) ? dependencies.retryDelayMs : RETRY_DELAY_MS;
112
145
  const busyDelayMs = Number.isFinite(dependencies.busyDelayMs) ? dependencies.busyDelayMs : BUSY_DELAY_MS;
146
+ const busyNoticeAfterMs = Number.isFinite(dependencies.busyNoticeAfterMs) ? dependencies.busyNoticeAfterMs : BUSY_NOTICE_AFTER_MS;
113
147
  const missedGraceMs = Number.isFinite(dependencies.missedGraceMs) ? dependencies.missedGraceMs : MISSED_WAKEUP_GRACE_MS;
114
148
  const watchTimeoutMs = Number.isFinite(dependencies.watchTimeoutMs) ? dependencies.watchTimeoutMs : DEFAULT_WATCH_TIMEOUT_MS;
115
149
  const occurrenceIdFactory = dependencies.occurrenceIdFactory || (() => uniqueId("occ"));
@@ -282,16 +316,37 @@ function createSchedulerService(dependencies = {}) {
282
316
  return null;
283
317
  }
284
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
+
285
334
  function wrappedPrompt(job, target) {
286
335
  const firedAt = new Date(now()).toISOString();
287
336
  const expired = !!(job.check && job.watchExpired);
337
+ const timingOut = !!(job.check && !expired && job.watchCheckTimingOut);
288
338
  const header = job.check
289
- ? (expired ? "Watch expired" : "Watch triggered")
339
+ ? (expired ? "Watch expired" : timingOut ? "Watch check timing out" : "Watch triggered")
290
340
  : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
291
341
  const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
292
342
  if (expired) {
293
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.]");
294
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);
295
350
  if (target.fallbackFrom) {
296
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.]`);
297
352
  } else if (target.revertedFrom) {
@@ -311,14 +366,36 @@ function createSchedulerService(dependencies = {}) {
311
366
  timers.delete(id);
312
367
  }
313
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
+
314
378
  function armRetry(job, delay = null) {
315
- unscheduleJob(job.id);
316
- 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);
317
382
  const wait = delay === null
318
383
  ? Math.max(0, Number(job.nextAttemptAt || now()) - now())
319
384
  : Math.max(0, delay);
320
385
  const timeout = setTimer(() => fireJob(job.id, { reason: "retry" }), wait);
321
- 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 };
322
399
  }
323
400
 
324
401
  function markFailure(job, failure, options = {}) {
@@ -345,14 +422,27 @@ function createSchedulerService(dependencies = {}) {
345
422
  function markBusy(job) {
346
423
  const latest = jobsStore.get(job.id) || job;
347
424
  const busyDeferralCount = (latest.busyDeferralCount || 0) + 1;
425
+ const waited = now() - Number(latest.watchTriggeredAt);
426
+ const noticeDue = !!latest.check
427
+ && latest.busyNoticeOccurrenceId !== latest.occurrenceId
428
+ && Number.isFinite(waited) && waited >= busyNoticeAfterMs;
348
429
  const patch = {
349
430
  status: "deferred",
350
431
  disabled: false,
351
432
  busyDeferralCount,
352
433
  nextAttemptAt: now() + busyDelayMs,
434
+ // At most once per occurrence: the marker is persisted with the deferral, before the send.
435
+ ...(noticeDue ? { busyNoticeOccurrenceId: latest.occurrenceId } : {}),
353
436
  };
354
437
  const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
355
438
  armRetry(updated, busyDelayMs);
439
+ if (noticeDue) {
440
+ const minutes = Math.floor(waited / 60000);
441
+ const what = updated.watchExpired ? "timed out" : updated.watchCheckTimingOut ? "raised a check-timeout alarm" : "triggered";
442
+ try {
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(() => {});
444
+ } catch (_) { /* a notice only */ }
445
+ }
356
446
  return { ok: false, deferred: true, status: updated.status, job: updated };
357
447
  }
358
448
 
@@ -397,6 +487,10 @@ function createSchedulerService(dependencies = {}) {
397
487
  watchTriggeredOccurrenceId: null,
398
488
  watchTriggeredAt: null,
399
489
  watchExpired: false,
490
+ watchCheckTimingOut: false,
491
+ watchExtraHits: 0,
492
+ watchLastHitAt: null,
493
+ watchLastClearAt: null,
400
494
  advisoryOccurrenceId: null,
401
495
  });
402
496
  if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
@@ -421,6 +515,10 @@ function createSchedulerService(dependencies = {}) {
421
515
  return { ok: false, status: job.status, skipped: true };
422
516
  }
423
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);
424
522
  return { ok: false, status: "running", skipped: true, inFlight: true };
425
523
  }
426
524
  if (job.status === "succeeded" && isOneShot(job)) {
@@ -450,15 +548,28 @@ function createSchedulerService(dependencies = {}) {
450
548
  // persisted before expiry existed (no expiresAt) keep polling forever.
451
549
  const expiresAt = Number(job.expiresAt || 0);
452
550
  if (!(expiresAt > 0 && now() >= expiresAt)) {
453
- logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
454
- 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 };
455
570
  }
456
- // Expiry ends the watch's window: one-shot removal even for --repeat
457
- // (the woken agent re-arms if still wanted). Persisted so a retry of a
458
- // failed expiry fire keeps the same semantics across restarts.
459
- job = { ...job, repeat: false, watchExpired: true };
460
571
  } else {
461
- job = { ...job, watchExpired: false };
572
+ job = { ...job, watchExpired: false, watchCheckTimingOut: false, watchTimeoutStreak: 0 };
462
573
  }
463
574
  // Latch the condition before admission or notification. Delivery retries
464
575
  // and restarts must not run the check again or change an expiry's meaning.
@@ -468,6 +579,8 @@ function createSchedulerService(dependencies = {}) {
468
579
  nextAttemptAt: now(),
469
580
  repeat: job.repeat,
470
581
  watchExpired: job.watchExpired,
582
+ watchCheckTimingOut: job.watchCheckTimingOut,
583
+ watchTimeoutStreak: Number(job.watchTimeoutStreak) || 0,
471
584
  watchTriggeredOccurrenceId: job.occurrenceId,
472
585
  watchTriggeredAt: now(),
473
586
  });
@@ -476,13 +589,15 @@ function createSchedulerService(dependencies = {}) {
476
589
  } catch (error) {
477
590
  return { ok: false, status: "failed", error: stableFailure(error) };
478
591
  }
479
- 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");
480
595
  }
481
596
 
482
597
  const adapter = resolveAdapter(job);
483
598
  if (!adapter) {
484
599
  try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
485
- catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
600
+ catch (error) { return keepRetrying(job, error); }
486
601
  }
487
602
  const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
488
603
  const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
@@ -512,7 +627,7 @@ function createSchedulerService(dependencies = {}) {
512
627
  const state = stateForUser(canonicalUserId);
513
628
  if (state.runningProcess || state.preparingRun || state.isCompacting) {
514
629
  try { return markBusy(job); }
515
- catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
630
+ catch (error) { return keepRetrying(job, error, busyDelayMs); }
516
631
  }
517
632
 
518
633
  const target = executionTarget(job, state);
@@ -522,7 +637,7 @@ function createSchedulerService(dependencies = {}) {
522
637
  code: "SCHEDULED_PROVIDER_UNAVAILABLE",
523
638
  message: `Saved provider ${job.provider} is unavailable and no explicit fallback is configured`,
524
639
  });
525
- } catch (error) { return { ok: false, status: "failed", error: stableFailure(error) }; }
640
+ } catch (error) { return keepRetrying(job, error); }
526
641
  }
527
642
 
528
643
  const project = projectDescriptor(job);
@@ -540,8 +655,14 @@ function createSchedulerService(dependencies = {}) {
540
655
  scheduledJobId: job.id,
541
656
  originRunId: job.originRunId || null,
542
657
  };
543
- // Admission is synchronous and precedes all notification delivery.
544
- 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
+ }
545
666
 
546
667
  let running;
547
668
  try {
@@ -556,11 +677,13 @@ function createSchedulerService(dependencies = {}) {
556
677
  });
557
678
  if (!running) throw new Error("scheduled attempt could not be persisted");
558
679
  } catch (error) {
559
- return { ok: false, status: "failed", error: stableFailure(error) };
680
+ return keepRetrying(job, error);
560
681
  }
561
682
 
562
683
  const advisory = job.check
563
- ? (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`)
564
687
  : `Wakeup: ${job.label}`;
565
688
  // Say so when the run is NOT on the provider this room is set to: an
566
689
  // unexplained error from a provider the room never selected is worse than
@@ -582,16 +705,70 @@ function createSchedulerService(dependencies = {}) {
582
705
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
583
706
  catch (error) {
584
707
  try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
585
- catch (persistError) { return { ok: false, status: "failed", error: stableFailure(persistError) }; }
708
+ catch (persistError) { return keepRetrying(running, persistError); }
586
709
  }
587
710
  if (!terminalRunSucceeded(result)) {
588
711
  try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
589
- catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
712
+ catch (error) { return { ...keepRetrying(running, error), result }; }
590
713
  }
591
714
  return settleSuccess(running, result, target);
592
715
  });
593
716
  }
594
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
+
595
772
  function scheduleJob(job) {
596
773
  unscheduleJob(job.id);
597
774
  if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
@@ -600,6 +777,7 @@ function createSchedulerService(dependencies = {}) {
600
777
  return false;
601
778
  }
602
779
  if (["failed", "deferred", "running"].includes(job.status)) {
780
+ if (pollsWhilePending(job)) armCron(job);
603
781
  armRetry(job, Math.max(0, Number(job.nextAttemptAt || now()) - now()));
604
782
  return true;
605
783
  }
@@ -617,20 +795,20 @@ function createSchedulerService(dependencies = {}) {
617
795
  } catch (_) {}
618
796
  return false;
619
797
  }
620
- 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);
621
808
  timers.set(job.id, { timeout });
622
809
  return true;
623
810
  }
624
- if (job.kind === "cron") {
625
- if (!validateCron(job.schedule)) return false;
626
- const task = scheduleCron(job.schedule, () => {
627
- const current = jobsStore.get(job.id);
628
- if (!current || current.disabled || current.status !== "pending") return;
629
- fireJob(current.id, { reason: "cron" });
630
- });
631
- timers.set(job.id, { stop: () => task.stop?.() });
632
- return true;
633
- }
811
+ if (job.kind === "cron") return armCron(job);
634
812
  return false;
635
813
  }
636
814
 
@@ -790,6 +968,9 @@ module.exports = {
790
968
  RETRY_DELAY_MS,
791
969
  BUSY_DELAY_MS,
792
970
  DEFAULT_WATCH_TIMEOUT_MS,
971
+ WATCH_TIMEOUT_ALARM_AFTER,
972
+ MAX_TIMER_DELAY_MS,
973
+ MAX_WAKEUP_HORIZON_MS,
793
974
  createSchedulerService,
794
975
  parseFallbacks,
795
976
  terminalRunSucceeded,
package/core/state.js CHANGED
@@ -796,6 +796,16 @@ function acquireRunLock(state = currentState()) {
796
796
  return true;
797
797
  }
798
798
 
799
+ // /stop arms state.cancelRequested with the id of the run it stops (true only
800
+ // when no run context is known), and a run acts on the flag only when it names
801
+ // that run. A bare boolean let a stop outlive its run and cancel the next one.
802
+ function cancelFlagFor(state = currentState()) {
803
+ return state.activeRunContext?.runId || true;
804
+ }
805
+ function cancelTargets(flag, runContext) {
806
+ return flag === true || (!!flag && flag === runContext?.runId);
807
+ }
808
+
799
809
  // True when ANY user has a turn running, admitted-and-preparing, compacting, or
800
810
  // waiting in its queue. Same busy predicate as acquireRunLock, but across every
801
811
  // user state rather than one — the Telegram poll-wedge backstop consults this
@@ -1535,6 +1545,8 @@ module.exports = {
1535
1545
  resetSessionUsage,
1536
1546
  resetSettings,
1537
1547
  acquireRunLock,
1548
+ cancelFlagFor,
1549
+ cancelTargets,
1538
1550
  anyTurnActive,
1539
1551
  saveState,
1540
1552
  loadSessions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.22",
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": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-upgrade-path.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-context-media.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
14
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
14
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stream-preview-purpose.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -289,6 +289,98 @@ async function codexRestrictionProbe() {
289
289
  assert.deepStrictEqual(claudeInvocation.args.slice(claudeInvocation.args.indexOf("--permission-mode"), claudeInvocation.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
290
290
  }
291
291
 
292
+ // 21 Sep (task 76ecr7): a watch passed while a long turn ran, the turn ended in
293
+ // a compaction, and the alarm looked lost. The queued delivery waits out the
294
+ // turn and the compaction, then runs once at the next idle moment on the
295
+ // compacted session; after a later session switch it still runs, on its own
296
+ // branch, without taking over the live session.
297
+ async function deferredWatchAcrossCompactionProbe() {
298
+ const { createSchedulerService } = require("./core/scheduler");
299
+ for (const switchAfter of [false, true]) {
300
+ resetFixture("claude");
301
+ const records = new Map([["watch-long-turn", {
302
+ id: "watch-long-turn",
303
+ kind: "cron",
304
+ schedule: "* * * * *",
305
+ check: "fixture-check",
306
+ repeat: false,
307
+ expiresAt: Date.now() + 60 * 60 * 1000,
308
+ adapter: "telegram-fixture",
309
+ adapterType: "telegram",
310
+ channelId: "fixture",
311
+ canonicalUserId: state.userId,
312
+ provider: "claude",
313
+ providerSettings: {},
314
+ project: "alpha",
315
+ projectDir: alphaDir,
316
+ sessionId: "old-claude",
317
+ prompt: "the SDK tag pipeline finished",
318
+ label: "SDK pipeline",
319
+ occurrenceId: "occ-long-turn",
320
+ status: "pending",
321
+ attemptCount: 0,
322
+ maxAttempts: 3,
323
+ disabled: false,
324
+ }]]);
325
+ const jobsStore = {
326
+ get: (id) => (records.has(id) ? structuredClone(records.get(id)) : null),
327
+ update(id, patch) {
328
+ if (!records.has(id)) return null;
329
+ records.set(id, { ...records.get(id), ...structuredClone(patch) });
330
+ return structuredClone(records.get(id));
331
+ },
332
+ remove(id) { const value = records.get(id) || null; records.delete(id); return value; },
333
+ listAll: () => [...records.values()].map((job) => structuredClone(job)),
334
+ };
335
+ const timers = [];
336
+ const runs = [];
337
+ const scheduler = createSchedulerService({
338
+ jobsStore,
339
+ adapters: [{ id: "telegram-fixture", type: "telegram" }],
340
+ workspace,
341
+ getUserState: () => state,
342
+ runInChat: async (_chat, fn) => fn(),
343
+ send: async () => ({ ok: true }),
344
+ setTimer: (fn, delay) => { timers.push({ fn, delay }); return timers.length; },
345
+ clearTimer: () => {},
346
+ scheduleCron: () => ({ stop() {} }),
347
+ providerStatus: () => ({ availability: { state: "available" }, auth: { state: "authenticated" } }),
348
+ fallbackProviders: [],
349
+ runWatchCheck: async () => ({ code: 0 }),
350
+ admitRunContext: (_prompt, cwd, _replyTo, opts) => ({ cwd, ...opts }),
351
+ runAgent: async (_prompt, _cwd, _replyTo, opts) => {
352
+ runs.push(opts);
353
+ return { ok: true, status: "succeeded", persistence: { transcript: { ok: true } }, delivery: { ok: true } };
354
+ },
355
+ });
356
+
357
+ state.runningProcess = { pid: 4242 };
358
+ assert.strictEqual((await scheduler.fireJob("watch-long-turn")).deferred, true, "a watch that passes mid-turn is queued");
359
+ for (let i = 0; i < 3; i++) await timers.at(-1).fn();
360
+ state.runningProcess = null;
361
+ let calls = 0;
362
+ const compaction = serviceWith(async () => {
363
+ calls += 1;
364
+ if (calls === 1) {
365
+ assert.strictEqual(state.isCompacting, true);
366
+ assert.strictEqual((await timers.at(-1).fn()).deferred, true, "the delivery waits out the compaction");
367
+ return result({ sessionId: "old-claude" });
368
+ }
369
+ return result({ sessionId: "compacted-claude" });
370
+ });
371
+ assert.strictEqual((await compaction.compact(alphaDir, { operationId: `compact-watch-${switchAfter}` })).compacted, true);
372
+ assert.strictEqual(runs.length, 0);
373
+ assert.ok(records.get("watch-long-turn"), "the queued delivery survives the compaction");
374
+ if (switchAfter) stateApi.setProviderSession(state, "claude", "alpha", "fresh-claude-after-new");
375
+
376
+ assert.strictEqual((await timers.at(-1).fn()).ok, true, "the next idle moment delivers it");
377
+ assert.strictEqual(runs.length, 1, "exactly once");
378
+ assert.strictEqual(runs[0].resumeSessionId, "compacted-claude", "it resumes the compacted session, not the pre-compaction one");
379
+ assert.strictEqual(runs[0].attachSession, !switchAfter, "it never takes over a session the user switched to");
380
+ assert.strictEqual(records.has("watch-long-turn"), false, "a delivered one-shot watch is consumed, not left listed");
381
+ }
382
+ }
383
+
292
384
  function duplicateCommitProbe() {
293
385
  resetFixture("claude");
294
386
  const transaction = {
@@ -364,6 +456,7 @@ function restartRecoveryProbe() {
364
456
  await immutableSuccessProbe();
365
457
  await failureProbe();
366
458
  await codexRestrictionProbe();
459
+ await deferredWatchAcrossCompactionProbe();
367
460
  duplicateCommitProbe();
368
461
  restartRecoveryProbe();
369
462
  console.log("provider compaction OK");