@stratusagent/gateway 0.14.0 → 0.14.1

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.
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { type JsonObject, type Session } from '@stratusagent/core';
2
+ import { type JsonObject, type Session, type SessionStatus, type SessionStoppedEarly, type TrustLevel } from '@stratusagent/core';
3
3
  import { type ScheduleDestination, type ScheduleRecord, type SchedulerHandle } from '@stratusagent/agents';
4
4
  import type { EventTrigger } from './event-intake.ts';
5
5
  import type { SqliteEventStore } from './events.ts';
@@ -78,6 +78,33 @@ export declare class SqliteScheduleStore {
78
78
  export declare const SCHEDULE_SESSION_ID_PREFIX = "schedule:";
79
79
  /** Whether a session id belongs to the reserved scheduled-firing namespace. */
80
80
  export declare const isScheduleSessionId: (sessionId: string) => boolean;
81
+ /** The longest prefix of `text` that fits in `bytes` of UTF-8, never splitting a character. */
82
+ export declare const utf8Prefix: (text: string, bytes: number) => string;
83
+ /**
84
+ * What the next firing of a schedule reads from the one before it (card
85
+ * 858), from the gateway's session store: the status, the stopped-early
86
+ * marker, the latest reply and the label that reply carries. Never the
87
+ * transcript.
88
+ */
89
+ export interface PreviousRun {
90
+ status: SessionStatus;
91
+ stoppedEarly?: SessionStoppedEarly;
92
+ /** The latest turn's text (`latestTurnReply`): for a stopped run, its wrap-up. */
93
+ reply?: string;
94
+ /** The label the run's own words carry (`sessionWriteTrust`). */
95
+ trust: TrustLevel;
96
+ }
97
+ /** A resumed firing carries at most this much of the stopped run's summary. */
98
+ export declare const RESUME_SUMMARY_MAX_BYTES = 4096;
99
+ /**
100
+ * What a firing that resumes sees after its schedule's prompt: when the
101
+ * stopped run ended and why, its own summary of what was done and left
102
+ * (capped), and, from the second stop in a row, that it's a repeat. The
103
+ * runtime posts nothing about a repeat: only the agent knows whether a
104
+ * person is waiting, so it's told it may post one line to the destination
105
+ * its schedule was approved with, when there is one.
106
+ */
107
+ export declare const renderResumeBlock: (stoppedEarly: SessionStoppedEarly, reply: string | undefined, streak: number, reportsSomewhere: boolean) => string;
81
108
  export interface SchedulerLimits {
82
109
  /**
83
110
  * The floor under a recurring cadence, milliseconds. Applied to intervals
@@ -140,6 +167,14 @@ export interface SchedulerRuntimeOptions {
140
167
  validateDestination(agentId: string, destination: ScheduleDestination): Promise<void>;
141
168
  /** Status of a firing's session, for the spent-one-shot sweep. */
142
169
  sessionStatus(sessionId: string): Promise<string | undefined>;
170
+ /**
171
+ * The session a schedule's previous firing ran in, or undefined when it
172
+ * doesn't exist (card 858). Read when the next firing of an `every` or
173
+ * `cron` schedule is claimed: a run that stopped early is that firing's
174
+ * checkpoint, and a run still going holds the slot back. Without it,
175
+ * every firing starts from its prompt alone, as before.
176
+ */
177
+ previousRun?(sessionId: string): Promise<PreviousRun | undefined>;
143
178
  /** The roster check: a schedule for an agent that does not exist is a refusal. */
144
179
  hasAgent(agentId: string): boolean;
145
180
  limits?: SchedulerLimits;
@@ -190,6 +225,13 @@ export interface SchedulerRuntime {
190
225
  * the next tick: the intake calls it after a delivery matched.
191
226
  */
192
227
  kickEvents(): void;
228
+ /**
229
+ * Take event triggers on a host that had no source when it built this
230
+ * runtime: a plugin's source is known only once plugins load, after the
231
+ * gateway built its scheduler. Once, and with the same options
232
+ * `SchedulerRuntimeOptions.events` would have carried.
233
+ */
234
+ attachEvents(events: SchedulerEventOptions): void;
193
235
  /**
194
236
  * The destination carve-out, shaped for the permission policy's
195
237
  * `destinations` option: true exactly when this session is a firing of a
@@ -1 +1 @@
1
- {"version":3,"file":"schedules.d.ts","sourceRoot":"","sources":["../src/schedules.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schedules.d.ts","sourceRoot":"","sources":["../src/schedules.ts"],"names":[],"mappings":"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"}
package/dist/schedules.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { chmodSync, mkdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
- import { SENDER_TRUST_METADATA_KEY } from '@stratusagent/core';
5
+ import { leastTrusted, SENDER_TRUST_METADATA_KEY, } from '@stratusagent/core';
6
6
  import { canonicalDestination, DEFAULT_EVENT_MAX_PER_HOUR, isWithinQuietHours, nextFireAfter, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, } from '@stratusagent/agents';
7
7
  import { assertDerivedStatePathSync } from '@stratusagent/state';
8
8
  import { tightenSqliteFile } from "./sessions.js";
@@ -119,6 +119,52 @@ export class SqliteScheduleStore {
119
119
  export const SCHEDULE_SESSION_ID_PREFIX = 'schedule:';
120
120
  /** Whether a session id belongs to the reserved scheduled-firing namespace. */
121
121
  export const isScheduleSessionId = (sessionId) => sessionId.startsWith(SCHEDULE_SESSION_ID_PREFIX);
122
+ /** The longest prefix of `text` that fits in `bytes` of UTF-8, never splitting a character. */
123
+ export const utf8Prefix = (text, bytes) => {
124
+ const { read } = new TextEncoder().encodeInto(text, new Uint8Array(Math.max(0, bytes)));
125
+ return text.slice(0, read);
126
+ };
127
+ /** A resumed firing carries at most this much of the stopped run's summary. */
128
+ export const RESUME_SUMMARY_MAX_BYTES = 4_096;
129
+ const STOPPED_EARLY_WHY = {
130
+ 'turn-limit': 'it used all its steps',
131
+ 'repeated-failure': 'it kept hitting the same failure',
132
+ };
133
+ const ordinal = (n) => {
134
+ const tens = n % 100;
135
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th');
136
+ return `${n}${suffix}`;
137
+ };
138
+ /**
139
+ * What a firing that resumes sees after its schedule's prompt: when the
140
+ * stopped run ended and why, its own summary of what was done and left
141
+ * (capped), and, from the second stop in a row, that it's a repeat. The
142
+ * runtime posts nothing about a repeat: only the agent knows whether a
143
+ * person is waiting, so it's told it may post one line to the destination
144
+ * its schedule was approved with, when there is one.
145
+ */
146
+ export const renderResumeBlock = (stoppedEarly, reply, streak, reportsSomewhere) => {
147
+ const text = reply?.trim() ?? '';
148
+ const summary = text === ''
149
+ ? 'It left no summary.'
150
+ : Buffer.byteLength(text) <= RESUME_SUMMARY_MAX_BYTES
151
+ ? text
152
+ : `${utf8Prefix(text, RESUME_SUMMARY_MAX_BYTES)}\n[summary cut at ${Math.round(RESUME_SUMMARY_MAX_BYTES / 1_024)} KB]`;
153
+ const when = typeof stoppedEarly.at === 'string' ? `, at ${stoppedEarly.at},` : '';
154
+ const why = STOPPED_EARLY_WHY[stoppedEarly.reason] ?? 'it stopped before finishing';
155
+ const lines = [
156
+ `Your last run of this schedule${when} stopped early: ${why}. What it said was done and what was left:`,
157
+ '',
158
+ summary,
159
+ '',
160
+ "Carry on from there, and don't redo finished work.",
161
+ ];
162
+ if (streak >= 2) {
163
+ lines.push('', `That was the ${ordinal(streak)} run in a row to stop early.`
164
+ + (reportsSomewhere ? " If that blocks someone, post one line to this schedule's report destination saying what's stuck." : ''));
165
+ }
166
+ return lines.join('\n');
167
+ };
122
168
  const DEFAULT_MIN_INTERVAL_MS = 60_000;
123
169
  const DEFAULT_MAX_CONCURRENT_PER_AGENT = 1;
124
170
  const DEFAULT_TICK_MS = 1_000;
@@ -129,9 +175,11 @@ const EVENT_HELD_PER_KEY = 5;
129
175
  /** How often the event tables are pruned to their 7-day window and row cap. */
130
176
  const EVENT_PRUNE_INTERVAL_MS = 60 * 60 * 1_000;
131
177
  export const createSchedulerRuntime = (options) => {
132
- const { store, dispatch, validateDestination, sessionStatus, hasAgent, log, warn, events } = options;
178
+ const { store, dispatch, validateDestination, sessionStatus, hasAgent, log, warn } = options;
179
+ // Late-bindable once: see `attachEvents`.
180
+ let events = options.events;
133
181
  const clock = options.now ?? (() => new Date());
134
- const maxEventsPerAgent = events?.maxConcurrentPerAgent ?? 1;
182
+ let maxEventsPerAgent = events?.maxConcurrentPerAgent ?? 1;
135
183
  const minIntervalMs = options.limits?.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
136
184
  const maxConcurrentPerAgent = options.limits?.maxConcurrentPerAgent ?? DEFAULT_MAX_CONCURRENT_PER_AGENT;
137
185
  const tickMs = options.limits?.tickMs ?? DEFAULT_TICK_MS;
@@ -193,7 +241,7 @@ export const createSchedulerRuntime = (options) => {
193
241
  throw new Error('createTrigger takes an event cadence.');
194
242
  }
195
243
  if (!events) {
196
- throw new Error('This daemon takes no events: its config has no "events" sources, so a trigger could never fire. The operator adds a source first.');
244
+ throw new Error('This daemon takes no events: its config has no "events" sources and no plugin feeds one, so a trigger could never fire. The operator adds a source first.');
197
245
  }
198
246
  const configured = events.sources();
199
247
  if (!configured.includes(input.cadence.source)) {
@@ -290,6 +338,128 @@ export const createSchedulerRuntime = (options) => {
290
338
  const { nextFireAt: _spent, ...rest } = record;
291
339
  return { ...rest, ...extra, ...(next ? { nextFireAt: next.toISOString() } : {}) };
292
340
  };
341
+ /**
342
+ * Card 858: a readiness check that failed after an await inside a scan.
343
+ * Carries the reason out to `tick`, which treats it like the check at the
344
+ * top of the tick: the scheduler stops, and at start the failure is
345
+ * `start()`'s rejection.
346
+ */
347
+ class NotReady extends Error {
348
+ scheduleId;
349
+ reason;
350
+ constructor(scheduleId, reason) {
351
+ super(reason instanceof Error ? reason.message : String(reason));
352
+ this.scheduleId = scheduleId;
353
+ this.reason = reason;
354
+ }
355
+ }
356
+ const stillReady = async (scheduleId) => {
357
+ if (!options.ready) {
358
+ return;
359
+ }
360
+ try {
361
+ await options.ready();
362
+ }
363
+ catch (error) {
364
+ stopped = true;
365
+ throw new NotReady(scheduleId, error);
366
+ }
367
+ };
368
+ /**
369
+ * What the firing about to be claimed carries from the one in
370
+ * `lastSessionId` (card 858): `'defer'` while that run is still going (a
371
+ * schedule never claims past a run whose outcome it hasn't read, or that
372
+ * run's summary is lost and the streak breaks), otherwise the new streak
373
+ * and the stopped run to resume from, if any. Undefined when the daemon
374
+ * gave no `previousRun`: the claim then leaves both fields as they are.
375
+ */
376
+ const planResume = async (record, last, slot) => {
377
+ const deferKey = `${record.id}:${slot}:previous`;
378
+ const holdBack = () => {
379
+ if (!deferralsWarned.has(deferKey)) {
380
+ deferralsWarned.add(deferKey);
381
+ warn(`schedule ${record.id}: its previous run ${last} is still going; deferring this firing (slot ${slot})`);
382
+ }
383
+ return 'defer';
384
+ };
385
+ // Started by this daemon and not settled: its session may not exist yet.
386
+ if (activeFirings.has(last)) {
387
+ return holdBack();
388
+ }
389
+ const read = options.previousRun;
390
+ if (!read) {
391
+ return undefined;
392
+ }
393
+ const streak = record.stoppedEarlyStreak ?? 0;
394
+ const readRun = async (sessionId) => {
395
+ try {
396
+ return await read(sessionId);
397
+ }
398
+ catch (error) {
399
+ warn(`schedule ${record.id}: could not read its previous run ${sessionId} (${error instanceof Error ? error.message : String(error)}); firing without it`);
400
+ return 'unreadable';
401
+ }
402
+ };
403
+ const previous = await readRun(last);
404
+ if (previous === 'unreadable') {
405
+ // No outcome to count: the streak stays, and there's no block.
406
+ return { streak };
407
+ }
408
+ if (previous !== undefined) {
409
+ if (previous.status === 'running' || previous.status === 'pending_approval') {
410
+ return holdBack();
411
+ }
412
+ deferralsWarned.delete(deferKey);
413
+ return previous.stoppedEarly !== undefined
414
+ ? { streak: streak + 1, from: { sessionId: last, run: previous } }
415
+ : { streak: 0 };
416
+ }
417
+ deferralsWarned.delete(deferKey);
418
+ const fallback = record.resumeFrom;
419
+ if (fallback !== undefined && fallback.firing === last) {
420
+ // That firing was claimed and never started: the daemon died before
421
+ // its session existed, or refused it on the way down. It has no
422
+ // outcome, so the streak stays, and it carried a checkpoint, which
423
+ // this firing carries instead.
424
+ const from = await readRun(fallback.from);
425
+ if (from === 'unreadable') {
426
+ return { streak };
427
+ }
428
+ if (from?.stoppedEarly !== undefined) {
429
+ return { streak, from: { sessionId: fallback.from, run: from } };
430
+ }
431
+ log(`schedule ${record.id}: previous run ${last} never started, and the run it resumed from (${fallback.from}) is gone; starting fresh`);
432
+ return { streak: 0 };
433
+ }
434
+ // Removed after it ran, a home restored without it, or a fallback left
435
+ // for another firing (an older build moved `lastSessionId` without it).
436
+ // Replaying an older checkpoint here could redo finished work.
437
+ log(`schedule ${record.id}: previous run ${last} not found; starting fresh`);
438
+ return { streak: 0 };
439
+ };
440
+ /**
441
+ * A resumed firing whose session exists no longer needs its fallback: the
442
+ * next claim reads that session. Only while the row still names this
443
+ * firing, through the slot claim's own conditional write, so a later
444
+ * claim's fallback is never the one removed. Best effort: one left
445
+ * behind is used only while `lastSessionId` names no session.
446
+ */
447
+ const dropFallback = async (scheduleId, sessionId) => {
448
+ try {
449
+ if ((await sessionStatus(sessionId)) === undefined) {
450
+ return;
451
+ }
452
+ const current = store.get(scheduleId);
453
+ if (current?.resumeFrom?.firing !== sessionId) {
454
+ return;
455
+ }
456
+ const { resumeFrom: _done, ...rest } = current;
457
+ store.claimSlot(rest, current.nextFireAt ?? '');
458
+ }
459
+ catch (error) {
460
+ warn(`schedule ${scheduleId}: could not clear the resume fallback for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
461
+ }
462
+ };
293
463
  /**
294
464
  * Fire one due schedule: consume the slot durably, then dispatch.
295
465
  *
@@ -299,7 +469,7 @@ export const createSchedulerRuntime = (options) => {
299
469
  * advance past it with a log line, never replay. Anything else fires,
300
470
  * which on time is the normal case and late is the single catch-up.
301
471
  */
302
- const fireOrSkip = (record, now) => {
472
+ const fireOrSkip = async (record, now) => {
303
473
  const slot = record.nextFireAt;
304
474
  if (!slot) {
305
475
  return;
@@ -320,11 +490,15 @@ export const createSchedulerRuntime = (options) => {
320
490
  // and the line that blamed downtime for it sent an operator to look
321
491
  // for a restart that never happened.
322
492
  const deferralKey = `${record.id}:${slot}`;
323
- log(deferralsWarned.has(deferralKey)
324
- ? `schedule ${record.id}: the firing deferred by the per-agent cap (slot ${slot}) outlasted its window; skipping to ${next ? next.toISOString() : 'never'}`
325
- : `schedule ${record.id}: missed firing(s) while the daemon was down; skipping to ${next ? next.toISOString() : 'never'}`);
493
+ const skippingTo = next ? next.toISOString() : 'never';
494
+ log(deferralsWarned.has(`${deferralKey}:previous`)
495
+ ? `schedule ${record.id}: the firing deferred while its previous run was still going (slot ${slot}) outlasted its window; skipping to ${skippingTo}`
496
+ : deferralsWarned.has(deferralKey)
497
+ ? `schedule ${record.id}: the firing deferred by the per-agent cap (slot ${slot}) outlasted its window; skipping to ${skippingTo}`
498
+ : `schedule ${record.id}: missed firing(s) while the daemon was down; skipping to ${skippingTo}`);
326
499
  store.claimSlot(withNextFire(record, next), slot);
327
500
  deferralsWarned.delete(deferralKey);
501
+ deferralsWarned.delete(`${deferralKey}:previous`);
328
502
  return;
329
503
  }
330
504
  if (options.paused?.() === true) {
@@ -357,6 +531,25 @@ export const createSchedulerRuntime = (options) => {
357
531
  return;
358
532
  }
359
533
  deferralsWarned.delete(`${record.id}:${slot}`);
534
+ // Card 858: what this firing carries from the one before it. Read here,
535
+ // at the claim, so a daemon killed after a wrap-up still leaves the
536
+ // stopped session for this read; it's the one copy of the checkpoint.
537
+ let plan;
538
+ if (record.cadence.kind !== 'at' && record.lastSessionId !== undefined) {
539
+ const planned = await planResume(record, record.lastSessionId, slot);
540
+ if (planned === 'defer') {
541
+ return;
542
+ }
543
+ if (planned !== undefined) {
544
+ // An await sat between the readiness check and the claim below,
545
+ // which writes into the schedules table: check both again.
546
+ if (stopped) {
547
+ return;
548
+ }
549
+ await stillReady(record.id);
550
+ plan = planned;
551
+ }
552
+ }
360
553
  const firedAt = now.toISOString();
361
554
  const sessionId = `${SCHEDULE_SESSION_ID_PREFIX}${record.id}:${slot}`;
362
555
  // Advance from the scheduled slot, not from now: `following` is the
@@ -370,28 +563,53 @@ export const createSchedulerRuntime = (options) => {
370
563
  // and spent ATOMICALLY against the slot this tick read: a schedule
371
564
  // cancelled between the due scan and here is gone, and its prompt
372
565
  // must not run on a grant that was just revoked.
373
- if (!store.claimSlot(withNextFire(record, next, { lastFiredAt: firedAt, lastSessionId: sessionId }), slot)) {
566
+ let claimed = withNextFire(record, next, { lastFiredAt: firedAt, lastSessionId: sessionId });
567
+ if (plan !== undefined) {
568
+ // The streak and the fallback are written in the same claim as the
569
+ // slot, so a crash can't leave one without the other.
570
+ const { stoppedEarlyStreak: _streak, resumeFrom: _resumeFrom, ...rest } = claimed;
571
+ claimed = {
572
+ ...rest,
573
+ ...(plan.streak > 0 ? { stoppedEarlyStreak: plan.streak } : {}),
574
+ ...(plan.from !== undefined ? { resumeFrom: { firing: sessionId, from: plan.from.sessionId } } : {}),
575
+ };
576
+ }
577
+ if (!store.claimSlot(claimed, slot)) {
374
578
  log(`schedule ${record.id}: slot ${slot} was cancelled or already claimed; not firing`);
375
579
  return;
376
580
  }
377
- runningPerAgent.set(record.agentId, running + 1);
581
+ const resumeFrom = plan?.from;
582
+ // Re-read: a firing of this agent's may have settled during the await above.
583
+ runningPerAgent.set(record.agentId, (runningPerAgent.get(record.agentId) ?? 0) + 1);
378
584
  activeFirings.add(sessionId);
379
585
  const firing = dispatch({
380
586
  sessionId,
381
587
  agentId: record.agentId,
382
- userMessage: record.prompt,
588
+ userMessage: resumeFrom === undefined
589
+ ? record.prompt
590
+ : [record.prompt, renderResumeBlock(resumeFrom.run.stoppedEarly, resumeFrom.run.reply, plan.streak, record.destination !== undefined)].join('\n\n'),
383
591
  metadata: {
384
592
  [SCHEDULED_TURN_METADATA_KEY]: true,
385
593
  [SCHEDULE_ID_METADATA_KEY]: record.id,
386
594
  // The firing's "sender" is the session that wrote the prompt, at
387
595
  // the label it had then; a schedule set before labels existed is
388
- // `unknown`, like every other record from before.
389
- [SENDER_TRUST_METADATA_KEY]: record.trust ?? 'unknown',
596
+ // `unknown`, like every other record from before. A resumed firing
597
+ // also carries the stopped run's own words, so it's sent at the
598
+ // lower of the two, the rule `agent.delegate` uses for a child: a
599
+ // run that read a web page can't launder that page's text into a
600
+ // turn at the schedule's label.
601
+ [SENDER_TRUST_METADATA_KEY]: resumeFrom === undefined
602
+ ? record.trust ?? 'unknown'
603
+ : leastTrusted(record.trust ?? 'unknown', resumeFrom.run.trust),
390
604
  },
391
605
  }).then(() => {
392
606
  log(`schedule ${record.id} fired — session ${sessionId} completed`);
393
607
  }, (error) => {
394
608
  warn(`schedule ${record.id} firing failed: ${error instanceof Error ? error.message : String(error)}`);
609
+ }).then(async () => {
610
+ if (resumeFrom !== undefined) {
611
+ await dropFallback(record.id, sessionId);
612
+ }
395
613
  }).finally(() => {
396
614
  activeFirings.delete(sessionId);
397
615
  const count = (runningPerAgent.get(record.agentId) ?? 1) - 1;
@@ -494,7 +712,9 @@ export const createSchedulerRuntime = (options) => {
494
712
  const processEvents = (now) => {
495
713
  // Not before start(): a held daemon's scheduler never starts, and its
496
714
  // waiting firings must not go out through a delivery's kick either.
497
- if (!events || stopped || !started) {
715
+ // Read once: the closures below would not see a narrowed `let`.
716
+ const current = events;
717
+ if (!current || stopped || !started) {
498
718
  return;
499
719
  }
500
720
  const nowIso = now.toISOString();
@@ -505,7 +725,7 @@ export const createSchedulerRuntime = (options) => {
505
725
  if (eventsPrunedAt === undefined || now.getTime() - eventsPrunedAt >= EVENT_PRUNE_INTERVAL_MS) {
506
726
  eventsPrunedAt = now.getTime();
507
727
  try {
508
- events.store.prune(nowIso);
728
+ current.store.prune(nowIso);
509
729
  }
510
730
  catch (error) {
511
731
  warn(`events: could not prune old deliveries: ${error instanceof Error ? error.message : String(error)}`);
@@ -522,13 +742,13 @@ export const createSchedulerRuntime = (options) => {
522
742
  // A claimed batch stays itself; free firings group by trigger and key.
523
743
  // Map order is first appearance in `open()`, oldest first.
524
744
  const groups = new Map();
525
- for (const row of events.store.open()) {
745
+ for (const row of current.store.open()) {
526
746
  if (row.batchKey !== undefined && inflightBatches.has(row.batchKey)) {
527
747
  continue;
528
748
  }
529
749
  const trigger = triggerFor(row.triggerId);
530
750
  if (!trigger || trigger.agentId !== row.agentId) {
531
- events.store.retire([row.id], 'expired', 'Its trigger was cancelled.', nowIso);
751
+ current.store.retire([row.id], 'expired', 'Its trigger was cancelled.', nowIso);
532
752
  continue;
533
753
  }
534
754
  // Holds expire; a claim does not. A batch claimed before a crash may
@@ -536,7 +756,7 @@ export const createSchedulerRuntime = (options) => {
536
756
  // turn or sends an event that never went out, however long the
537
757
  // daemon was down.
538
758
  if (row.status !== 'firing' && now.getTime() - Date.parse(row.createdAt) > EVENT_HOLD_MAX_MS) {
539
- events.store.retire([row.id], 'expired', 'Held for more than 24 hours.', nowIso);
759
+ current.store.retire([row.id], 'expired', 'Held for more than 24 hours.', nowIso);
540
760
  continue;
541
761
  }
542
762
  const key = row.batchKey !== undefined ? `batch\n${row.batchKey}` : `key\n${row.triggerId}\n${row.coalescingKey}`;
@@ -547,7 +767,7 @@ export const createSchedulerRuntime = (options) => {
547
767
  const holdAs = (rows, status) => {
548
768
  const ids = rows.filter((row) => row.status !== status).map((row) => row.id);
549
769
  if (ids.length > 0) {
550
- events.store.hold(ids, status, nowIso);
770
+ current.store.hold(ids, status, nowIso);
551
771
  }
552
772
  };
553
773
  for (const [groupKey, all] of groups) {
@@ -559,7 +779,7 @@ export const createSchedulerRuntime = (options) => {
559
779
  const batched = rows[0].batchKey !== undefined;
560
780
  if (!batched && rows.length > EVENT_HELD_PER_KEY) {
561
781
  const dropped = rows.slice(0, rows.length - EVENT_HELD_PER_KEY);
562
- events.store.retire(dropped.map((row) => row.id), 'superseded', `Over ${EVENT_HELD_PER_KEY} waiting for one subject; newer ones were kept.`, nowIso);
782
+ current.store.retire(dropped.map((row) => row.id), 'superseded', `Over ${EVENT_HELD_PER_KEY} waiting for one subject; newer ones were kept.`, nowIso);
563
783
  rows = rows.slice(-EVENT_HELD_PER_KEY);
564
784
  }
565
785
  const { quietHours } = trigger.cadence;
@@ -578,16 +798,16 @@ export const createSchedulerRuntime = (options) => {
578
798
  eventDeferralsWarned.delete(groupKey);
579
799
  const maxPerHour = trigger.cadence.maxPerHour ?? DEFAULT_EVENT_MAX_PER_HOUR;
580
800
  const hourAgo = new Date(now.getTime() - 3_600_000).toISOString();
581
- if (events.store.turnsStartedSince(trigger.id, hourAgo, rows[0].batchKey) >= maxPerHour) {
801
+ if (current.store.turnsStartedSince(trigger.id, hourAgo, rows[0].batchKey) >= maxPerHour) {
582
802
  if (!batched && rows.length > 1) {
583
- events.store.retire(rows.slice(0, -1).map((row) => row.id), 'superseded', 'A newer event for the same subject replaced it while over the hourly limit.', nowIso);
803
+ current.store.retire(rows.slice(0, -1).map((row) => row.id), 'superseded', 'A newer event for the same subject replaced it while over the hourly limit.', nowIso);
584
804
  rows = rows.slice(-1);
585
805
  }
586
806
  holdAs(rows, 'rate_limited');
587
807
  continue;
588
808
  }
589
809
  const batchKey = rows[0].batchKey ?? rows[rows.length - 1].dispatchKey;
590
- if (!events.store.claim(rows.map((row) => row.id), batchKey, nowIso, { triggerId: trigger.id })) {
810
+ if (!current.store.claim(rows.map((row) => row.id), batchKey, nowIso, { triggerId: trigger.id })) {
591
811
  continue;
592
812
  }
593
813
  // When it last went out, on the trigger's own row, so `schedule.list`,
@@ -634,11 +854,18 @@ export const createSchedulerRuntime = (options) => {
634
854
  if (stopped) {
635
855
  break;
636
856
  }
637
- fireOrSkip(record, now);
857
+ await fireOrSkip(record, now);
638
858
  }
639
859
  processEvents(now);
640
860
  }
641
861
  catch (error) {
862
+ if (error instanceof NotReady) {
863
+ if (propagateReadiness) {
864
+ throw error.reason;
865
+ }
866
+ warn(`scheduler stopped before claiming schedule ${error.scheduleId}'s firing: ${error.message}`);
867
+ return;
868
+ }
642
869
  warn(`scheduler tick failed: ${error instanceof Error ? error.message : String(error)}`);
643
870
  }
644
871
  if (!stopped) {
@@ -663,6 +890,13 @@ export const createSchedulerRuntime = (options) => {
663
890
  filter: record.cadence.filter,
664
891
  }));
665
892
  },
893
+ attachEvents(attached) {
894
+ if (events) {
895
+ throw new Error('This scheduler already takes events; its sources are read live, so add a source there instead.');
896
+ }
897
+ events = attached;
898
+ maxEventsPerAgent = attached.maxConcurrentPerAgent ?? 1;
899
+ },
666
900
  kickEvents() {
667
901
  try {
668
902
  processEvents(clock());