@ours.network/fleet 1.2.0-nightly.68 → 1.2.0-nightly.69
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/briefing.js +3 -1
- package/dist/build-info.json +4 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +28 -6
- package/dist/preset-migration.js +9 -0
- package/dist/reminders/manager.d.ts +11 -4
- package/dist/reminders/manager.js +71 -5
- package/dist/reminders/store.d.ts +100 -0
- package/dist/reminders/store.js +132 -5
- package/dist/reminders/tools.js +7 -1
- package/dist/session/arbiter.d.ts +60 -0
- package/dist/session/arbiter.js +180 -3
- package/dist/session/types.d.ts +3 -1
- package/docs/self-reminders.md +105 -7
- package/package.json +1 -1
- package/presets/fleet/roles/Critic.yaml +13 -0
- package/presets/fleet/roles/Developer.yaml +13 -0
- package/presets/fleet/roles/Engineer.yaml +13 -0
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
* are not retained, so session recovery cannot replay them) and is retried
|
|
22
22
|
* under the same event id. An event found `dispatched` may or may not have been
|
|
23
23
|
* seen, so it is held as `uncertain` and never automatically re-admitted.
|
|
24
|
+
*
|
|
25
|
+
* Urgency is an explicit opt-in per reminder (`interrupt`). Absent means false,
|
|
26
|
+
* so stores written before it existed keep their exact idle-only meaning, and
|
|
27
|
+
* an older runtime that ignores the field stays idle-only. An urgent event may
|
|
28
|
+
* request at most ONE interruption of the agent's current turn per revision:
|
|
29
|
+
* the request is durably recorded before the session is asked to cancel, so a
|
|
30
|
+
* crash, a forced adapter restart or a second urgent reminder can never turn
|
|
31
|
+
* it into a cancellation loop. Every later admission of that event is
|
|
32
|
+
* idle-only, and only the release guard ever records `dispatched`.
|
|
24
33
|
*/
|
|
25
34
|
export declare const SELF_REMINDERS_FILE = ".self-reminders.json";
|
|
26
35
|
export declare const MAX_OPEN_REMINDERS = 100;
|
|
@@ -70,6 +79,40 @@ export interface ReminderRecord {
|
|
|
70
79
|
createdAt: string;
|
|
71
80
|
updatedAt: string;
|
|
72
81
|
closedReason?: string;
|
|
82
|
+
/** Present (true) only for an urgent reminder; absent is the idle-only default. */
|
|
83
|
+
interrupt?: true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What became of an urgent event's single interruption request. `requested` is
|
|
87
|
+
* the durable mark written before the session is asked to cancel; it is never
|
|
88
|
+
* evidence that anything was cancelled or delivered.
|
|
89
|
+
*
|
|
90
|
+
* - `cancelled`: the target turn's own terminal result says it was cancelled
|
|
91
|
+
* by this request (correlated, not inferred).
|
|
92
|
+
* - `ended`: the session reported the cancellation settled, but the target's
|
|
93
|
+
* terminal could not be correlated, or it ended some other way.
|
|
94
|
+
* - `forced`: the adapter ignored the cancel and was restarted.
|
|
95
|
+
* - `failed`: the control call failed; it may or may not have reached the adapter.
|
|
96
|
+
* - `unknown`: the supervisor restarted before the outcome was recorded.
|
|
97
|
+
*/
|
|
98
|
+
export type ReminderInterruptOutcome = 'requested' | 'cancelled' | 'ended' | 'forced' | 'failed' | 'unknown';
|
|
99
|
+
/**
|
|
100
|
+
* The exact turn an urgent event asked to stop: typed provenance and the
|
|
101
|
+
* session's own identifiers for it (prompt id within that session generation),
|
|
102
|
+
* never a body.
|
|
103
|
+
*/
|
|
104
|
+
export interface ReminderInterruptTarget {
|
|
105
|
+
source: string;
|
|
106
|
+
promptId: string;
|
|
107
|
+
generation: string;
|
|
108
|
+
sessionId?: string;
|
|
109
|
+
}
|
|
110
|
+
export interface ReminderEventInterrupt {
|
|
111
|
+
requestedAt: string;
|
|
112
|
+
target: ReminderInterruptTarget;
|
|
113
|
+
outcome: ReminderInterruptOutcome;
|
|
114
|
+
settledAt?: string;
|
|
115
|
+
reason?: string;
|
|
73
116
|
}
|
|
74
117
|
export interface ReminderEvent {
|
|
75
118
|
id: string;
|
|
@@ -84,6 +127,10 @@ export interface ReminderEvent {
|
|
|
84
127
|
dispatchedAt?: string;
|
|
85
128
|
closedAt?: string;
|
|
86
129
|
reason?: string;
|
|
130
|
+
/** The one interruption an urgent event may have requested. */
|
|
131
|
+
interrupt?: ReminderEventInterrupt;
|
|
132
|
+
/** Why an urgent event last found the busy turn not interruptible (diagnostic). */
|
|
133
|
+
interruptDeferred?: string;
|
|
87
134
|
}
|
|
88
135
|
export declare class ReminderError extends Error {
|
|
89
136
|
readonly code: string;
|
|
@@ -99,6 +146,7 @@ export interface ScheduleInput {
|
|
|
99
146
|
time_zone?: unknown;
|
|
100
147
|
fold?: unknown;
|
|
101
148
|
late_window_seconds?: unknown;
|
|
149
|
+
interrupt?: unknown;
|
|
102
150
|
}
|
|
103
151
|
export interface ReminderView {
|
|
104
152
|
id: string;
|
|
@@ -113,6 +161,8 @@ export interface ReminderView {
|
|
|
113
161
|
fold?: 0 | 1;
|
|
114
162
|
due_at: string;
|
|
115
163
|
late_window_seconds: number;
|
|
164
|
+
/** True only when this reminder may interrupt a busy turn when due. */
|
|
165
|
+
interrupt: boolean;
|
|
116
166
|
expires_at: string;
|
|
117
167
|
created_at: string;
|
|
118
168
|
updated_at: string;
|
|
@@ -122,6 +172,7 @@ export interface ReminderView {
|
|
|
122
172
|
id: string;
|
|
123
173
|
state: ReminderEventState;
|
|
124
174
|
revision: number;
|
|
175
|
+
interrupt_state?: ReminderInterruptOutcome;
|
|
125
176
|
};
|
|
126
177
|
}
|
|
127
178
|
export declare class ReminderStore {
|
|
@@ -168,6 +219,20 @@ export declare class ReminderStore {
|
|
|
168
219
|
};
|
|
169
220
|
inspect(eventId: unknown): {
|
|
170
221
|
event: {
|
|
222
|
+
interrupt?: {
|
|
223
|
+
reason?: string | undefined;
|
|
224
|
+
settled_at?: string | undefined;
|
|
225
|
+
state: ReminderInterruptOutcome;
|
|
226
|
+
requested_at: string;
|
|
227
|
+
target_source: string;
|
|
228
|
+
target: {
|
|
229
|
+
session_id?: string | undefined;
|
|
230
|
+
source: string;
|
|
231
|
+
prompt_id: string;
|
|
232
|
+
generation: string;
|
|
233
|
+
};
|
|
234
|
+
} | undefined;
|
|
235
|
+
interrupt_deferred?: string | undefined;
|
|
171
236
|
reason?: string | undefined;
|
|
172
237
|
closed_at?: string | undefined;
|
|
173
238
|
dispatched_at?: string | undefined;
|
|
@@ -184,6 +249,20 @@ export declare class ReminderStore {
|
|
|
184
249
|
};
|
|
185
250
|
ack(eventId: unknown, expectedRevision: unknown): {
|
|
186
251
|
event: {
|
|
252
|
+
interrupt?: {
|
|
253
|
+
reason?: string | undefined;
|
|
254
|
+
settled_at?: string | undefined;
|
|
255
|
+
state: ReminderInterruptOutcome;
|
|
256
|
+
requested_at: string;
|
|
257
|
+
target_source: string;
|
|
258
|
+
target: {
|
|
259
|
+
session_id?: string | undefined;
|
|
260
|
+
source: string;
|
|
261
|
+
prompt_id: string;
|
|
262
|
+
generation: string;
|
|
263
|
+
};
|
|
264
|
+
} | undefined;
|
|
265
|
+
interrupt_deferred?: string | undefined;
|
|
187
266
|
reason?: string | undefined;
|
|
188
267
|
closed_at?: string | undefined;
|
|
189
268
|
dispatched_at?: string | undefined;
|
|
@@ -221,6 +300,27 @@ export declare class ReminderStore {
|
|
|
221
300
|
dispatch: boolean;
|
|
222
301
|
reason?: string;
|
|
223
302
|
};
|
|
303
|
+
/**
|
|
304
|
+
* Whether this undispatched event may still ask to interrupt a busy turn: its
|
|
305
|
+
* reminder is urgent and current, and no interruption was ever requested
|
|
306
|
+
* for it (in this process or before a restart).
|
|
307
|
+
*/
|
|
308
|
+
mayInterrupt(eventId: string): boolean;
|
|
309
|
+
/**
|
|
310
|
+
* Durable mark made BEFORE the session is asked to cancel. False: do not
|
|
311
|
+
* interrupt. Once written it is never cleared, which is what bounds every
|
|
312
|
+
* urgent event to one interruption.
|
|
313
|
+
*/
|
|
314
|
+
markInterruptRequested(eventId: string, target: ReminderInterruptTarget): boolean;
|
|
315
|
+
/**
|
|
316
|
+
* Record why an urgent event did not interrupt this time. Written only when
|
|
317
|
+
* the reason changes, so a long busy stretch costs one write, not one per poll.
|
|
318
|
+
*/
|
|
319
|
+
recordInterruptDeferred(eventId: string, reason: string): void;
|
|
320
|
+
/** How the requested interruption ended. Diagnostic; never moves the event. */
|
|
321
|
+
recordInterruptOutcome(eventId: string, outcome: Exclude<ReminderInterruptOutcome, 'requested'>, reason?: string): void;
|
|
322
|
+
/** Still the current, undispatched claim this process made? Re-checked after every await. */
|
|
323
|
+
stillAdmitting(eventId: string): boolean;
|
|
224
324
|
/**
|
|
225
325
|
* An admission that ended without the release guard dispatching it (queue
|
|
226
326
|
* refused, session died, release refused while stopping) goes back to
|
package/dist/reminders/store.js
CHANGED
|
@@ -26,6 +26,15 @@ import { ReminderScheduleError, resolveSchedule } from './schedule.js';
|
|
|
26
26
|
* are not retained, so session recovery cannot replay them) and is retried
|
|
27
27
|
* under the same event id. An event found `dispatched` may or may not have been
|
|
28
28
|
* seen, so it is held as `uncertain` and never automatically re-admitted.
|
|
29
|
+
*
|
|
30
|
+
* Urgency is an explicit opt-in per reminder (`interrupt`). Absent means false,
|
|
31
|
+
* so stores written before it existed keep their exact idle-only meaning, and
|
|
32
|
+
* an older runtime that ignores the field stays idle-only. An urgent event may
|
|
33
|
+
* request at most ONE interruption of the agent's current turn per revision:
|
|
34
|
+
* the request is durably recorded before the session is asked to cancel, so a
|
|
35
|
+
* crash, a forced adapter restart or a second urgent reminder can never turn
|
|
36
|
+
* it into a cancellation loop. Every later admission of that event is
|
|
37
|
+
* idle-only, and only the release guard ever records `dispatched`.
|
|
29
38
|
*/
|
|
30
39
|
export const SELF_REMINDERS_FILE = '.self-reminders.json';
|
|
31
40
|
export const MAX_OPEN_REMINDERS = 100;
|
|
@@ -37,6 +46,7 @@ const MAX_HORIZON_MS = 5 * 366 * 24 * 3600 * 1000;
|
|
|
37
46
|
const MAX_TITLE_CHARS = 200;
|
|
38
47
|
const MAX_NOTE_CHARS = 4000;
|
|
39
48
|
const MAX_REQUEST_KEY_CHARS = 200;
|
|
49
|
+
const INTERRUPT_OUTCOMES = new Set(['requested', 'cancelled', 'ended', 'forced', 'failed', 'unknown']);
|
|
40
50
|
export class ReminderError extends Error {
|
|
41
51
|
code;
|
|
42
52
|
constructor(code, message) {
|
|
@@ -81,6 +91,13 @@ function revisionOf(raw) {
|
|
|
81
91
|
throw new ReminderError('invalid_request', 'expected_revision must be the current positive revision');
|
|
82
92
|
return raw;
|
|
83
93
|
}
|
|
94
|
+
function urgency(raw) {
|
|
95
|
+
if (raw === undefined || raw === null)
|
|
96
|
+
return false;
|
|
97
|
+
if (typeof raw !== 'boolean')
|
|
98
|
+
throw new ReminderError('invalid_request', 'interrupt must be true or false');
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
84
101
|
export class ReminderStore {
|
|
85
102
|
owner;
|
|
86
103
|
now;
|
|
@@ -137,6 +154,10 @@ export class ReminderStore {
|
|
|
137
154
|
recover() {
|
|
138
155
|
const at = iso(this.now());
|
|
139
156
|
for (const event of Object.values(this.state.events)) {
|
|
157
|
+
// An interruption requested by the previous process has an unknown end.
|
|
158
|
+
// Keep the mark (so it is never requested again) and say so.
|
|
159
|
+
if (event.interrupt?.outcome === 'requested')
|
|
160
|
+
event.interrupt = { ...event.interrupt, outcome: 'unknown', settledAt: at, reason: 'restarted_before_outcome' };
|
|
140
161
|
if (event.state === 'admitting') {
|
|
141
162
|
// The release guard never ran, so the model never saw it.
|
|
142
163
|
event.state = 'pending';
|
|
@@ -186,6 +207,8 @@ export class ReminderStore {
|
|
|
186
207
|
const retime = !previous || input.local_time !== undefined || input.time_zone !== undefined || input.fold !== undefined;
|
|
187
208
|
const late = input.late_window_seconds === undefined && previous
|
|
188
209
|
? previous.lateWindowSeconds : lateWindow(input.late_window_seconds);
|
|
210
|
+
const interrupt = input.interrupt === undefined && previous
|
|
211
|
+
? previous.interrupt === true : urgency(input.interrupt);
|
|
189
212
|
let when;
|
|
190
213
|
if (retime) {
|
|
191
214
|
try {
|
|
@@ -205,7 +228,7 @@ export class ReminderStore {
|
|
|
205
228
|
else {
|
|
206
229
|
when = { localTime: previous.localTime, timeZone: previous.timeZone, fold: previous.fold, dueAt: Date.parse(previous.dueAt) };
|
|
207
230
|
}
|
|
208
|
-
return { title, note, ...when, late };
|
|
231
|
+
return { title, note, ...when, late, interrupt };
|
|
209
232
|
}
|
|
210
233
|
openCount() {
|
|
211
234
|
return Object.values(this.state.reminders).filter(item => item.state === 'active' || item.state === 'delivered').length;
|
|
@@ -216,6 +239,9 @@ export class ReminderStore {
|
|
|
216
239
|
title: input.title ?? null, note: input.note ?? null, local_time: input.local_time ?? null,
|
|
217
240
|
time_zone: input.time_zone ?? null, fold: input.fold ?? null,
|
|
218
241
|
late_window_seconds: input.late_window_seconds ?? null,
|
|
242
|
+
// Only an explicit urgent request changes the digest, so a retry of a
|
|
243
|
+
// call made before `interrupt` existed (or with it false) still matches.
|
|
244
|
+
...(input.interrupt === true ? { interrupt: true } : {}),
|
|
219
245
|
});
|
|
220
246
|
const existing = Object.values(this.state.reminders).find(item => item.requestKey === key);
|
|
221
247
|
if (existing) {
|
|
@@ -234,6 +260,7 @@ export class ReminderStore {
|
|
|
234
260
|
...(values.fold === undefined ? {} : { fold: values.fold }),
|
|
235
261
|
dueAt: iso(values.dueAt), lateWindowSeconds: values.late,
|
|
236
262
|
expiresAt: iso(values.dueAt + values.late * 1000), createdAt: at, updatedAt: at,
|
|
263
|
+
...(values.interrupt ? { interrupt: true } : {}),
|
|
237
264
|
};
|
|
238
265
|
state.reminders[record.id] = record;
|
|
239
266
|
this.prune(state);
|
|
@@ -264,6 +291,10 @@ export class ReminderStore {
|
|
|
264
291
|
delete target.fold;
|
|
265
292
|
else
|
|
266
293
|
target.fold = values.fold;
|
|
294
|
+
if (values.interrupt)
|
|
295
|
+
target.interrupt = true;
|
|
296
|
+
else
|
|
297
|
+
delete target.interrupt;
|
|
267
298
|
delete target.closedReason;
|
|
268
299
|
return this.view(target);
|
|
269
300
|
});
|
|
@@ -523,6 +554,80 @@ export class ReminderStore {
|
|
|
523
554
|
}
|
|
524
555
|
return { dispatch: true };
|
|
525
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* Whether this undispatched event may still ask to interrupt a busy turn: its
|
|
559
|
+
* reminder is urgent and current, and no interruption was ever requested
|
|
560
|
+
* for it (in this process or before a restart).
|
|
561
|
+
*/
|
|
562
|
+
mayInterrupt(eventId) {
|
|
563
|
+
const event = this.state.events[eventId];
|
|
564
|
+
const record = event && this.state.reminders[event.reminderId];
|
|
565
|
+
return Boolean(event && record && record.interrupt === true && !event.interrupt
|
|
566
|
+
&& (event.state === 'pending' || event.state === 'admitting')
|
|
567
|
+
&& record.state === 'active' && record.revision === event.revision);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Durable mark made BEFORE the session is asked to cancel. False: do not
|
|
571
|
+
* interrupt. Once written it is never cleared, which is what bounds every
|
|
572
|
+
* urgent event to one interruption.
|
|
573
|
+
*/
|
|
574
|
+
markInterruptRequested(eventId, target) {
|
|
575
|
+
if (!this.mayInterrupt(eventId) || this.state.events[eventId].state !== 'admitting')
|
|
576
|
+
return false;
|
|
577
|
+
if (!target.source || !target.promptId || !target.generation)
|
|
578
|
+
return false;
|
|
579
|
+
const event = this.state.events[eventId];
|
|
580
|
+
try {
|
|
581
|
+
this.commit(() => {
|
|
582
|
+
event.interrupt = {
|
|
583
|
+
requestedAt: iso(this.now()), outcome: 'requested',
|
|
584
|
+
target: {
|
|
585
|
+
source: target.source, promptId: target.promptId, generation: target.generation,
|
|
586
|
+
...(target.sessionId ? { sessionId: target.sessionId } : {}),
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
delete event.interruptDeferred;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Record why an urgent event did not interrupt this time. Written only when
|
|
599
|
+
* the reason changes, so a long busy stretch costs one write, not one per poll.
|
|
600
|
+
*/
|
|
601
|
+
recordInterruptDeferred(eventId, reason) {
|
|
602
|
+
const event = this.state.events[eventId];
|
|
603
|
+
if (!event || event.interrupt || event.interruptDeferred === reason)
|
|
604
|
+
return;
|
|
605
|
+
try {
|
|
606
|
+
this.commit(() => { event.interruptDeferred = reason; });
|
|
607
|
+
}
|
|
608
|
+
catch { /* diagnostic only */ }
|
|
609
|
+
}
|
|
610
|
+
/** How the requested interruption ended. Diagnostic; never moves the event. */
|
|
611
|
+
recordInterruptOutcome(eventId, outcome, reason) {
|
|
612
|
+
const event = this.state.events[eventId];
|
|
613
|
+
if (!event?.interrupt || event.interrupt.outcome !== 'requested')
|
|
614
|
+
return;
|
|
615
|
+
try {
|
|
616
|
+
this.commit(() => {
|
|
617
|
+
event.interrupt = {
|
|
618
|
+
...event.interrupt, outcome, settledAt: iso(this.now()), ...(reason ? { reason } : {}),
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
catch { /* stays `requested`: still never re-requested, still not delivered */ }
|
|
623
|
+
}
|
|
624
|
+
/** Still the current, undispatched claim this process made? Re-checked after every await. */
|
|
625
|
+
stillAdmitting(eventId) {
|
|
626
|
+
const event = this.state.events[eventId];
|
|
627
|
+
const record = event && this.state.reminders[event.reminderId];
|
|
628
|
+
return Boolean(event && record && event.state === 'admitting' && record.state === 'active'
|
|
629
|
+
&& record.revision === event.revision);
|
|
630
|
+
}
|
|
526
631
|
/**
|
|
527
632
|
* An admission that ended without the release guard dispatching it (queue
|
|
528
633
|
* refused, session died, release refused while stopping) goes back to
|
|
@@ -576,10 +681,14 @@ export class ReminderStore {
|
|
|
576
681
|
title: record.title, note: record.note,
|
|
577
682
|
local_time: record.localTime, time_zone: record.timeZone,
|
|
578
683
|
...(record.fold === undefined ? {} : { fold: record.fold }),
|
|
579
|
-
due_at: record.dueAt, late_window_seconds: record.lateWindowSeconds,
|
|
684
|
+
due_at: record.dueAt, late_window_seconds: record.lateWindowSeconds, interrupt: record.interrupt === true,
|
|
685
|
+
expires_at: record.expiresAt,
|
|
580
686
|
created_at: record.createdAt, updated_at: record.updatedAt,
|
|
581
687
|
...(record.closedReason ? { closed_reason: record.closedReason } : {}),
|
|
582
|
-
...(event ? { event: {
|
|
688
|
+
...(event ? { event: {
|
|
689
|
+
id: event.id, state: event.state, revision: event.revision,
|
|
690
|
+
...(event.interrupt ? { interrupt_state: event.interrupt.outcome } : {}),
|
|
691
|
+
} } : {}),
|
|
583
692
|
};
|
|
584
693
|
}
|
|
585
694
|
eventView(event) {
|
|
@@ -589,6 +698,18 @@ export class ReminderStore {
|
|
|
589
698
|
...(event.dispatchedAt ? { dispatched_at: event.dispatchedAt } : {}),
|
|
590
699
|
...(event.closedAt ? { closed_at: event.closedAt } : {}),
|
|
591
700
|
...(event.reason ? { reason: event.reason } : {}),
|
|
701
|
+
...(event.interruptDeferred ? { interrupt_deferred: event.interruptDeferred } : {}),
|
|
702
|
+
...(event.interrupt ? { interrupt: {
|
|
703
|
+
state: event.interrupt.outcome, requested_at: event.interrupt.requestedAt,
|
|
704
|
+
target_source: event.interrupt.target.source,
|
|
705
|
+
target: {
|
|
706
|
+
source: event.interrupt.target.source, prompt_id: event.interrupt.target.promptId,
|
|
707
|
+
generation: event.interrupt.target.generation,
|
|
708
|
+
...(event.interrupt.target.sessionId ? { session_id: event.interrupt.target.sessionId } : {}),
|
|
709
|
+
},
|
|
710
|
+
...(event.interrupt.settledAt ? { settled_at: event.interrupt.settledAt } : {}),
|
|
711
|
+
...(event.interrupt.reason ? { reason: event.interrupt.reason } : {}),
|
|
712
|
+
} } : {}),
|
|
592
713
|
};
|
|
593
714
|
}
|
|
594
715
|
}
|
|
@@ -618,10 +739,16 @@ function validFile(value) {
|
|
|
618
739
|
return Object.entries(file.reminders).every(([id, item]) => item && item.id === id
|
|
619
740
|
&& Number.isSafeInteger(item.revision) && item.revision >= 1 && reminderStates.has(item.state)
|
|
620
741
|
&& 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))
|
|
742
|
+
&& Number.isFinite(Date.parse(item.dueAt)) && Number.isFinite(Date.parse(item.expiresAt))
|
|
743
|
+
&& (item.interrupt === undefined || item.interrupt === true))
|
|
622
744
|
&& Object.entries(file.events).every(([id, item]) => item && item.id === id
|
|
623
745
|
&& typeof item.reminderId === 'string' && Number.isSafeInteger(item.revision) && eventStates.has(item.state)
|
|
624
|
-
&& Number.isSafeInteger(item.attempts)
|
|
746
|
+
&& Number.isSafeInteger(item.attempts)
|
|
747
|
+
&& (item.interrupt === undefined || (typeof item.interrupt === 'object' && item.interrupt !== null
|
|
748
|
+
&& typeof item.interrupt.requestedAt === 'string' && typeof item.interrupt.target?.source === 'string'
|
|
749
|
+
&& typeof item.interrupt.target.promptId === 'string' && typeof item.interrupt.target.generation === 'string'
|
|
750
|
+
&& (item.interrupt.target.sessionId === undefined || typeof item.interrupt.target.sessionId === 'string')
|
|
751
|
+
&& INTERRUPT_OUTCOMES.has(item.interrupt.outcome))));
|
|
625
752
|
}
|
|
626
753
|
/** Remove a retired agent's reminder store and any quarantined copies. */
|
|
627
754
|
export function removeReminderState(stateDir) {
|
package/dist/reminders/tools.js
CHANGED
|
@@ -9,6 +9,10 @@ const schedule = {
|
|
|
9
9
|
time_zone: z.string().describe('IANA zone the time is meant in, e.g. "Europe/London", "America/New_York" or "UTC". Use the zone of the person or task, not a guess.'),
|
|
10
10
|
fold: z.union([z.literal(0), z.literal(1)]).optional().describe('Only for a wall time that occurs twice when clocks go back: 0 = first occurrence, 1 = second.'),
|
|
11
11
|
late_window_seconds: z.number().int().optional().describe('How late the wake may still be delivered if this agent or the host was offline or busy at the due time (60..604800, default 3600). After it the reminder expires without a wake.'),
|
|
12
|
+
interrupt: z.boolean().optional().describe('Default false: the wake waits until you are idle. true only for a return that is genuinely time-bound: '
|
|
13
|
+
+ 'when due and you are busy with interruptible work, Fleet asks your current turn to stop (session and context are kept) and wakes you with this reminder instead of waiting. '
|
|
14
|
+
+ 'At most one interruption per reminder revision. It never pre-empts owner requests, the owner console, startup, a pending permission, queued work or another reminder; '
|
|
15
|
+
+ 'then it waits like an ordinary reminder. Not a hard real-time alarm: no wake while the host or agent is down, and model start-up still takes time.'),
|
|
12
16
|
};
|
|
13
17
|
function result(value) {
|
|
14
18
|
return {
|
|
@@ -90,6 +94,7 @@ admit) {
|
|
|
90
94
|
time_zone: schedule.time_zone.optional(),
|
|
91
95
|
fold: schedule.fold,
|
|
92
96
|
late_window_seconds: schedule.late_window_seconds,
|
|
97
|
+
interrupt: schedule.interrupt,
|
|
93
98
|
},
|
|
94
99
|
}, async ({ id, expected_revision, ...changes }) => run(({ store }) => store.update(id, expected_revision, changes), true));
|
|
95
100
|
server.registerTool('fleet_reminder_cancel', {
|
|
@@ -97,7 +102,8 @@ admit) {
|
|
|
97
102
|
inputSchema: { id: z.string(), expected_revision: z.number().int() },
|
|
98
103
|
}, async ({ id, expected_revision }) => run(({ store }) => store.cancel(id, expected_revision), true));
|
|
99
104
|
server.registerTool('fleet_reminder_inspect', {
|
|
100
|
-
description: 'Read the reminder behind a [fleet-reminder] wake, including its note,
|
|
105
|
+
description: 'Read the reminder behind a [fleet-reminder] wake, including its note, whether the wake is still current and eligible, '
|
|
106
|
+
+ 'and for an urgent reminder what its interruption request did (interrupt.state).',
|
|
101
107
|
inputSchema: { event_id: z.string() },
|
|
102
108
|
}, async ({ event_id }) => run(({ store }) => store.inspect(event_id), false));
|
|
103
109
|
server.registerTool('fleet_reminder_ack', {
|
|
@@ -12,6 +12,42 @@ export type ReminderAttempt =
|
|
|
12
12
|
error: string;
|
|
13
13
|
admitted: false;
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The single interruption an urgent reminder may request, as the arbiter saw
|
|
17
|
+
* it. `target` names the exact checked turn; `outcome` is what the session
|
|
18
|
+
* and the target's own terminal result actually reported.
|
|
19
|
+
*/
|
|
20
|
+
export interface UrgentInterruptReport {
|
|
21
|
+
target: UrgentInterruptTarget;
|
|
22
|
+
outcome: 'cancelled' | 'ended' | 'forced' | 'failed';
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UrgentInterruptTarget {
|
|
26
|
+
source: NonNullable<SessionSnapshot['activePromptSource']>;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
generation: string;
|
|
29
|
+
promptId: string;
|
|
30
|
+
}
|
|
31
|
+
/** The urgent path's durable seams; every one is synchronous and owned by the reminder store. */
|
|
32
|
+
export interface UrgentReminderHooks {
|
|
33
|
+
/** The durable `admitting` claim (as for `tryReminder`). False: nothing is queued. */
|
|
34
|
+
beforeQueue(): boolean;
|
|
35
|
+
/** Release fence; the only way the prompt reaches the model. */
|
|
36
|
+
releaseGuard(): boolean;
|
|
37
|
+
/** Whether this event may still request its one interruption. */
|
|
38
|
+
mayInterrupt(): boolean;
|
|
39
|
+
/** Durable mark written BEFORE the session is asked to cancel. False: do not cancel. */
|
|
40
|
+
markRequested(target: UrgentInterruptTarget): boolean;
|
|
41
|
+
/** How the requested interruption ended. */
|
|
42
|
+
recordOutcome(report: UrgentInterruptReport): void;
|
|
43
|
+
/** The busy turn was not interruptible this time, and why. */
|
|
44
|
+
deferred(reason: UrgentDeferral): void;
|
|
45
|
+
/** Still this caller's current, undispatched claim? Re-checked after every await. */
|
|
46
|
+
stillAdmitting(): boolean;
|
|
47
|
+
/** The wake text, built once the interruption (if any) is known. */
|
|
48
|
+
text(report?: UrgentInterruptReport): string;
|
|
49
|
+
}
|
|
50
|
+
export type UrgentDeferral = 'protected_turn' | 'untracked_turn' | 'permission_pending' | 'queued_work' | 'session_starting' | 'interrupt_unsupported' | 'uncorrelatable' | 'interrupt_already_used';
|
|
15
51
|
export type ScheduledAttempt = {
|
|
16
52
|
state: 'started';
|
|
17
53
|
queued: QueuedPrompt;
|
|
@@ -34,6 +70,10 @@ export declare class RoleTurnArbiter implements AgentSession {
|
|
|
34
70
|
readonly capabilities: import("./types.js").AgentSessionCapabilities | undefined;
|
|
35
71
|
private tail;
|
|
36
72
|
private unsettled;
|
|
73
|
+
/** Admissions waiting for the boundary; an urgent interruption yields to them. */
|
|
74
|
+
private waiting;
|
|
75
|
+
/** Terminal results of prompts this arbiter admitted and that have not settled yet. */
|
|
76
|
+
private readonly live;
|
|
37
77
|
private stopping;
|
|
38
78
|
private retiring;
|
|
39
79
|
/** Bumped by `retireStalledAdmission`; every claim carries the one it was made under. */
|
|
@@ -91,6 +131,26 @@ export declare class RoleTurnArbiter implements AgentSession {
|
|
|
91
131
|
tryReminder(text: string, origin: Extract<PromptOrigin, {
|
|
92
132
|
kind: 'self-reminder';
|
|
93
133
|
}>, beforeQueue: () => boolean, releaseGuard: () => boolean): Promise<ReminderAttempt>;
|
|
134
|
+
/**
|
|
135
|
+
* Admit an URGENT self-reminder wake. Idle: exactly `tryReminder`. Busy: the
|
|
136
|
+
* reminder may ask the exact checked turn to stop — once per event — and is
|
|
137
|
+
* then admitted at the next boundary like any other reminder.
|
|
138
|
+
*
|
|
139
|
+
* The check and the cancel request happen in one synchronous step inside the
|
|
140
|
+
* admission boundary, so the turn asked to stop is the turn that was checked.
|
|
141
|
+
* Only a running, correlatable, interruptible turn with nothing queued behind
|
|
142
|
+
* it and no admission waiting is ever asked to stop; anything else is a
|
|
143
|
+
* reported deferral (`busy`). After the cancellation the boundary is given up
|
|
144
|
+
* before the wake is queued, so an owner prompt (or any producer) that
|
|
145
|
+
* arrived meanwhile is admitted first and the wake simply finds the role
|
|
146
|
+
* busy. A forced, failed or uncertain cancellation never queues the wake.
|
|
147
|
+
*/
|
|
148
|
+
tryUrgentReminder(origin: Extract<PromptOrigin, {
|
|
149
|
+
kind: 'self-reminder';
|
|
150
|
+
}>, hooks: UrgentReminderHooks): Promise<ReminderAttempt>;
|
|
151
|
+
/** The exact interruptible turn, or why there is none. Synchronous by design. */
|
|
152
|
+
private urgentTarget;
|
|
153
|
+
private queueReminder;
|
|
94
154
|
/** Retire only at the same admission boundary used by all prompt producers. */
|
|
95
155
|
retireIfIdle(expired: (snapshot: SessionSnapshot, queueDepth: number) => boolean): Promise<boolean>;
|
|
96
156
|
sharedTurnId(promptId: string): string | undefined;
|