@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.
@@ -6,6 +6,7 @@ const assert = require("assert");
6
6
  const fs = require("fs");
7
7
  const os = require("os");
8
8
  const path = require("path");
9
+ const { spawnSync } = require("child_process");
9
10
 
10
11
  const root = __dirname;
11
12
  const fixturesRoot = path.join(root, "test-fixtures", "migrations");
@@ -373,6 +374,7 @@ function baseJob(overrides = {}) {
373
374
  function schedulerHarness(createSchedulerService, seed, options = {}) {
374
375
  const jobsStore = options.jobsStore || memoryJobsStore(seed);
375
376
  const scheduled = [];
377
+ const crons = [];
376
378
  const admissions = [];
377
379
  const runCalls = [];
378
380
  const sends = [];
@@ -401,12 +403,24 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
401
403
  workspace: "/workspace",
402
404
  now: options.now || (() => 1_720_000_000_500),
403
405
  occurrenceIdFactory: () => `next-occ-${++occurrenceSequence}`,
404
- setTimer(fn, delay) { scheduled.push({ fn, delay }); return { fn, delay }; },
406
+ setTimer(fn, delay) {
407
+ // Node's setTimeout fires at once past this; no path may ask for more.
408
+ assert.ok(delay <= 2 ** 31 - 1, `timer delay ${delay} overflows setTimeout`);
409
+ scheduled.push({ fn, delay });
410
+ return { fn, delay };
411
+ },
405
412
  clearTimer() {},
413
+ // Cron ticks are driven by hand: callback() is one tick of the schedule.
414
+ scheduleCron(expression, callback) {
415
+ const task = { expression, callback, stopped: false, stop() { task.stopped = true; } };
416
+ crons.push(task);
417
+ return task;
418
+ },
406
419
  runInChat: async (_ctx, fn) => fn(),
407
420
  getUserState: () => state,
408
421
  send: async (text) => { sends.push(text); if (options.onSend) await options.onSend(state); return { ok: true }; },
409
422
  admitRunContext(prompt, cwd, _replyTo, opts) {
423
+ if (options.admitError) throw options.admitError;
410
424
  const context = Object.freeze({
411
425
  runId: `scheduled-${admissions.length + 1}`,
412
426
  provider: opts.provider,
@@ -425,6 +439,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
425
439
  async runAgent(prompt, cwd, _replyTo, opts) {
426
440
  runCalls.push({ prompt, cwd, opts });
427
441
  const next = outcomes.shift();
442
+ if (next instanceof Error) throw next;
428
443
  return next && typeof next.then === "function" ? next : next;
429
444
  },
430
445
  providerStatus: options.providerStatus || (() => ({ availability: { state: "available" }, auth: { state: "authenticated" } })),
@@ -434,7 +449,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
434
449
  missedGraceMs: 60 * 60 * 1000,
435
450
  ...(options.runWatchCheck ? { runWatchCheck: options.runWatchCheck } : {}),
436
451
  });
437
- return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
452
+ return { service, jobsStore, scheduled, crons, admissions, runCalls, sends, state };
438
453
  }
439
454
 
440
455
  async function firetimeSelectionProbe(createSchedulerService) {
@@ -670,6 +685,472 @@ async function busyWatchNoticeProbe(createSchedulerService) {
670
685
  assert.strictEqual(w.sends.length, 0);
671
686
  }
672
687
 
688
+ function succeededRun() {
689
+ return {
690
+ ok: true,
691
+ status: "succeeded",
692
+ persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
693
+ delivery: { ok: true },
694
+ };
695
+ }
696
+
697
+ // One tick of a hand-driven cron, then let its fire or poll settle (the fakes
698
+ // all resolve in microtasks).
699
+ async function tick(task) {
700
+ task.callback();
701
+ for (let i = 0; i < 3; i++) await nextTurn();
702
+ }
703
+
704
+ function repeatingWatch(overrides = {}) {
705
+ return baseJob({
706
+ kind: "cron",
707
+ schedule: "*/15 * * * *",
708
+ fireAt: undefined,
709
+ check: "fixture-check",
710
+ repeat: true,
711
+ label: "EVENT BUS ALARM",
712
+ expiresAt: 1_720_000_000_000 + 30 * 24 * 60 * 60 * 1000,
713
+ ...overrides,
714
+ });
715
+ }
716
+
717
+ async function pendingDeliveryPollingProbe(createSchedulerService) {
718
+ // 24 Sep: the event-bus watch triggered at 04:15, a busy room deferred the
719
+ // delivery until 11:50, and the bus went unpolled the whole time.
720
+ const t0 = 1_720_000_000_000;
721
+ const at = (minutes) => t0 + minutes * 60_000;
722
+ const iso = (ms) => new Date(ms).toISOString();
723
+ let clock = t0;
724
+ let code = 0;
725
+ let checks = 0;
726
+ const h = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus", occurrenceId: "occ-bus" })], {
727
+ now: () => clock,
728
+ runWatchCheck: async () => { checks++; return { code, timedOut: code === 124 }; },
729
+ outcomes: [succeededRun()],
730
+ });
731
+ h.service.init();
732
+ assert.strictEqual(h.crons.length, 1);
733
+ const poll = h.crons[0];
734
+ h.state.runningProcess = { pid: 1 };
735
+ await tick(poll);
736
+ assert.strictEqual(h.jobsStore.get("bus").status, "deferred");
737
+ assert.strictEqual(poll.stopped, false, "a repeating watch keeps its poll schedule while the delivery waits");
738
+ assert.strictEqual(h.scheduled.length, 1, "the delivery waits on its own retry timer");
739
+
740
+ clock = at(15);
741
+ await tick(poll);
742
+ assert.strictEqual(checks, 2, "the watch polls while its delivery is pending");
743
+ let bus = h.jobsStore.get("bus");
744
+ assert.strictEqual(bus.watchExtraHits, 1, "a new hit folds into the pending delivery");
745
+ assert.strictEqual(bus.watchLastHitAt, at(15));
746
+ assert.strictEqual(bus.occurrenceId, "occ-bus");
747
+ assert.strictEqual(bus.watchTriggeredAt, t0, "the trigger time stays the first hit's");
748
+ assert.strictEqual(h.scheduled.length, 1, "no second delivery is queued");
749
+
750
+ clock = at(16);
751
+ await h.scheduled.at(-1).fn();
752
+ assert.deepStrictEqual(h.sends, ['⏳ Watch "EVENT BUS ALARM" triggered 16 min ago and is waiting for the current run to end. /stop ends that run so the watch can go.'],
753
+ "the 15-minute heads-up still goes out, counted from the first hit");
754
+ code = 1;
755
+ clock = at(30);
756
+ await tick(poll);
757
+ code = 124;
758
+ clock = at(40);
759
+ await tick(poll);
760
+ assert.strictEqual(h.jobsStore.get("bus").watchLastClearAt, at(30), "a timed-out poll is not a clear");
761
+ code = 0;
762
+ clock = at(45);
763
+ await tick(poll);
764
+ bus = h.jobsStore.get("bus");
765
+ assert.strictEqual(bus.watchExtraHits, 2);
766
+ assert.strictEqual(bus.watchLastClearAt, at(30));
767
+ assert.strictEqual(h.runCalls.length, 0);
768
+
769
+ h.state.runningProcess = null;
770
+ clock = at(46);
771
+ assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true);
772
+ assert.strictEqual(h.runCalls.length, 1, "one delivery for the whole wait");
773
+ const prompt = h.runCalls[0].prompt;
774
+ assert.match(prompt, /^\[Watch triggered "EVENT BUS ALARM"/);
775
+ assert.ok(prompt.includes(`[This watch kept polling while this delivery waited (queued at ${iso(t0)}): the check passed 2 more times, the latest at ${iso(at(45))}; the condition was still true at the latest check.]`), prompt);
776
+ assert.strictEqual(h.sends.length, 2, "heads-up once, then the advisory");
777
+ bus = h.jobsStore.get("bus");
778
+ assert.strictEqual(bus.status, "pending");
779
+ assert.strictEqual(bus.watchExtraHits, 0, "the next occurrence starts its own count");
780
+ assert.strictEqual(bus.watchLastHitAt, null);
781
+ assert.strictEqual(bus.watchLastClearAt, null);
782
+ assert.strictEqual(h.crons.filter((task) => !task.stopped).length, 1, "one live poll schedule after the delivery");
783
+
784
+ // Third case: attempt 1 died with exit 143 after a /stop and polling never
785
+ // resumed. The watch keeps polling and the delivery is retried.
786
+ clock = t0;
787
+ code = 0;
788
+ const killed = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus2", occurrenceId: "occ-bus2" })], {
789
+ now: () => clock,
790
+ runWatchCheck: async () => ({ code }),
791
+ outcomes: [
792
+ { ok: false, status: "failed", exitCode: 143, error: { code: "PROVIDER_EXIT_NONZERO", message: "Provider exited with code 143" }, delivery: { ok: true } },
793
+ succeededRun(),
794
+ ],
795
+ });
796
+ killed.service.init();
797
+ const killedPoll = killed.crons[0];
798
+ await tick(killedPoll);
799
+ assert.strictEqual(killed.runCalls.length, 1);
800
+ assert.strictEqual(killed.jobsStore.get("bus2").status, "failed");
801
+ assert.strictEqual(killedPoll.stopped, false, "a killed delivery does not stop the polling");
802
+ assert.strictEqual(killed.scheduled.length, 1, "the pending delivery is retried");
803
+ code = 1;
804
+ clock = at(15);
805
+ await tick(killedPoll);
806
+ assert.strictEqual(killed.jobsStore.get("bus2").watchLastClearAt, at(15));
807
+ clock = at(16);
808
+ assert.strictEqual((await killed.scheduled.at(-1).fn()).ok, true);
809
+ assert.strictEqual(killed.runCalls.length, 2);
810
+ assert.ok(killed.runCalls[1].prompt.includes(`its latest run, at ${iso(at(15))}, did not pass, so the condition may have cleared`), killed.runCalls[1].prompt);
811
+
812
+ // The watch also polls while its delivery runs; a hit then reaches the retry
813
+ // of a run that did not finish.
814
+ clock = t0;
815
+ const inRun = deferred();
816
+ const during = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "bus3", occurrenceId: "occ-bus3" })], {
817
+ now: () => clock,
818
+ runWatchCheck: async () => ({ code: 0 }),
819
+ outcomes: [inRun.promise, succeededRun()],
820
+ });
821
+ during.service.init();
822
+ during.crons[0].callback();
823
+ await nextTurn();
824
+ assert.strictEqual(during.jobsStore.get("bus3").status, "running");
825
+ clock = at(15);
826
+ await tick(during.crons[0]);
827
+ assert.strictEqual(during.jobsStore.get("bus3").watchExtraHits, 1, "the watch polls while its delivery runs");
828
+ inRun.resolve({ ok: false, status: "cancelled", error: { code: "RUN_CANCELLED", message: "stopped" }, delivery: { ok: true } });
829
+ for (let i = 0; i < 3; i++) await nextTurn();
830
+ assert.strictEqual(during.jobsStore.get("bus3").status, "failed");
831
+ clock = at(16);
832
+ assert.strictEqual((await during.scheduled.at(-1).fn()).ok, true);
833
+ assert.ok(during.runCalls[1].prompt.includes(`the check passed 1 more time, the latest at ${iso(at(15))}`), during.runCalls[1].prompt);
834
+
835
+ // After a restart, a watch whose delivery is still queued polls again too.
836
+ clock = t0;
837
+ const bootStore = memoryJobsStore([repeatingWatch({
838
+ id: "bus4", occurrenceId: "occ-bus4", status: "deferred", nextAttemptAt: t0 + 30_000,
839
+ watchTriggeredOccurrenceId: "occ-bus4", watchTriggeredAt: t0,
840
+ })]);
841
+ const rebooted = schedulerHarness(createSchedulerService, [], { jobsStore: bootStore, now: () => clock, runWatchCheck: async () => ({ code: 0 }) });
842
+ rebooted.service.init();
843
+ assert.strictEqual(rebooted.crons.length, 1, "boot re-arms the poll schedule of a watch whose delivery is queued");
844
+ assert.deepStrictEqual(rebooted.scheduled.map((timer) => timer.delay), [30_000], "and its delivery retry");
845
+ clock = at(15);
846
+ await tick(rebooted.crons[0]);
847
+ assert.strictEqual(bootStore.get("bus4").watchExtraHits, 1);
848
+
849
+ // A retry with no latched trigger (state an older version could leave) is
850
+ // not a pending delivery: its tick polls nothing, the retry runs the check.
851
+ let legacyChecks = 0;
852
+ const legacy = schedulerHarness(createSchedulerService, [], {
853
+ jobsStore: memoryJobsStore([repeatingWatch({ id: "legacy", occurrenceId: "occ-legacy", status: "failed", nextAttemptAt: t0 + 30_000 })]),
854
+ now: () => clock,
855
+ runWatchCheck: async () => { legacyChecks++; return { code: 0 }; },
856
+ });
857
+ legacy.service.init();
858
+ await tick(legacy.crons[0]);
859
+ assert.strictEqual(legacyChecks, 0, "only a latched trigger has a delivery to fold into");
860
+
861
+ // A check that outlives the delivery must not leak into the next occurrence.
862
+ clock = t0;
863
+ let release = null;
864
+ const race = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "race", occurrenceId: "occ-race" })], {
865
+ now: () => clock,
866
+ runWatchCheck: () => (release === null ? Promise.resolve({ code: 0 }) : new Promise((resolve) => { release = resolve; })),
867
+ outcomes: [succeededRun()],
868
+ });
869
+ race.service.init();
870
+ race.state.runningProcess = { pid: 1 };
871
+ await tick(race.crons[0]);
872
+ release = () => {};
873
+ race.crons[0].callback();
874
+ await nextTurn();
875
+ race.state.runningProcess = null;
876
+ assert.strictEqual((await race.scheduled.at(-1).fn()).ok, true);
877
+ release({ code: 0 });
878
+ for (let i = 0; i < 3; i++) await nextTurn();
879
+ assert.strictEqual(race.jobsStore.get("race").occurrenceId, "next-occ-1");
880
+ assert.strictEqual(race.jobsStore.get("race").watchExtraHits, 0, "a late check result belongs to no occurrence");
881
+
882
+ // One-shot watches keep today's semantics: the delivery waits alone.
883
+ let onceChecks = 0;
884
+ const once = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "once", repeat: false, occurrenceId: "occ-once" })], {
885
+ now: () => clock,
886
+ runWatchCheck: async () => { onceChecks++; return { code: 0 }; },
887
+ });
888
+ once.service.init();
889
+ once.state.runningProcess = { pid: 1 };
890
+ await tick(once.crons[0]);
891
+ assert.strictEqual(once.crons[0].stopped, true, "a one-shot watch parks on its retry timer alone");
892
+ await tick(once.crons[0]);
893
+ assert.strictEqual(onceChecks, 1, "and is not polled while its delivery waits");
894
+ }
895
+
896
+ async function checkTimeoutAlarmProbe(createSchedulerService) {
897
+ // 24 Sep: the Phone health check timed out at 09:30 and 09:45 and was only
898
+ // logged idle. Three timeouts in a row alarm once; the watch keeps polling.
899
+ let clock = 1_720_000_000_000;
900
+ let result = { code: 124, timedOut: true };
901
+ const h = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "phone", label: "Phone health", occurrenceId: "occ-phone" })], {
902
+ now: () => clock,
903
+ runWatchCheck: async () => result,
904
+ outcomes: [succeededRun(), succeededRun(), succeededRun()],
905
+ });
906
+ const poll = () => { clock += 15 * 60_000; return h.service.fireJob("phone"); };
907
+ assert.strictEqual((await poll()).status, "watch-idle");
908
+ assert.strictEqual((await poll()).status, "watch-idle");
909
+ assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 2);
910
+ result = { code: 1, timedOut: false };
911
+ await poll();
912
+ assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 0, "a completed check resets the count");
913
+ result = { code: 124, timedOut: true };
914
+ await poll();
915
+ await poll();
916
+ assert.strictEqual(h.runCalls.length, 0, "two timeouts in a row stay quiet");
917
+ assert.strictEqual((await poll()).ok, true);
918
+ assert.strictEqual(h.runCalls.length, 1, "the third timeout in a row wakes the agent");
919
+ assert.match(h.runCalls[0].prompt, /^\[Watch check timing out "Phone health"/);
920
+ assert.match(h.runCalls[0].prompt, /timed out 3 times in a row \(each run is capped at 30 s\)/);
921
+ assert.ok(h.sends.includes('⚠️ Watch "Phone health": its check timed out 3 times in a row'), h.sends.join("\n"));
922
+ const log = fs.readFileSync(process.env.OPEN_CLAUDIA_WATCH_LOG_FILE, "utf8");
923
+ assert.match(log, /label="Phone health" idle \(check timed out, 2 in a row\)/);
924
+ assert.match(log, /label="Phone health" check timed out 3 times in a row — delivery queued/);
925
+ assert.strictEqual(h.jobsStore.get("phone").status, "pending", "the watch keeps polling");
926
+ assert.strictEqual(h.jobsStore.get("phone").watchCheckTimingOut, false);
927
+ for (let i = 0; i < 5; i++) await poll();
928
+ assert.strictEqual(h.runCalls.length, 1, "a check that stays hung alarms once, not on every poll");
929
+ result = { code: 1, timedOut: false };
930
+ await poll();
931
+ result = { code: 124, timedOut: true };
932
+ for (let i = 0; i < 3; i++) await poll();
933
+ assert.strictEqual(h.runCalls.length, 2, "after a completed check a new run of timeouts alarms again");
934
+ result = { code: 0, timedOut: false };
935
+ await poll();
936
+ assert.strictEqual(h.runCalls.length, 3);
937
+ assert.match(h.runCalls[2].prompt, /^\[Watch triggered "Phone health"/);
938
+ assert.strictEqual(h.jobsStore.get("phone").watchTimeoutStreak, 0, "a passing check resets the count too");
939
+
940
+ // A timeout alarm stuck behind a busy room gets the same one heads-up, and
941
+ // a check that completes while it waits resets the count.
942
+ let hungResult = { code: 124, timedOut: true };
943
+ const hung = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "hung", label: "Phone health", occurrenceId: "occ-hung", watchTimeoutStreak: 2 })], {
944
+ now: () => clock,
945
+ runWatchCheck: async () => hungResult,
946
+ });
947
+ hung.state.runningProcess = { pid: 1 };
948
+ const raisedAt = clock;
949
+ await hung.service.fireJob("hung");
950
+ clock = raisedAt + 16 * 60_000;
951
+ await hung.scheduled.at(-1).fn();
952
+ assert.deepStrictEqual(hung.sends, ['⏳ Watch "Phone health" raised a check-timeout alarm 16 min ago and is waiting for the current run to end. /stop ends that run so the watch can go.']);
953
+ hung.service.init();
954
+ hungResult = { code: 1, timedOut: false };
955
+ await tick(hung.crons.at(-1));
956
+ assert.strictEqual(hung.jobsStore.get("hung").watchTimeoutStreak, 0, "a completed poll while the alarm waits resets the count");
957
+
958
+ // A one-shot watch is not used up by a timeout alarm; a real hit still ends it.
959
+ let onceResult = { code: 124, timedOut: true };
960
+ const once = schedulerHarness(createSchedulerService, [repeatingWatch({ id: "once", repeat: false, label: "deploy done", occurrenceId: "occ-once" })], {
961
+ now: () => clock,
962
+ runWatchCheck: async () => onceResult,
963
+ outcomes: [succeededRun(), succeededRun()],
964
+ });
965
+ for (let i = 0; i < 3; i++) await once.service.fireJob("once");
966
+ assert.strictEqual(once.runCalls.length, 1);
967
+ assert.strictEqual(once.jobsStore.get("once")?.status, "pending", "a timeout alarm does not use up a one-shot watch");
968
+ onceResult = { code: 0, timedOut: false };
969
+ await once.service.fireJob("once");
970
+ assert.strictEqual(once.runCalls.length, 2);
971
+ assert.match(once.runCalls[1].prompt, /^\[Watch triggered "deploy done"/);
972
+ assert.strictEqual(once.jobsStore.get("once"), null, "the real trigger still ends a one-shot watch");
973
+ }
974
+
975
+ async function farWakeupProbe(createSchedulerService, MAX_TIMER_DELAY_MS) {
976
+ // 17 Sep: a reminder set for 2028 fired 61 s after it was set. setTimeout
977
+ // fires at once past 2^31-1 ms, so a far wakeup sleeps in chunks.
978
+ const DAY = 24 * 60 * 60 * 1000;
979
+ for (const [label, horizon] of [["thirty-day", 30 * DAY], ["two-year", 730 * DAY]]) {
980
+ const t0 = 1_720_000_000_000;
981
+ let clock = t0;
982
+ const h = schedulerHarness(createSchedulerService, [baseJob({ id: label, fireAt: t0 + horizon, occurrenceId: `occ-${label}` })], {
983
+ now: () => clock,
984
+ outcomes: [succeededRun()],
985
+ });
986
+ h.service.init();
987
+ let chunks = 0;
988
+ for (let timer = h.scheduled.at(-1); clock + timer.delay < t0 + horizon; timer = h.scheduled.at(-1)) {
989
+ clock += timer.delay;
990
+ timer.fn();
991
+ chunks += 1;
992
+ assert.strictEqual(h.runCalls.length, 0, `${label}: a chunk never fires the wakeup early`);
993
+ assert.strictEqual(h.jobsStore.get(label).status, "pending");
994
+ }
995
+ assert.strictEqual(chunks, Math.ceil(horizon / MAX_TIMER_DELAY_MS) - 1, `${label}: slept in whole chunks`);
996
+ const final = h.scheduled.at(-1);
997
+ clock += final.delay;
998
+ assert.strictEqual(clock, t0 + horizon, `${label}: the last timer lands on the fire time`);
999
+ assert.strictEqual((await final.fn()).ok, true);
1000
+ assert.strictEqual(h.runCalls.length, 1, `${label}: fires once when due`);
1001
+ assert.strictEqual(h.jobsStore.get(label), null, `${label}: consumed after it fires`);
1002
+ const armed = h.scheduled.length;
1003
+ h.service.init();
1004
+ await final.fn();
1005
+ assert.strictEqual(h.scheduled.length, armed, `${label}: nothing re-arms a consumed wakeup`);
1006
+ assert.strictEqual(h.runCalls.length, 1, `${label}: never fires twice`);
1007
+ }
1008
+
1009
+ const due = schedulerHarness(createSchedulerService, [baseJob({ id: "due", fireAt: 1_720_000_000_500 })], { outcomes: [succeededRun(), succeededRun()] });
1010
+ due.service.init();
1011
+ assert.deepStrictEqual(due.scheduled.map((timer) => timer.delay), [0]);
1012
+ await due.scheduled[0].fn();
1013
+ await due.scheduled[0].fn();
1014
+ assert.strictEqual(due.runCalls.length, 1, "a due wakeup fires once");
1015
+ assert.strictEqual(due.jobsStore.get("due"), null);
1016
+
1017
+ // With Node's own setTimeout: a 30-day wakeup must not fire now.
1018
+ const runs = [];
1019
+ const real = createSchedulerService({
1020
+ jobsStore: memoryJobsStore([baseJob({ id: "real-far", fireAt: Date.now() + 30 * DAY })]),
1021
+ adapters: [{ id: "telegram-fixture", type: "telegram" }],
1022
+ workspace: "/workspace",
1023
+ runInChat: async (_ctx, fn) => fn(),
1024
+ getUserState: () => ({ userId: "fixture-user", settings: { backend: "claude" }, activeSessions: {}, completedCompactions: {} }),
1025
+ send: async () => ({ ok: true }),
1026
+ admitRunContext: (_prompt, cwd) => ({ cwd }),
1027
+ runAgent: async () => { runs.push("fired"); return succeededRun(); },
1028
+ providerStatus: () => ({ availability: { state: "available" }, auth: { state: "authenticated" } }),
1029
+ fallbackProviders: [],
1030
+ });
1031
+ real.init();
1032
+ await new Promise((resolve) => setTimeout(resolve, 50));
1033
+ real.stopAll();
1034
+ assert.deepStrictEqual(runs, [], "a real 30-day timer does not fire at once");
1035
+ }
1036
+
1037
+ function wakeupHorizonCliProbe() {
1038
+ // The CLI refuses a <when> more than 10 years out before it calls the bot.
1039
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "oc-wakeup-horizon-"));
1040
+ const env = { PATH: process.env.PATH, HOME: temp, OPEN_CLAUDIA_CONFIG_DIR: temp };
1041
+ const run = (when) => spawnSync(process.execPath, ["-e", `require("./bin/schedule").runScheduleWakeup([${JSON.stringify(when)}, "fixture reminder"])`], {
1042
+ cwd: root, env, encoding: "utf8", timeout: 30000,
1043
+ });
1044
+ try {
1045
+ const far = run("4000d");
1046
+ assert.strictEqual(far.status, 2, far.stderr);
1047
+ assert.match(far.stderr, /^<when> "4000d" is \d{4}-\d\d-\d\dT[\d:.]+Z, more than 10 years out\. Check the date; for a far reminder use a yearly cron-add\.\n$/);
1048
+ const absurd = run("99999999999999999999d");
1049
+ assert.strictEqual(absurd.status, 2, absurd.stderr);
1050
+ assert.match(absurd.stderr, /is past any valid date, more than 10 years out/);
1051
+ const near = run("3600d");
1052
+ assert.strictEqual(near.status, 2);
1053
+ assert.match(near.stderr, /No active chat context/, "a <when> inside 10 years passes the horizon check");
1054
+ } finally {
1055
+ fs.rmSync(temp, { recursive: true, force: true });
1056
+ }
1057
+ }
1058
+
1059
+ function listingProbe() {
1060
+ // A fired job must not keep reading as a future one (17 Sep: a fired 2028
1061
+ // reminder still listed "fires 2028-08-01"; 21 Sep: a triggered watch whose
1062
+ // delivery was queued listed "EXPIRED (fires on next poll)" and was taken
1063
+ // for a watch that never passed).
1064
+ const { fmtJob, fmtWatch } = require("./bin/schedule");
1065
+ const triggeredAt = Date.UTC(2026, 8, 21, 6, 46, 1);
1066
+ const watch = {
1067
+ id: "cron_x_hn854u", check: "pipeline-terminal 12933", schedule: "* * * * *", repeat: false,
1068
+ label: "SDK tag pipeline", occurrenceId: "occ", expiresAt: triggeredAt + 60_000,
1069
+ };
1070
+ assert.match(fmtWatch({ ...watch, status: "pending" }, triggeredAt + 120_000), /polls "\* \* \* \* \*" EXPIRED \(fires on next poll\) check:/,
1071
+ "an untriggered watch still shows its expiry");
1072
+ const queued = fmtWatch({ ...watch, status: "deferred", watchTriggeredOccurrenceId: "occ", watchTriggeredAt: triggeredAt }, triggeredAt + 120_000);
1073
+ assert.strictEqual(queued, 'cron_x_hn854u one-shot polls "* * * * *" TRIGGERED 2026-09-21T06:46:01.000Z, delivery waiting for a busy room check: pipeline-terminal 12933 → SDK tag pipeline');
1074
+ assert.match(fmtWatch({ ...watch, status: "failed", watchTriggeredOccurrenceId: "occ" }), /polls "\* \* \* \* \*" delivery failed \(error\), retrying check:/,
1075
+ "a latch without a trigger time still lists");
1076
+ const wake = { id: "wakeup_x", kind: "wakeup", label: "rotate token", fireAt: Date.UTC(2028, 7, 1) };
1077
+ assert.strictEqual(fmtJob({ ...wake, status: "pending" }), "wakeup_x wakeup rotate token fires 2028-08-01T00:00:00.000Z");
1078
+ assert.strictEqual(fmtJob({ ...wake, status: "running" }), "wakeup_x wakeup rotate token fires 2028-08-01T00:00:00.000Z [delivery running]");
1079
+ assert.match(fmtJob({ ...wake, status: "failed", lastError: { code: "PROVIDER_EXIT_NONZERO" } }), / \[delivery failed \(PROVIDER_EXIT_NONZERO\), retrying\]$/);
1080
+ assert.match(fmtJob({ ...wake, status: "dead-letter", lastError: { code: "PROVIDER_UNAUTHENTICATED" } }), / \[delivery failed \(PROVIDER_UNAUTHENTICATED\), DEAD: will not fire\]$/);
1081
+ }
1082
+
1083
+ async function strandedDeliveryProbe(createSchedulerService) {
1084
+ // 21 and 23 Sep: watch alarms that triggered during long turns were never
1085
+ // delivered. A jobs-store write that throws (the disk was full on 15 Sep)
1086
+ // must leave a retry armed, and the delivery goes at the next idle moment.
1087
+ const diskFull = () => Object.assign(new Error("ENOSPC: no space left on device, write"), { code: "ENOSPC" });
1088
+ const failedRun = { ok: false, status: "failed", exitCode: 1, error: { code: "PROVIDER_EXIT_NONZERO", message: "exit 1" }, delivery: { ok: true } };
1089
+ const cases = [
1090
+ ["busy deferral", (patch) => patch.status === "deferred" && !!patch.busyDeferralCount, { busy: true }],
1091
+ ["attempt start", (patch) => patch.status === "running", {}],
1092
+ ["failed run", (patch) => patch.status === "failed", { outcomes: [failedRun] }],
1093
+ ["thrown run", (patch) => patch.status === "failed", { outcomes: [new Error("provider crashed")] }],
1094
+ ["admission refusal", (patch) => patch.status === "failed", { admitError: new Error("Choose Claude Code or OpenAI Codex before starting a model turn") }],
1095
+ ];
1096
+ for (const [name, faulty, setup] of cases) {
1097
+ let clock = 1_720_000_000_000;
1098
+ const store = memoryJobsStore([repeatingWatch({ id: "w", repeat: false, occurrenceId: "occ-w" })]);
1099
+ const update = store.update.bind(store);
1100
+ let armed = true;
1101
+ let faults = 0;
1102
+ store.update = (id, patch) => {
1103
+ if (armed && faulty(patch)) { armed = false; faults++; throw diskFull(); }
1104
+ return update(id, patch);
1105
+ };
1106
+ const options = {
1107
+ jobsStore: store,
1108
+ now: () => clock,
1109
+ runWatchCheck: async () => ({ code: 0 }),
1110
+ outcomes: [...(setup.outcomes || []), succeededRun()],
1111
+ admitError: setup.admitError || null,
1112
+ };
1113
+ const h = schedulerHarness(createSchedulerService, [], options);
1114
+ if (setup.busy) h.state.runningProcess = { pid: 1 };
1115
+ const first = await h.service.fireJob("w");
1116
+ assert.strictEqual(faults, 1, `${name}: the write failed`);
1117
+ assert.strictEqual(first.ok, false, name);
1118
+ assert.strictEqual(h.scheduled.length, 1, `${name}: a retry stays armed after the failed write`);
1119
+ h.state.runningProcess = null;
1120
+ options.admitError = null;
1121
+ clock += 60_000;
1122
+ assert.strictEqual((await h.scheduled.at(-1).fn()).ok, true, `${name}: delivered at the next idle moment`);
1123
+ assert.strictEqual(h.jobsStore.get("w"), null, name);
1124
+ }
1125
+ assert.match(fs.readFileSync(process.env.OPEN_CLAUDIA_WATCH_LOG_FILE, "utf8"), /delivery bookkeeping failed \(ENOSPC\) — retrying in \d+ s/);
1126
+
1127
+ // An admission refusal with a working store is a counted, retried failure.
1128
+ const refused = schedulerHarness(createSchedulerService, [baseJob({ id: "refused" })], { admitError: new Error("no provider selected") });
1129
+ const refusal = await refused.service.fireJob("refused");
1130
+ assert.strictEqual(refusal.status, "failed");
1131
+ assert.strictEqual(refused.jobsStore.get("refused").attemptCount, 1);
1132
+ assert.strictEqual(refused.scheduled.length, 1);
1133
+
1134
+ // Pre-run failure records that cannot be written still keep a retry.
1135
+ for (const [name, overrides, options] of [
1136
+ ["unknown adapter", { adapter: "gone", adapterType: "gone" }, {}],
1137
+ ["no runnable provider", {}, { providerStatus: () => ({ availability: { state: "missing" }, auth: { state: "not_checked" } }) }],
1138
+ ]) {
1139
+ const store = memoryJobsStore([baseJob({ id: "pre", ...overrides })]);
1140
+ store.update = () => { throw diskFull(); };
1141
+ const h = schedulerHarness(createSchedulerService, [], { jobsStore: store, ...options });
1142
+ assert.strictEqual((await h.service.fireJob("pre")).status, "failed", name);
1143
+ assert.strictEqual(h.scheduled.length, 1, `${name}: a retry stays armed`);
1144
+ assert.strictEqual(h.runCalls.length, 0);
1145
+ }
1146
+
1147
+ // A running lease left by an earlier process is looked at again when it
1148
+ // lapses; fireJob already turns away a fire that is really in flight.
1149
+ const leased = schedulerHarness(createSchedulerService, [baseJob({ id: "leased", status: "running", attemptCount: 1, nextAttemptAt: 1_720_000_000_500 + 5000 })]);
1150
+ assert.strictEqual((await leased.service.fireJob("leased")).inFlight, true);
1151
+ assert.deepStrictEqual(leased.scheduled.map((timer) => timer.delay), [5000]);
1152
+ }
1153
+
673
1154
  async function durablePendingProbe(createSchedulerService, jobsApi) {
674
1155
  const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-pending-restart-"));
675
1156
  let clock = 1_720_000_000_500;
@@ -706,7 +1187,7 @@ async function durablePendingProbe(createSchedulerService, jobsApi) {
706
1187
  const terminal = deferred();
707
1188
  const restarted = schedulerHarness(createSchedulerService, [], {
708
1189
  jobsStore: reloaded, now: () => clock, outcomes: [terminal.promise, success],
709
- runWatchCheck: async () => { throw new Error("pending watch must not poll after restart"); },
1190
+ runWatchCheck: async () => { throw new Error("a queued delivery must not re-run its trigger check after restart"); },
710
1191
  });
711
1192
  restarted.service.init();
712
1193
  const firing = restarted.scheduled[0].fn();
@@ -1063,6 +1544,18 @@ function staticSchedulingProbe() {
1063
1544
  assert.match(source["core/scheduler.js"], /watchExpired/, "scheduler must mark expiry fires distinctly");
1064
1545
  assert.match(source["core/loopback.js"], /expiresAt/, "loopback must pass the watch expiry through");
1065
1546
  assert.match(source["bin/schedule.js"], /--timeout/, "watch-add CLI must expose --timeout");
1547
+
1548
+ // Phase 0 (B2): a watch check must NOT inherit the bot's whole environment,
1549
+ // must build an explicit hardened env, and must register a creator-scoped
1550
+ // run around the poll.
1551
+ assert.ok(!/env:\s*process\.env/.test(source["core/scheduler.js"]), "watch check must not spawn with the bot's whole process env");
1552
+ assert.match(source["core/scheduler.js"], /buildWatchCheckEnv\(process\.env/, "watch check builds an explicit hardened env");
1553
+ assert.match(source["core/scheduler.js"], /registerRun\(checkRunId/, "watch check registers a creator-scoped run");
1554
+ assert.match(source["core/scheduler.js"], /endRun\(checkRunId\)/, "watch check ends its run when the check exits");
1555
+ // Phase 0 (B2): the loopback binds a job's identity to the run-attested
1556
+ // creator, not the payload's claims, and refuses an unattributed caller.
1557
+ assert.match(source["core/loopback.js"], /JOB_CREATE_KINDS/, "loopback must bind job creation to the run-attested caller");
1558
+ assert.match(source["core/loopback.js"], /createdBy/, "jobs must record their creator principal");
1066
1559
  }
1067
1560
 
1068
1561
  async function main() {
@@ -1072,6 +1565,8 @@ async function main() {
1072
1565
  assert.strictEqual(typeof jobsApi.migrateJobsDocument, "function");
1073
1566
  assert.strictEqual(typeof jobsApi.createJobsStore, "function");
1074
1567
  assert.strictEqual(typeof schedulerApi.createSchedulerService, "function");
1568
+ assert.strictEqual(typeof schedulerApi.buildWatchCheckEnv, "function", "scheduler exports the watch-check env builder");
1569
+ assert.strictEqual(typeof schedulerApi.watchCheckPrincipal, "function", "scheduler exports the watch-check principal resolver");
1075
1570
 
1076
1571
  migrationProbe(jobsApi);
1077
1572
  legacyDefaultsProbe(jobsApi);
@@ -1083,6 +1578,12 @@ async function main() {
1083
1578
  await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
1084
1579
  await durablePendingProbe(schedulerApi.createSchedulerService, jobsApi);
1085
1580
  await busyWatchNoticeProbe(schedulerApi.createSchedulerService);
1581
+ await pendingDeliveryPollingProbe(schedulerApi.createSchedulerService);
1582
+ await checkTimeoutAlarmProbe(schedulerApi.createSchedulerService);
1583
+ await farWakeupProbe(schedulerApi.createSchedulerService, schedulerApi.MAX_TIMER_DELAY_MS);
1584
+ wakeupHorizonCliProbe();
1585
+ listingProbe();
1586
+ await strandedDeliveryProbe(schedulerApi.createSchedulerService);
1086
1587
  await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
1087
1588
  await cronOccurrenceProbe(schedulerApi.createSchedulerService);
1088
1589
  await watchGateProbe(schedulerApi.createSchedulerService);
@@ -95,6 +95,17 @@ try {
95
95
  const claudeSettings = buildClaudeHookSettings();
96
96
  assert.strictEqual(claudeSettings.hooks.PreToolUse[0].matcher, "Bash");
97
97
  assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, /deny-gate-hook/);
98
+ // Claude's file-reading tools never pass through a shell, so the guarded
99
+ // read gate needs its own matcher on the same deny-gate entry.
100
+ const readHook = claudeSettings.hooks.PreToolUse.find((entry) => entry.matcher === "^(Read|Grep|Glob|NotebookRead|LS)$");
101
+ assert.ok(readHook, "Claude read tools are hooked");
102
+ assert.strictEqual(readHook.hooks[0].command, claudeSettings.hooks.PreToolUse[0].hooks[0].command);
103
+ for (const tool of ["Read", "Grep", "Glob", "NotebookRead", "LS"]) {
104
+ assert.ok(new RegExp(readHook.matcher).test(tool), `${tool} matches the read-gate matcher`);
105
+ }
106
+ for (const tool of ["Bash", "Write", "WebFetch", "ReadMcpResourceTool", "Task"]) {
107
+ assert.ok(!new RegExp(readHook.matcher).test(tool), `${tool} stays out of the read-gate matcher`);
108
+ }
98
109
  assert.match(claudeSettings.hooks.PreToolUse[0].hooks[0].command, new RegExp(process.execPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
99
110
  assert.strictEqual(ensureClaudeHookSettings({ settingsFile: claudeSettingsFile }), claudeSettingsFile);
100
111
  assert.deepStrictEqual(JSON.parse(fs.readFileSync(claudeSettingsFile, "utf8")), claudeSettings);
package/test-run-lock.js CHANGED
@@ -87,6 +87,8 @@ const { acquireRunLock } = require("./core/state");
87
87
  'Queued behind the watch "Phone alarm" (running 42 min). /stop cancels it.');
88
88
  assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Scheduled wakeup "Check CI" fired at x]' }, t0 + 3 * 60_000),
89
89
  'Queued behind the wakeup "Check CI" (running 3 min). /stop cancels it.');
90
+ assert.strictEqual(queuedNotice({ admittedAt: t0, userPrompt: '[Watch check timing out "Phone health" fired at x; occurrence y]' }, t0 + 20 * 60_000),
91
+ 'Queued behind the watch "Phone health" (running 20 min). /stop cancels it.', "a check-timeout alarm is a watch run too");
90
92
  const long = queuedNotice({ admittedAt: t0, userPrompt: `[Watch triggered "${"x".repeat(90)}" fired at x]` }, t0 + 3 * 60_000);
91
93
  assert.ok(long.includes(`"${"x".repeat(59)}…"`), "a long label is shortened to 60 characters");
92
94
  }
@@ -122,7 +122,9 @@ const deny = guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { com
122
122
  assert.strictEqual(deny.exit, 2, "deny → exit 2 (blocking)");
123
123
  assert.ok(/deny-gate/.test(deny.stderr), "deny → stderr message for the model");
124
124
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0, "benign Bash → allow");
125
- assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "non-Bash tools are never gated");
125
+ assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0, "reads are never gated without channel context");
126
+ // Owner-local (no channel context) fails OPEN; guarded turns fail closed
127
+ // instead (pinned in test-read-guard.js).
126
128
  assert.strictEqual(guard.runHook("not json{{{").exit, 0, "unparseable payload fails OPEN");
127
129
  assert.strictEqual(guard.runHook(null).exit, 0, "null payload fails OPEN");
128
130
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash" })).exit, 0, "missing tool_input fails OPEN");
@@ -154,7 +156,7 @@ assert.strictEqual(
154
156
  guard.runHook(JSON.stringify({ tool_name: "apply_patch", tool_input: { command: "*** Begin Patch\n*** Add File: /tmp/evil.txt\n+x\n*** End Patch\n" } })).exit, 2,
155
157
  "Codex apply_patch equally gated on guarded turns (probed live: file edits arrive under this tool name)");
156
158
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Read", tool_input: { file_path: "/etc/hosts" } })).exit, 0,
157
- "reads stay free on guarded turns");
159
+ "ordinary reads stay free on guarded turns (protected roots: test-read-guard.js)");
158
160
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls -la" } })).exit, 0,
159
161
  "benign Bash unaffected by the file gate (deny-gate rules unchanged)");
