@ours.network/fleet 1.2.0-nightly.64 → 1.2.0-nightly.66

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.
@@ -0,0 +1,635 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { ReminderScheduleError, resolveSchedule } from './schedule.js';
6
+ /**
7
+ * Durable, private, single-writer state for one agent's self-reminders.
8
+ *
9
+ * The runner that supervises the agent is the only writer: the agent's MCP
10
+ * tools, the dispatch timer and the session's release guard all run in that
11
+ * one process, and every mutation here is synchronous, so each method call is
12
+ * its own linearization point. Asynchronous callers (a queue receipt, a turn
13
+ * completion) never carry state across an `await`; they re-read the current
14
+ * record and apply only forward transitions, so a late receipt can never undo
15
+ * an acknowledgement, update or cancellation that happened meanwhile.
16
+ *
17
+ * Event lifecycle — the boundary that matters is `dispatched`:
18
+ *
19
+ * pending ──admit──▶ admitting ──release guard──▶ dispatched ──ack──▶ acknowledged
20
+ * ▲ │ │
21
+ * └── not released ───┘ └─ restart ─▶ uncertain (held)
22
+ *
23
+ * `dispatched` is written durably by the session's release guard immediately
24
+ * before the prompt is handed to the model. An event found `admitting` after a
25
+ * restart therefore provably never reached the model (reminder prompt bodies
26
+ * are not retained, so session recovery cannot replay them) and is retried
27
+ * under the same event id. An event found `dispatched` may or may not have been
28
+ * seen, so it is held as `uncertain` and never automatically re-admitted.
29
+ */
30
+ export const SELF_REMINDERS_FILE = '.self-reminders.json';
31
+ export const MAX_OPEN_REMINDERS = 100;
32
+ export const MAX_RETAINED_CLOSED = 200;
33
+ export const DEFAULT_LATE_WINDOW_SECONDS = 3600;
34
+ export const MAX_LATE_WINDOW_SECONDS = 7 * 24 * 3600;
35
+ export const MIN_LATE_WINDOW_SECONDS = 60;
36
+ const MAX_HORIZON_MS = 5 * 366 * 24 * 3600 * 1000;
37
+ const MAX_TITLE_CHARS = 200;
38
+ const MAX_NOTE_CHARS = 4000;
39
+ const MAX_REQUEST_KEY_CHARS = 200;
40
+ export class ReminderError extends Error {
41
+ code;
42
+ constructor(code, message) {
43
+ super(message);
44
+ this.code = code;
45
+ this.name = 'ReminderError';
46
+ }
47
+ }
48
+ const durableWrite = (path, contents) => replaceFileAtomically(path, contents, 0o600, {}, { durable: true });
49
+ const OPEN_EVENT_STATES = new Set(['pending', 'admitting', 'dispatched', 'uncertain']);
50
+ const iso = (ms) => new Date(ms).toISOString();
51
+ function digest(value) {
52
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
53
+ }
54
+ function text(raw, field, max, required) {
55
+ if (raw === undefined || raw === null) {
56
+ if (required)
57
+ throw new ReminderError('invalid_request', `${field} is required`);
58
+ return '';
59
+ }
60
+ if (typeof raw !== 'string')
61
+ throw new ReminderError('invalid_request', `${field} must be text`);
62
+ const value = raw.replace(/\r\n?/g, '\n').normalize('NFC');
63
+ if (required && !value.trim())
64
+ throw new ReminderError('invalid_request', `${field} must not be blank`);
65
+ if (value.includes('\0'))
66
+ throw new ReminderError('invalid_request', `${field} must not contain NUL`);
67
+ if (Array.from(value).length > max)
68
+ throw new ReminderError('invalid_request', `${field} exceeds ${max} characters`);
69
+ return value;
70
+ }
71
+ function lateWindow(raw) {
72
+ if (raw === undefined || raw === null)
73
+ return DEFAULT_LATE_WINDOW_SECONDS;
74
+ if (typeof raw !== 'number' || !Number.isSafeInteger(raw)
75
+ || raw < MIN_LATE_WINDOW_SECONDS || raw > MAX_LATE_WINDOW_SECONDS)
76
+ throw new ReminderError('invalid_request', `late_window_seconds must be a whole number from ${MIN_LATE_WINDOW_SECONDS} to ${MAX_LATE_WINDOW_SECONDS}`);
77
+ return raw;
78
+ }
79
+ function revisionOf(raw) {
80
+ if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 1)
81
+ throw new ReminderError('invalid_request', 'expected_revision must be the current positive revision');
82
+ return raw;
83
+ }
84
+ export class ReminderStore {
85
+ owner;
86
+ now;
87
+ log;
88
+ write;
89
+ path;
90
+ state;
91
+ /**
92
+ * Load or create the store for exactly this owner (see `ReminderOwner`). A
93
+ * store written for anyone else is never adopted: it is moved aside and
94
+ * nothing in it is delivered, so a same-named successor or a replaced
95
+ * identity cannot inherit a predecessor's wakes.
96
+ */
97
+ constructor(stateDir, owner, now, log = () => { }, write = durableWrite) {
98
+ this.owner = owner;
99
+ this.now = now;
100
+ this.log = log;
101
+ this.write = write;
102
+ this.path = join(stateDir, SELF_REMINDERS_FILE);
103
+ let restored;
104
+ if (existsSync(this.path)) {
105
+ let reason;
106
+ try {
107
+ if (!privateFile(this.path))
108
+ throw new Error('insecure');
109
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
110
+ if (!validFile(parsed))
111
+ reason = 'corrupt';
112
+ else if (!sameOwner(parsed.owner, owner))
113
+ reason = 'foreign-owner';
114
+ else {
115
+ // A permanent agent's runtime is re-provisioned on every orderly
116
+ // start; record the current one, keep the reminders.
117
+ parsed.owner = { ...owner };
118
+ restored = parsed;
119
+ }
120
+ }
121
+ catch {
122
+ reason = 'corrupt';
123
+ }
124
+ if (!restored) {
125
+ const aside = `${this.path}.${reason}-${now()}`;
126
+ renameSync(this.path, aside);
127
+ chmodSync(aside, 0o600);
128
+ this.log(`[${owner.role}] self-reminder store ${reason}; moved aside, nothing in it will be delivered`);
129
+ }
130
+ }
131
+ this.state = restored ?? { version: 1, owner: { ...owner }, reminders: {}, events: {} };
132
+ if (restored)
133
+ this.recover();
134
+ this.persist();
135
+ }
136
+ /** Restart recovery. Never re-admits anything that may have reached the model. */
137
+ recover() {
138
+ const at = iso(this.now());
139
+ for (const event of Object.values(this.state.events)) {
140
+ if (event.state === 'admitting') {
141
+ // The release guard never ran, so the model never saw it.
142
+ event.state = 'pending';
143
+ delete event.promptId;
144
+ delete event.retryAt;
145
+ }
146
+ else if (event.state === 'dispatched') {
147
+ event.state = 'uncertain';
148
+ event.reason = 'restarted_after_dispatch';
149
+ event.closedAt = at;
150
+ }
151
+ }
152
+ }
153
+ persist() {
154
+ this.write(this.path, JSON.stringify(this.state, null, 2) + '\n');
155
+ chmodSync(this.path, 0o600);
156
+ }
157
+ /**
158
+ * Apply a mutation and persist it as one step. If the write fails the
159
+ * in-memory state is restored, so memory never claims what disk does not.
160
+ */
161
+ commit(mutate) {
162
+ const before = structuredClone(this.state);
163
+ try {
164
+ const result = mutate(this.state);
165
+ this.persist();
166
+ return result;
167
+ }
168
+ catch (error) {
169
+ this.state = before;
170
+ throw error;
171
+ }
172
+ }
173
+ reminder(id) {
174
+ if (typeof id !== 'string' || !Object.hasOwn(this.state.reminders, id))
175
+ throw new ReminderError('not_found', 'no such reminder for this agent');
176
+ return this.state.reminders[id];
177
+ }
178
+ event(id) {
179
+ if (typeof id !== 'string' || !Object.hasOwn(this.state.events, id))
180
+ throw new ReminderError('not_found', 'no such reminder event for this agent');
181
+ return this.state.events[id];
182
+ }
183
+ schedule(input, previous) {
184
+ const title = input.title === undefined && previous ? previous.title : text(input.title, 'title', MAX_TITLE_CHARS, true);
185
+ const note = input.note === undefined && previous ? previous.note : text(input.note, 'note', MAX_NOTE_CHARS, false);
186
+ const retime = !previous || input.local_time !== undefined || input.time_zone !== undefined || input.fold !== undefined;
187
+ const late = input.late_window_seconds === undefined && previous
188
+ ? previous.lateWindowSeconds : lateWindow(input.late_window_seconds);
189
+ let when;
190
+ if (retime) {
191
+ try {
192
+ when = resolveSchedule(input.local_time ?? previous?.localTime, input.time_zone ?? previous?.timeZone, input.fold === undefined ? (input.local_time === undefined && input.time_zone === undefined ? previous?.fold : undefined) : input.fold);
193
+ }
194
+ catch (error) {
195
+ if (error instanceof ReminderScheduleError)
196
+ throw new ReminderError(error.code, error.message);
197
+ throw error;
198
+ }
199
+ const now = this.now();
200
+ if (when.dueAt <= now)
201
+ throw new ReminderError('not_in_future', 'the reminder time must be in the future');
202
+ if (when.dueAt - now > MAX_HORIZON_MS)
203
+ throw new ReminderError('too_far', 'the reminder time must be within five years');
204
+ }
205
+ else {
206
+ when = { localTime: previous.localTime, timeZone: previous.timeZone, fold: previous.fold, dueAt: Date.parse(previous.dueAt) };
207
+ }
208
+ return { title, note, ...when, late };
209
+ }
210
+ openCount() {
211
+ return Object.values(this.state.reminders).filter(item => item.state === 'active' || item.state === 'delivered').length;
212
+ }
213
+ create(requestKey, input) {
214
+ const key = text(requestKey, 'request_key', MAX_REQUEST_KEY_CHARS, true);
215
+ const requestDigest = digest({
216
+ title: input.title ?? null, note: input.note ?? null, local_time: input.local_time ?? null,
217
+ time_zone: input.time_zone ?? null, fold: input.fold ?? null,
218
+ late_window_seconds: input.late_window_seconds ?? null,
219
+ });
220
+ const existing = Object.values(this.state.reminders).find(item => item.requestKey === key);
221
+ if (existing) {
222
+ if (existing.requestDigest !== requestDigest)
223
+ throw new ReminderError('request_key_conflict', 'request_key was already used with different input');
224
+ return { reminder: this.view(existing), created: false };
225
+ }
226
+ if (this.openCount() >= MAX_OPEN_REMINDERS)
227
+ throw new ReminderError('limit_reached', `at most ${MAX_OPEN_REMINDERS} open reminders per agent`);
228
+ const values = this.schedule(input);
229
+ return this.commit(state => {
230
+ const at = iso(this.now());
231
+ const record = {
232
+ id: `rem_${randomUUID()}`, requestKey: key, requestDigest, revision: 1, state: 'active',
233
+ title: values.title, note: values.note, localTime: values.localTime, timeZone: values.timeZone,
234
+ ...(values.fold === undefined ? {} : { fold: values.fold }),
235
+ dueAt: iso(values.dueAt), lateWindowSeconds: values.late,
236
+ expiresAt: iso(values.dueAt + values.late * 1000), createdAt: at, updatedAt: at,
237
+ };
238
+ state.reminders[record.id] = record;
239
+ this.prune(state);
240
+ return { reminder: this.view(record), created: true };
241
+ });
242
+ }
243
+ update(id, expectedRevision, input) {
244
+ const record = this.reminder(id);
245
+ const revision = revisionOf(expectedRevision);
246
+ if (record.revision !== revision)
247
+ throw new ReminderError('stale_revision', `reminder is at revision ${record.revision}`);
248
+ if (record.state !== 'active' && record.state !== 'delivered')
249
+ throw new ReminderError('closed', `reminder is ${record.state}`);
250
+ if (Object.values(input).every(value => value === undefined))
251
+ throw new ReminderError('invalid_request', 'nothing to update');
252
+ const values = this.schedule(input, record);
253
+ return this.commit(state => {
254
+ const target = state.reminders[record.id];
255
+ const at = iso(this.now());
256
+ this.supersedeEvents(state, target.id, 'superseded_by_update', at);
257
+ Object.assign(target, {
258
+ revision: target.revision + 1, state: 'active',
259
+ title: values.title, note: values.note, localTime: values.localTime, timeZone: values.timeZone,
260
+ dueAt: iso(values.dueAt), lateWindowSeconds: values.late,
261
+ expiresAt: iso(values.dueAt + values.late * 1000), updatedAt: at,
262
+ });
263
+ if (values.fold === undefined)
264
+ delete target.fold;
265
+ else
266
+ target.fold = values.fold;
267
+ delete target.closedReason;
268
+ return this.view(target);
269
+ });
270
+ }
271
+ cancel(id, expectedRevision) {
272
+ const record = this.reminder(id);
273
+ const revision = revisionOf(expectedRevision);
274
+ if (record.revision !== revision)
275
+ throw new ReminderError('stale_revision', `reminder is at revision ${record.revision}`);
276
+ if (record.state !== 'active' && record.state !== 'delivered')
277
+ throw new ReminderError('closed', `reminder is ${record.state}`);
278
+ return this.commit(state => {
279
+ const target = state.reminders[record.id];
280
+ const at = iso(this.now());
281
+ this.supersedeEvents(state, target.id, 'cancelled', at);
282
+ target.revision++;
283
+ target.state = 'cancelled';
284
+ target.closedReason = 'cancelled';
285
+ target.updatedAt = at;
286
+ this.prune(state);
287
+ return this.view(target);
288
+ });
289
+ }
290
+ list(filter = {}) {
291
+ const scope = filter.state ?? 'open';
292
+ if (!['open', 'closed', 'all'].includes(scope))
293
+ throw new ReminderError('invalid_request', "state must be 'open', 'closed' or 'all'");
294
+ const limit = filter.limit ?? 50;
295
+ if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
296
+ throw new ReminderError('invalid_request', 'limit must be 1..100');
297
+ if (filter.after !== undefined && typeof filter.after !== 'string')
298
+ throw new ReminderError('invalid_request', 'after must be a reminder id from a previous page');
299
+ const open = (item) => item.state === 'active' || item.state === 'delivered';
300
+ const rows = Object.values(this.state.reminders)
301
+ .filter(item => scope === 'all' || (scope === 'open') === open(item))
302
+ .sort((a, b) => a.dueAt.localeCompare(b.dueAt) || a.id.localeCompare(b.id));
303
+ const start = filter.after ? rows.findIndex(item => item.id === filter.after) + 1 : 0;
304
+ if (filter.after && start === 0)
305
+ throw new ReminderError('invalid_request', 'after does not name a reminder in this listing');
306
+ const page = rows.slice(start, start + limit);
307
+ const more = start + limit < rows.length;
308
+ return {
309
+ reminders: page.map(item => this.view(item)),
310
+ more, ...(more ? { after: page.at(-1).id } : {}),
311
+ uncertain_events: Object.values(this.state.events).filter(event => event.state === 'uncertain').length,
312
+ };
313
+ }
314
+ inspect(eventId) {
315
+ const event = this.event(eventId);
316
+ const record = this.state.reminders[event.reminderId];
317
+ const now = this.now();
318
+ const current = Boolean(record && record.revision === event.revision);
319
+ const eligible = Boolean(record && current && (event.state === 'dispatched' || event.state === 'uncertain')
320
+ && (record.state === 'delivered' || record.state === 'active'));
321
+ return {
322
+ event: this.eventView(event),
323
+ reminder: record ? this.view(record) : undefined,
324
+ current, eligible,
325
+ late_by_ms: record ? Math.max(0, now - Date.parse(record.dueAt)) : undefined,
326
+ };
327
+ }
328
+ ack(eventId, expectedRevision) {
329
+ const event = this.event(eventId);
330
+ const revision = revisionOf(expectedRevision);
331
+ if (event.revision !== revision)
332
+ throw new ReminderError('stale_revision', `event belongs to revision ${event.revision}`);
333
+ if (event.state === 'acknowledged')
334
+ return { event: this.eventView(event), acknowledged: true, duplicate: true };
335
+ if (event.state !== 'dispatched' && event.state !== 'uncertain')
336
+ throw new ReminderError('not_acknowledgeable', `event is ${event.state}`);
337
+ const record = this.state.reminders[event.reminderId];
338
+ if (!record || record.revision !== event.revision)
339
+ throw new ReminderError('stale_revision', 'the reminder changed after this wake; this event is no longer current');
340
+ return this.commit(state => {
341
+ const target = state.events[event.id];
342
+ const at = iso(this.now());
343
+ target.state = 'acknowledged';
344
+ target.closedAt = at;
345
+ const owner = state.reminders[target.reminderId];
346
+ owner.state = 'acknowledged';
347
+ owner.closedReason = 'acknowledged';
348
+ owner.updatedAt = at;
349
+ this.prune(state);
350
+ return { event: this.eventView(target), acknowledged: true, duplicate: false };
351
+ });
352
+ }
353
+ // ── dispatch side (runner only) ────────────────────────────────────────────
354
+ /**
355
+ * Earliest instant the dispatcher has work, or undefined when idle.
356
+ * `live` names admissions still owned by a queued prompt in this process:
357
+ * their own completion re-arms the dispatcher, so they are not work here.
358
+ */
359
+ nextWorkAt(live = new Set()) {
360
+ let next;
361
+ const consider = (value) => { next = next === undefined ? value : Math.min(next, value); };
362
+ for (const record of Object.values(this.state.reminders)) {
363
+ if (record.state !== 'active')
364
+ continue;
365
+ const open = this.openEvent(record);
366
+ if (open && live.has(open.id))
367
+ continue;
368
+ if (!open)
369
+ consider(Date.parse(record.dueAt));
370
+ else if (open.state === 'pending')
371
+ consider(open.retryAt ? Date.parse(open.retryAt) : Date.parse(record.dueAt));
372
+ else if (open.state === 'admitting')
373
+ consider(Date.parse(record.dueAt));
374
+ if (!open || open.state === 'pending' || open.state === 'admitting')
375
+ consider(Date.parse(record.expiresAt));
376
+ }
377
+ return next;
378
+ }
379
+ openEvent(record) {
380
+ return Object.values(this.state.events).find(event => event.reminderId === record.id
381
+ && event.revision === record.revision && OPEN_EVENT_STATES.has(event.state));
382
+ }
383
+ /**
384
+ * Expire what is past its window and create a pending event for each due
385
+ * reminder. Returns the events ready to admit now, earliest due first.
386
+ */
387
+ sweep(live = new Set()) {
388
+ const now = this.now();
389
+ let changed = false;
390
+ const ready = [];
391
+ const next = structuredClone(this.state);
392
+ for (const record of Object.values(next.reminders).sort((a, b) => a.dueAt.localeCompare(b.dueAt))) {
393
+ if (record.state !== 'active')
394
+ continue;
395
+ const open = Object.values(next.events).find(event => event.reminderId === record.id
396
+ && event.revision === record.revision && OPEN_EVENT_STATES.has(event.state));
397
+ if (open && live.has(open.id))
398
+ continue;
399
+ if (open?.state === 'admitting') {
400
+ // No queued prompt in this process owns it (a write that would have
401
+ // returned it to pending failed). The release guard never committed
402
+ // a dispatch, so it is still undispatched: the same move as restart
403
+ // recovery, made here so it can neither strand nor spin.
404
+ open.state = 'pending';
405
+ delete open.promptId;
406
+ delete open.retryAt;
407
+ changed = true;
408
+ }
409
+ if (now >= Date.parse(record.expiresAt) && (!open || open.state === 'pending')) {
410
+ if (open) {
411
+ open.state = 'expired';
412
+ open.closedAt = iso(now);
413
+ }
414
+ record.state = 'expired';
415
+ record.closedReason = 'late_window_elapsed';
416
+ record.updatedAt = iso(now);
417
+ changed = true;
418
+ continue;
419
+ }
420
+ if (now < Date.parse(record.dueAt))
421
+ continue;
422
+ if (!open) {
423
+ const event = {
424
+ id: `rev_${randomUUID()}`, reminderId: record.id, revision: record.revision,
425
+ state: 'pending', createdAt: iso(now), attempts: 0,
426
+ };
427
+ next.events[event.id] = event;
428
+ changed = true;
429
+ ready.push(event);
430
+ }
431
+ else if (open.state === 'pending' && (!open.retryAt || Date.parse(open.retryAt) <= now)) {
432
+ ready.push(open);
433
+ }
434
+ }
435
+ if (changed) {
436
+ this.prune(next);
437
+ const before = this.state;
438
+ this.state = next;
439
+ try {
440
+ this.persist();
441
+ }
442
+ catch (error) {
443
+ this.state = before;
444
+ throw error;
445
+ }
446
+ }
447
+ return ready.map(event => structuredClone(this.state.events[event.id]));
448
+ }
449
+ /** Durable claim made before the prompt is queued. False: do not queue. */
450
+ markAdmitting(eventId) {
451
+ const event = this.state.events[eventId];
452
+ const record = event && this.state.reminders[event.reminderId];
453
+ const now = this.now();
454
+ if (!event || event.state !== 'pending' || !record || record.state !== 'active'
455
+ || record.revision !== event.revision || now < Date.parse(record.dueAt)
456
+ || now >= Date.parse(record.expiresAt))
457
+ return false;
458
+ return this.commit(() => {
459
+ event.state = 'admitting';
460
+ event.attempts++;
461
+ event.admittedAt = iso(this.now());
462
+ delete event.retryAt;
463
+ return true;
464
+ });
465
+ }
466
+ /** Record the session's queue receipt; never moves an event backwards. */
467
+ recordReceipt(eventId, promptId) {
468
+ const event = this.state.events[eventId];
469
+ if (!event || (event.state !== 'admitting' && event.state !== 'dispatched') || event.promptId)
470
+ return;
471
+ this.commit(() => { event.promptId = promptId; });
472
+ }
473
+ /**
474
+ * The release guard: called synchronously by the session at the moment the
475
+ * prompt would be handed to the model. It returns true only after
476
+ * `dispatched` is durably on disk; any doubt refuses the dispatch.
477
+ */
478
+ claimDispatch(eventId, stopping) {
479
+ const event = this.state.events[eventId];
480
+ const record = event && this.state.reminders[event.reminderId];
481
+ if (!event || event.state !== 'admitting')
482
+ return { dispatch: false, reason: 'event_not_admitting' };
483
+ if (stopping)
484
+ return { dispatch: false, reason: 'stopping' };
485
+ const at = this.now();
486
+ const close = (state, reason) => {
487
+ try {
488
+ this.commit(() => { event.state = state; event.reason = reason; event.closedAt = iso(at); });
489
+ }
490
+ catch { /* left admitting: recovery returns it to pending, still undispatched */ }
491
+ return { dispatch: false, reason };
492
+ };
493
+ if (!record || record.revision !== event.revision || record.state !== 'active')
494
+ return close('invalidated', 'stale_revision');
495
+ // The wall clock can step back after admission. Not due yet means not yet:
496
+ // the event stays undispatched and is admitted again once it is due.
497
+ if (at < Date.parse(record.dueAt))
498
+ return { dispatch: false, reason: 'not_due' };
499
+ if (at >= Date.parse(record.expiresAt)) {
500
+ try {
501
+ this.commit(() => {
502
+ event.state = 'expired';
503
+ event.reason = 'late_window_elapsed';
504
+ event.closedAt = iso(at);
505
+ record.state = 'expired';
506
+ record.closedReason = 'late_window_elapsed';
507
+ record.updatedAt = iso(at);
508
+ });
509
+ }
510
+ catch { /* undispatched either way */ }
511
+ return { dispatch: false, reason: 'expired' };
512
+ }
513
+ try {
514
+ this.commit(() => {
515
+ event.state = 'dispatched';
516
+ event.dispatchedAt = iso(at);
517
+ record.state = 'delivered';
518
+ record.updatedAt = iso(at);
519
+ });
520
+ }
521
+ catch {
522
+ return { dispatch: false, reason: 'dispatch_commit_failed' };
523
+ }
524
+ return { dispatch: true };
525
+ }
526
+ /**
527
+ * An admission that ended without the release guard dispatching it (queue
528
+ * refused, session died, release refused while stopping) goes back to
529
+ * pending. Anything already past `admitting` is left exactly as it is.
530
+ */
531
+ releaseUndispatched(eventId, retryAt) {
532
+ const event = this.state.events[eventId];
533
+ if (!event || event.state !== 'admitting')
534
+ return;
535
+ try {
536
+ this.commit(() => {
537
+ event.state = 'pending';
538
+ event.retryAt = iso(retryAt);
539
+ delete event.promptId;
540
+ });
541
+ }
542
+ catch { /* stays admitting; restart recovery makes the same move */ }
543
+ }
544
+ eventSummary(eventId) {
545
+ const event = this.state.events[eventId];
546
+ const reminder = event && this.state.reminders[event.reminderId];
547
+ return event && reminder ? structuredClone({ event, reminder }) : undefined;
548
+ }
549
+ supersedeEvents(state, reminderId, reason, at) {
550
+ for (const event of Object.values(state.events))
551
+ if (event.reminderId === reminderId && OPEN_EVENT_STATES.has(event.state)) {
552
+ event.state = 'invalidated';
553
+ event.reason = reason;
554
+ event.closedAt = at;
555
+ }
556
+ }
557
+ /** Bound retained history; open reminders and their events are never dropped. */
558
+ prune(state) {
559
+ const closed = Object.values(state.reminders)
560
+ .filter(item => item.state !== 'active' && item.state !== 'delivered'
561
+ && !Object.values(state.events).some(event => event.reminderId === item.id && event.state === 'uncertain'))
562
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
563
+ for (const item of closed.slice(MAX_RETAINED_CLOSED)) {
564
+ delete state.reminders[item.id];
565
+ for (const [id, event] of Object.entries(state.events))
566
+ if (event.reminderId === item.id)
567
+ delete state.events[id];
568
+ }
569
+ }
570
+ view(record) {
571
+ const event = Object.values(this.state.events)
572
+ .filter(item => item.reminderId === record.id && item.revision === record.revision)
573
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
574
+ return {
575
+ id: record.id, request_key: record.requestKey, revision: record.revision, state: record.state,
576
+ title: record.title, note: record.note,
577
+ local_time: record.localTime, time_zone: record.timeZone,
578
+ ...(record.fold === undefined ? {} : { fold: record.fold }),
579
+ due_at: record.dueAt, late_window_seconds: record.lateWindowSeconds, expires_at: record.expiresAt,
580
+ created_at: record.createdAt, updated_at: record.updatedAt,
581
+ ...(record.closedReason ? { closed_reason: record.closedReason } : {}),
582
+ ...(event ? { event: { id: event.id, state: event.state, revision: event.revision } } : {}),
583
+ };
584
+ }
585
+ eventView(event) {
586
+ return {
587
+ id: event.id, reminder_id: event.reminderId, revision: event.revision, state: event.state,
588
+ created_at: event.createdAt,
589
+ ...(event.dispatchedAt ? { dispatched_at: event.dispatchedAt } : {}),
590
+ ...(event.closedAt ? { closed_at: event.closedAt } : {}),
591
+ ...(event.reason ? { reason: event.reason } : {}),
592
+ };
593
+ }
594
+ }
595
+ function sameOwner(a, b) {
596
+ if (a.role !== b.role || a.lifetime !== b.lifetime || a.cid.toUpperCase() !== b.cid.toUpperCase())
597
+ return false;
598
+ return a.lifetime === 'permanent' || (a.instance === b.instance && a.action === b.action);
599
+ }
600
+ function privateFile(path) {
601
+ const stat = lstatSync(path);
602
+ const uid = process.getuid?.();
603
+ return stat.isFile() && !stat.isSymbolicLink() && (stat.mode & 0o077) === 0
604
+ && (uid === undefined || stat.uid === uid);
605
+ }
606
+ function validFile(value) {
607
+ if (!value || typeof value !== 'object')
608
+ return false;
609
+ const file = value;
610
+ const owner = file.owner;
611
+ if (file.version !== 1 || !owner || typeof owner !== 'object'
612
+ || !['role', 'instance', 'cid', 'action'].every(key => typeof owner[key] === 'string')
613
+ || (owner.lifetime !== 'permanent' && owner.lifetime !== 'temporary')
614
+ || !file.reminders || typeof file.reminders !== 'object' || !file.events || typeof file.events !== 'object')
615
+ return false;
616
+ const reminderStates = new Set(['active', 'delivered', 'acknowledged', 'cancelled', 'expired']);
617
+ const eventStates = new Set(['pending', 'admitting', 'dispatched', 'uncertain', 'acknowledged', 'invalidated', 'expired']);
618
+ return Object.entries(file.reminders).every(([id, item]) => item && item.id === id
619
+ && Number.isSafeInteger(item.revision) && item.revision >= 1 && reminderStates.has(item.state)
620
+ && typeof item.requestKey === 'string' && typeof item.title === 'string' && typeof item.note === 'string'
621
+ && Number.isFinite(Date.parse(item.dueAt)) && Number.isFinite(Date.parse(item.expiresAt)))
622
+ && Object.entries(file.events).every(([id, item]) => item && item.id === id
623
+ && typeof item.reminderId === 'string' && Number.isSafeInteger(item.revision) && eventStates.has(item.state)
624
+ && Number.isSafeInteger(item.attempts));
625
+ }
626
+ /** Remove a retired agent's reminder store and any quarantined copies. */
627
+ export function removeReminderState(stateDir) {
628
+ rmSync(join(stateDir, SELF_REMINDERS_FILE), { force: true });
629
+ try {
630
+ for (const name of readdirSync(stateDir))
631
+ if (name.startsWith(`${SELF_REMINDERS_FILE}.`) || name.startsWith(`.${SELF_REMINDERS_FILE}.`))
632
+ rmSync(join(stateDir, name), { force: true });
633
+ }
634
+ catch { /* directory already gone */ }
635
+ }
@@ -0,0 +1,20 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type ReminderStore } from './store.js';
3
+ /**
4
+ * What the agent's managed MCP endpoint needs from its runner. Absent until
5
+ * the runner has bound the store to this exact runtime instance and identity.
6
+ */
7
+ export interface ReminderToolBackend {
8
+ store: ReminderStore;
9
+ /** Re-arm the dispatcher after a schedule change. */
10
+ changed(): void;
11
+ }
12
+ export declare const REMINDER_TOOL_NAMES: readonly ["fleet_reminder_create", "fleet_reminder_list", "fleet_reminder_update", "fleet_reminder_cancel", "fleet_reminder_inspect", "fleet_reminder_ack"];
13
+ /**
14
+ * Register the self-reminder tools on the agent's own fixed-identity MCP
15
+ * server. No argument names an agent, identity or recipient: the server is the
16
+ * caller's own supervisor, so every operation is implicitly self-scoped.
17
+ */
18
+ export declare function registerReminderTools(server: McpServer, backend: () => ReminderToolBackend | undefined,
19
+ /** The endpoint's runtime admission; nothing is read or changed unless it admits. */
20
+ admit: () => Promise<() => void>): void;