@qilin-agent/schedule 0.0.0-stage → 3.1.4
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/LICENSE +21 -0
- package/README.i18n.yaml +76 -0
- package/README.md +170 -2
- package/README.zh.md +171 -0
- package/lib/index.js +2271 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +734 -0
- package/lib/typert.remote-client.d.ts +30 -0
- package/lib/typert.remote-client.js +480 -0
- package/lib/types/client.d.ts +3 -0
- package/lib/types/client.js +2 -0
- package/lib/types/delivery-history.d.ts +24 -0
- package/lib/types/delivery-history.js +66 -0
- package/lib/types/domain.d.ts +309 -0
- package/lib/types/domain.js +1587 -0
- package/lib/types/index.d.ts +134 -0
- package/lib/types/index.js +416 -0
- package/lib/types/runtime.d.ts +42 -0
- package/lib/types/runtime.js +168 -0
- package/lib/types/storage.d.ts +59 -0
- package/lib/types/storage.js +54 -0
- package/lib/types/types.d.ts +422 -0
- package/lib/types/types.js +6 -0
- package/lib/types/update.d.ts +26 -0
- package/lib/types/update.js +164 -0
- package/package.json +84 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Host timer over stored tasks; Session activation is a delivery operation. */
|
|
2
|
+
import { createUserMessage } from '@qilin-agent/llm';
|
|
3
|
+
import { isRecurringScheduleRecord, renderReminderFraming, renderRecurringReminderBatchFraming, resolveRecurringOccurrence } from "./domain.js";
|
|
4
|
+
import { appendDelivery } from "./delivery-history.js";
|
|
5
|
+
/** Largest delay Node timers represent without clamping. */
|
|
6
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
7
|
+
/** Owns at most one timer across recomputations; delivery and management share the serialized operation. */
|
|
8
|
+
export class ScheduleRuntime {
|
|
9
|
+
ctx;
|
|
10
|
+
tasks;
|
|
11
|
+
transact;
|
|
12
|
+
commit;
|
|
13
|
+
retention;
|
|
14
|
+
timer;
|
|
15
|
+
running;
|
|
16
|
+
stopping = false;
|
|
17
|
+
requested = false;
|
|
18
|
+
/**
|
|
19
|
+
* @param ctx - Host services used to resume and enqueue.
|
|
20
|
+
* @param tasks - Current durable tasks.
|
|
21
|
+
* @param transact - Serialize delivery against management writes.
|
|
22
|
+
* @param commit - Persist task status, target, receipt, and history together after durable inbox delivery.
|
|
23
|
+
*/
|
|
24
|
+
constructor(ctx, tasks, transact, commit, retention) {
|
|
25
|
+
this.ctx = ctx;
|
|
26
|
+
this.tasks = tasks;
|
|
27
|
+
this.transact = transact;
|
|
28
|
+
this.commit = commit;
|
|
29
|
+
this.retention = retention;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Recompute the nearest obligation after startup or a durable change.
|
|
33
|
+
* Dispatch failures are logged; refused admission does not retry automatically.
|
|
34
|
+
*/
|
|
35
|
+
requestDrive() {
|
|
36
|
+
if (this.stopping)
|
|
37
|
+
return;
|
|
38
|
+
this.requested = true;
|
|
39
|
+
this.clearTimer();
|
|
40
|
+
if (this.running !== undefined)
|
|
41
|
+
return;
|
|
42
|
+
let run;
|
|
43
|
+
try {
|
|
44
|
+
run = this.ctx.agents.withoutInitiator(async () => {
|
|
45
|
+
while (this.requested && !this.stopping) {
|
|
46
|
+
this.requested = false;
|
|
47
|
+
await this.transact(async () => { await this.drive(); });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// Ancestor unloading closes initiator admission before runtime cleanup runs.
|
|
53
|
+
this.requested = false;
|
|
54
|
+
this.ctx.logger.warn(`schedule: dispatch stopped: ${String(error)}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.running = run;
|
|
58
|
+
void run.catch((error) => {
|
|
59
|
+
this.ctx.logger.warn(`schedule: dispatch stopped: ${String(error)}`);
|
|
60
|
+
}).finally(() => {
|
|
61
|
+
this.running = undefined;
|
|
62
|
+
if (this.requested && !this.stopping)
|
|
63
|
+
this.requestDrive();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Stop the timer and drain an accepted delivery before storage closes. */
|
|
67
|
+
async dispose() {
|
|
68
|
+
this.stopping = true;
|
|
69
|
+
this.clearTimer();
|
|
70
|
+
// requestDrive reports execution failures; teardown only waits for quiescence.
|
|
71
|
+
await this.running?.catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
clearTimer() {
|
|
74
|
+
if (this.timer !== undefined)
|
|
75
|
+
clearTimeout(this.timer);
|
|
76
|
+
this.timer = undefined;
|
|
77
|
+
}
|
|
78
|
+
async drive() {
|
|
79
|
+
this.clearTimer();
|
|
80
|
+
const failed = new Set();
|
|
81
|
+
const handled = new Set();
|
|
82
|
+
const scanNow = Date.now();
|
|
83
|
+
const due = this.tasks().filter(task => task.status === 'active' && Date.parse(task.record.scheduledAt) <= scanNow);
|
|
84
|
+
for (const task of due) {
|
|
85
|
+
if (this.stopping)
|
|
86
|
+
return;
|
|
87
|
+
if (handled.has(task.record.id))
|
|
88
|
+
continue;
|
|
89
|
+
const group = isRecurringScheduleRecord(task.record)
|
|
90
|
+
? due.filter(candidate => candidate.sessionId === task.sessionId && isRecurringScheduleRecord(candidate.record))
|
|
91
|
+
: [task];
|
|
92
|
+
for (const member of group)
|
|
93
|
+
handled.add(member.record.id);
|
|
94
|
+
let admitted = group;
|
|
95
|
+
const committed = new Set();
|
|
96
|
+
try {
|
|
97
|
+
const resolved = await this.ctx.sessionController.resolveAgent(task.sessionId);
|
|
98
|
+
if ('error' in resolved)
|
|
99
|
+
throw resolved.error;
|
|
100
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Disposal can run while Session restoration is awaited.
|
|
101
|
+
if (this.stopping)
|
|
102
|
+
return;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
// Session restoration can span a wall-clock rollback; future members keep their timer obligation.
|
|
105
|
+
admitted = group.filter(member => Date.parse(member.record.scheduledAt) <= now);
|
|
106
|
+
if (admitted.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
const recurring = admitted.filter((member) => isRecurringScheduleRecord(member.record));
|
|
109
|
+
const occurrences = recurring.map(member => ({
|
|
110
|
+
task: member, occurrence: resolveRecurringOccurrence(member.record, now),
|
|
111
|
+
}));
|
|
112
|
+
const text = isRecurringScheduleRecord(task.record)
|
|
113
|
+
? renderRecurringReminderBatchFraming(occurrences.map(({ task: member, occurrence }) => ({
|
|
114
|
+
record: member.record, occurrenceAt: occurrence.occurrenceAt,
|
|
115
|
+
})))
|
|
116
|
+
: renderReminderFraming(task.record);
|
|
117
|
+
const message = createUserMessage({
|
|
118
|
+
content: [{ type: 'text', text }], source: { kind: 'schedule' },
|
|
119
|
+
});
|
|
120
|
+
// followup synchronously appends the inbox splice before flush observes the Session.
|
|
121
|
+
resolved.agent.followup(message);
|
|
122
|
+
const flushed = await this.ctx.sessions.flush(resolved.agent.session);
|
|
123
|
+
if (!flushed)
|
|
124
|
+
throw new Error('Session persistence did not acknowledge the reminder');
|
|
125
|
+
const deliveredAt = new Date(Date.now()).toISOString();
|
|
126
|
+
if (!isRecurringScheduleRecord(task.record)) {
|
|
127
|
+
await this.commit({
|
|
128
|
+
...task, status: 'inactive',
|
|
129
|
+
...appendDelivery(task, { scheduledAt: task.record.scheduledAt, deliveredAt, messageId: message.id }, this.retention),
|
|
130
|
+
});
|
|
131
|
+
committed.add(task.record.id);
|
|
132
|
+
}
|
|
133
|
+
for (const { task: member, occurrence } of occurrences) {
|
|
134
|
+
await this.commit({
|
|
135
|
+
...member,
|
|
136
|
+
record: { ...member.record, scheduledAt: occurrence.nextScheduledAt ?? occurrence.occurrenceAt },
|
|
137
|
+
status: occurrence.nextScheduledAt === undefined ? 'inactive' : 'active',
|
|
138
|
+
...appendDelivery(member, { scheduledAt: occurrence.occurrenceAt, deliveredAt, messageId: message.id }, this.retention),
|
|
139
|
+
});
|
|
140
|
+
committed.add(member.record.id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// Successful commits and targets made future by clock rollback keep their timer obligation.
|
|
145
|
+
const pending = admitted.filter(member => !committed.has(member.record.id));
|
|
146
|
+
const failedAt = Date.now();
|
|
147
|
+
for (const member of pending) {
|
|
148
|
+
if (Date.parse(member.record.scheduledAt) <= failedAt)
|
|
149
|
+
failed.add(member.record.id);
|
|
150
|
+
}
|
|
151
|
+
const ids = pending.map(member => member.record.id);
|
|
152
|
+
this.ctx.logger.warn(`schedule: reminders ${JSON.stringify(ids)} were not acknowledged: ${String(error)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (this.stopping)
|
|
156
|
+
return;
|
|
157
|
+
const next = this.tasks().filter(task => task.status === 'active' && !failed.has(task.record.id))
|
|
158
|
+
.reduce((at, task) => {
|
|
159
|
+
const target = Date.parse(task.record.scheduledAt);
|
|
160
|
+
return at === undefined ? target : Math.min(at, target);
|
|
161
|
+
}, undefined);
|
|
162
|
+
if (next !== undefined) {
|
|
163
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.requestDrive(); }, Math.max(0, Math.min(next - Date.now(), MAX_TIMER_DELAY_MS)));
|
|
164
|
+
this.timer.unref();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Durable Host-wide Schedule tasks, independently of Session activation. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SessionId } from '@qilin-agent/session';
|
|
4
|
+
import { MessageId } from '@qilin-agent/llm/brand';
|
|
5
|
+
import type { ScheduleId } from './types.ts';
|
|
6
|
+
/** Stored task binds one schedule to its original Session; absent status decodes as active. */
|
|
7
|
+
export declare const scheduleTaskSchema: z.ZodObject<{
|
|
8
|
+
sessionId: z.ZodPipe<z.ZodString, z.ZodTransform<SessionId, string>>;
|
|
9
|
+
record: z.ZodPipe<z.ZodUnknown, z.ZodTransform<import("./types.ts").AfterScheduleRecord | import("./types.ts").AtScheduleRecord | import("./types.ts").EveryScheduleRecord | import("./types.ts").DailyScheduleRecord | import("./types.ts").WeeklyScheduleRecord | import("./types.ts").CronScheduleRecord, unknown>>;
|
|
10
|
+
status: z.ZodDefault<z.ZodEnum<{
|
|
11
|
+
active: "active";
|
|
12
|
+
inactive: "inactive";
|
|
13
|
+
}>>;
|
|
14
|
+
lastDelivery: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
scheduledAt: z.ZodISODateTime;
|
|
16
|
+
deliveredAt: z.ZodISODateTime;
|
|
17
|
+
messageId: z.ZodPipe<z.ZodString, z.ZodTransform<MessageId, string>>;
|
|
18
|
+
}, z.core.$strict>>;
|
|
19
|
+
deliveryHistory: z.ZodOptional<z.ZodObject<{
|
|
20
|
+
records: z.ZodArray<z.ZodObject<{
|
|
21
|
+
scheduledAt: z.ZodISODateTime;
|
|
22
|
+
deliveredAt: z.ZodISODateTime;
|
|
23
|
+
messageId: z.ZodPipe<z.ZodString, z.ZodTransform<MessageId, string>>;
|
|
24
|
+
prompt: z.ZodOptional<z.ZodString>;
|
|
25
|
+
}, z.core.$strict>>;
|
|
26
|
+
earlierRecordsUnavailable: z.ZodBoolean;
|
|
27
|
+
earlierRecordsPruned: z.ZodOptional<z.ZodBoolean>;
|
|
28
|
+
}, z.core.$strict>>;
|
|
29
|
+
}, z.core.$strict>;
|
|
30
|
+
/** Persistent task value; absent history retains only its legacy last receipt without a read-time rewrite. */
|
|
31
|
+
export type ScheduleTask = z.infer<typeof scheduleTaskSchema>;
|
|
32
|
+
/** Authoritative Schedule storage; malformed tasks reject opening the domain. */
|
|
33
|
+
export declare const scheduleDomain: {
|
|
34
|
+
name: string;
|
|
35
|
+
version: number;
|
|
36
|
+
tables: {
|
|
37
|
+
tasks: import("@qilin-agent/storage-domain").DomainTableSpec<ScheduleId, {
|
|
38
|
+
sessionId: SessionId;
|
|
39
|
+
record: import("./types.ts").AfterScheduleRecord | import("./types.ts").AtScheduleRecord | import("./types.ts").EveryScheduleRecord | import("./types.ts").DailyScheduleRecord | import("./types.ts").WeeklyScheduleRecord | import("./types.ts").CronScheduleRecord;
|
|
40
|
+
status: "active" | "inactive";
|
|
41
|
+
lastDelivery?: {
|
|
42
|
+
scheduledAt: string;
|
|
43
|
+
deliveredAt: string;
|
|
44
|
+
messageId: MessageId;
|
|
45
|
+
} | undefined;
|
|
46
|
+
deliveryHistory?: {
|
|
47
|
+
records: {
|
|
48
|
+
scheduledAt: string;
|
|
49
|
+
deliveredAt: string;
|
|
50
|
+
messageId: MessageId;
|
|
51
|
+
prompt?: string | undefined;
|
|
52
|
+
}[];
|
|
53
|
+
earlierRecordsUnavailable: boolean;
|
|
54
|
+
earlierRecordsPruned?: boolean | undefined;
|
|
55
|
+
} | undefined;
|
|
56
|
+
}>;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=storage.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Durable Host-wide Schedule tasks, independently of Session activation. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SessionId } from '@qilin-agent/session';
|
|
4
|
+
import { MessageId } from '@qilin-agent/llm/brand';
|
|
5
|
+
import { defineDomain, domainTable } from '@qilin-agent/storage-domain';
|
|
6
|
+
import { decodeScheduleRecord } from "./domain.js";
|
|
7
|
+
const recordSchema = z.unknown().transform((value, context) => {
|
|
8
|
+
try {
|
|
9
|
+
return decodeScheduleRecord(value);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
context.addIssue({ code: 'custom', message: String(error) });
|
|
13
|
+
}
|
|
14
|
+
return z.NEVER;
|
|
15
|
+
});
|
|
16
|
+
const instantSchema = z.iso.datetime({ precision: 3 }).refine(value => !value.startsWith('0000-'), {
|
|
17
|
+
message: 'Expected a canonical four-digit-year UTC calendar instant',
|
|
18
|
+
});
|
|
19
|
+
const deliveryReceiptSchema = z.object({
|
|
20
|
+
scheduledAt: instantSchema,
|
|
21
|
+
deliveredAt: instantSchema,
|
|
22
|
+
messageId: z.string().min(1).refine(value => value.trim() === value).transform(MessageId),
|
|
23
|
+
}).strict();
|
|
24
|
+
const deliveryHistorySchema = z.object({
|
|
25
|
+
records: z.array(deliveryReceiptSchema.extend({ prompt: z.string().optional() }).strict()),
|
|
26
|
+
earlierRecordsUnavailable: z.boolean(),
|
|
27
|
+
earlierRecordsPruned: z.boolean().optional(),
|
|
28
|
+
}).strict().refine(history => new Set(history.records.map(record => record.messageId)).size === history.records.length, {
|
|
29
|
+
message: 'Delivery history message identities must be unique within a task',
|
|
30
|
+
});
|
|
31
|
+
/** Stored task binds one schedule to its original Session; absent status decodes as active. */
|
|
32
|
+
export const scheduleTaskSchema = z.object({
|
|
33
|
+
sessionId: z.string().min(1).transform(SessionId),
|
|
34
|
+
record: recordSchema,
|
|
35
|
+
status: z.enum(['active', 'inactive']).default('active'),
|
|
36
|
+
lastDelivery: deliveryReceiptSchema.optional(),
|
|
37
|
+
deliveryHistory: deliveryHistorySchema.optional(),
|
|
38
|
+
}).strict().refine((task) => {
|
|
39
|
+
if (task.deliveryHistory === undefined)
|
|
40
|
+
return true;
|
|
41
|
+
const latest = task.deliveryHistory.records.at(-1);
|
|
42
|
+
if (latest === undefined)
|
|
43
|
+
return task.lastDelivery === undefined;
|
|
44
|
+
return task.lastDelivery !== undefined
|
|
45
|
+
&& latest.scheduledAt === task.lastDelivery.scheduledAt
|
|
46
|
+
&& latest.deliveredAt === task.lastDelivery.deliveredAt
|
|
47
|
+
&& latest.messageId === task.lastDelivery.messageId;
|
|
48
|
+
}, { message: 'Last delivery must match the latest saved delivery receipt' });
|
|
49
|
+
/** Authoritative Schedule storage; malformed tasks reject opening the domain. */
|
|
50
|
+
export const scheduleDomain = defineDomain({
|
|
51
|
+
name: 'schedule', version: 1,
|
|
52
|
+
tables: { tasks: domainTable(scheduleTaskSchema) },
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=storage.js.map
|