160
162
 
@@ -185,8 +187,11 @@ assert.strictEqual(guard.externalControlPlaneDenial(cpWrite), null, "no OC_CHANN
185
187
  assert.strictEqual(guard.runHook(JSON.stringify({ tool_name: "Bash", tool_input: { command: cpWrite } })).exit, 0,
186
188
  "owner-local shells keep full config access");
187
189
  process.exitCode = 0;
190
+ delete process.env.OC_RUN_ATTEST; // hermetic even when the suite runs inside a live bot turn
188
191
  require("./bin/keyring").run(["path"]);
189
- assert.strictEqual(process.exitCode || 0, 0, "keyring CLI stays open owner-local (no channel env)");
192
+ assert.strictEqual(process.exitCode, 1,
193
+ "keyring CLI is owner-only even with no channel env: no live owner run behind the process = unattributed (test-cli-owner-gates.js)");
194
+ process.exitCode = 0;
190
195
  process.env.OC_CHANNEL_ADAPTER = "telegram";
191
196
  process.env.OC_CHANNEL_ID = "999000111";
192
197
  process.env.OC_CHANNEL_USER_ID = "999000111";
@@ -288,6 +293,10 @@ assert.strictEqual(pre[1].matcher, "^(Write|Edit|MultiEdit|NotebookEdit)$",
288
293
  "file-mutation tools are hooked for the external-speaker gate");
289
294
  assert.strictEqual(pre[1].hooks[0].command, pre[0].hooks[0].command,
290
295
  "file gate rides the same deny-gate-hook entry (one CLI, one audit path)");
296
+ assert.strictEqual(pre[2].matcher, "^(Read|Grep|Glob|NotebookRead|LS)$",
297
+ "file-reading tools are hooked for the guarded read gate");
298
+ assert.strictEqual(pre[2].hooks[0].command, pre[0].hooks[0].command,
299
+ "read gate rides the same deny-gate-hook entry");
291
300
  // second call with the file read-only: content-compare must skip the write
292
301
  fs.chmodSync(sPath, 0o444);
293
302
  assert.strictEqual(guard.ensureHookSettings(), sPath, "unchanged settings are not rewritten");