@stratusagent/gateway 0.14.0 → 0.14.2
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/dist/event-intake.d.ts +19 -1
- package/dist/event-intake.d.ts.map +1 -1
- package/dist/event-intake.js +88 -12
- package/dist/event-intake.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +1 -6
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +82 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +548 -48
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +43 -1
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +258 -24
- package/dist/schedules.js.map +1 -1
- package/dist/sessions.d.ts +15 -7
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +89 -19
- package/dist/sessions.js.map +1 -1
- package/package.json +7 -7
package/dist/schedules.d.ts
CHANGED
|
@@ -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
|
package/dist/schedules.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schedules.d.ts","sourceRoot":"","sources":["../src/schedules.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
801
|
+
if (current.store.turnsStartedSince(trigger.id, hourAgo, rows[0].batchKey) >= maxPerHour) {
|
|
582
802
|
if (!batched && rows.length > 1) {
|
|
583
|
-
|
|
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 (!
|
|
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());
|