@qilin-agent/schedule 3.1.6 → 3.1.8
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/README.i18n.yaml +72 -39
- package/README.md +180 -56
- package/README.zh.md +185 -61
- package/lib/index.js +5565 -1191
- package/lib/typert.host.js +1886 -267
- package/lib/typert.remote-client.d.ts +22 -10
- package/lib/typert.remote-client.js +1727 -190
- package/lib/types/delivery-history.d.ts +4 -6
- package/lib/types/delivery-history.js +7 -20
- package/lib/types/domain.d.ts +117 -80
- package/lib/types/domain.js +311 -797
- package/lib/types/index.d.ts +168 -80
- package/lib/types/index.js +531 -222
- package/lib/types/runtime.d.ts +92 -4
- package/lib/types/runtime.js +392 -72
- package/lib/types/storage.d.ts +126 -24
- package/lib/types/storage.js +85 -16
- package/lib/types/types.d.ts +287 -148
- package/lib/types/types.js +1 -1
- package/lib/types/update.d.ts +16 -21
- package/lib/types/update.js +72 -144
- package/package.json +38 -35
package/lib/types/runtime.d.ts
CHANGED
|
@@ -6,29 +6,46 @@ declare module '@qilin-agent/llm' {
|
|
|
6
6
|
} & ContextFormed;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
import { type SessionId as SessionIdType } from '@qilin-agent/session';
|
|
9
10
|
import type { Context } from '@qilin-agent/kylin';
|
|
10
|
-
import type {
|
|
11
|
+
import type { Agent } from '@qilin-agent/agent';
|
|
11
12
|
import type { ScheduleTask } from './storage.ts';
|
|
13
|
+
import type { DeliveryRetentionBounds, ScheduleId } from './types.ts';
|
|
12
14
|
/** Largest delay Node timers represent without clamping. */
|
|
13
15
|
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
16
|
+
/** Fixed per-task delivery floor interval, timed or manual, of a target other than `new`; it matches the shortest recurrence spacing. */
|
|
17
|
+
export declare const MIN_DELIVERY_INTERVAL_MS: number;
|
|
18
|
+
/** Resolved deployment limits of one runtime. */
|
|
19
|
+
export interface ScheduleRuntimeLimits {
|
|
20
|
+
/** Retention window and record cap applied on each delivery commit. */
|
|
21
|
+
readonly retention: DeliveryRetentionBounds;
|
|
22
|
+
/** Delivery floor interval of one task whose every delivery creates a Session. */
|
|
23
|
+
readonly newSessionIntervalMs: number;
|
|
24
|
+
}
|
|
14
25
|
/** Owns at most one timer across recomputations; delivery and management share the serialized operation. */
|
|
15
26
|
export declare class ScheduleRuntime {
|
|
16
27
|
private readonly ctx;
|
|
17
28
|
private readonly tasks;
|
|
18
29
|
private readonly transact;
|
|
19
30
|
private readonly commit;
|
|
20
|
-
private readonly
|
|
31
|
+
private readonly changed;
|
|
32
|
+
private readonly limits;
|
|
33
|
+
private readonly resolveAgent;
|
|
21
34
|
private timer;
|
|
22
35
|
private running;
|
|
36
|
+
private readonly executing;
|
|
23
37
|
private stopping;
|
|
24
38
|
private requested;
|
|
25
39
|
/**
|
|
26
40
|
* @param ctx - Host services used to resume and enqueue.
|
|
27
41
|
* @param tasks - Current durable tasks.
|
|
28
42
|
* @param transact - Serialize delivery against management writes.
|
|
29
|
-
* @param commit - Persist task status, target,
|
|
43
|
+
* @param commit - Persist one task value and publish its change; delivery commits status, target, and receipt together.
|
|
44
|
+
* @param changed - Publish a change that has no task write, such as an execution reaching whole-agent idle.
|
|
45
|
+
* @param limits - Resolved retention and new-Session admission limits.
|
|
46
|
+
* @param resolveAgent - Host fallback after embedding activation handlers delegate.
|
|
30
47
|
*/
|
|
31
|
-
constructor(ctx: Context, tasks: () => readonly ScheduleTask[], transact: (work: () => Promise<
|
|
48
|
+
constructor(ctx: Context, tasks: () => readonly ScheduleTask[], transact: <T>(work: () => Promise<T>) => Promise<T>, commit: (task: ScheduleTask) => Promise<void>, changed: () => void, limits: ScheduleRuntimeLimits, resolveAgent: (sessionId: SessionIdType) => Promise<Agent>);
|
|
32
49
|
/**
|
|
33
50
|
* Recompute the nearest obligation after startup or a durable change.
|
|
34
51
|
* Dispatch failures are logged; refused admission does not retry automatically.
|
|
@@ -36,7 +53,78 @@ export declare class ScheduleRuntime {
|
|
|
36
53
|
requestDrive(): void;
|
|
37
54
|
/** Stop the timer and drain an accepted delivery before storage closes. */
|
|
38
55
|
dispose(): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Queue one manual execution without consuming or moving its scheduled occurrence.
|
|
58
|
+
* @param id - Canonical task identity resolved again inside the shared transaction.
|
|
59
|
+
* @param signal - Cancellation checked after entering the transaction and before delivery.
|
|
60
|
+
* @returns Admission and task state after durable Session inbox acknowledgement, or undefined when deleted first.
|
|
61
|
+
*/
|
|
62
|
+
runNow(id: ScheduleId, signal?: AbortSignal): Promise<{
|
|
63
|
+
readonly task: ScheduleTask;
|
|
64
|
+
readonly admitted: boolean;
|
|
65
|
+
} | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a queued or running execution holds this task's slot.
|
|
68
|
+
* @param id - Task identity.
|
|
69
|
+
* @returns Whether a delivery to the task's reused target Session has not yet reached whole-agent idle; a `new` target holds no slot.
|
|
70
|
+
*/
|
|
71
|
+
isExecutionActive(id: ScheduleId): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* End of this task's current minimum delivery interval.
|
|
74
|
+
* @param task - Current durable task state.
|
|
75
|
+
* @returns Epoch milliseconds while the interval remains in the future.
|
|
76
|
+
*/
|
|
77
|
+
deliveryFloorEnd(task: ScheduleTask): number | undefined;
|
|
39
78
|
private clearTimer;
|
|
40
79
|
private drive;
|
|
80
|
+
/**
|
|
81
|
+
* Deliver one due group in one message and advance each committed member.
|
|
82
|
+
* @param group - One task, or the due recurring tasks of one target Session.
|
|
83
|
+
* @param batch - Whether the message uses the batch framing.
|
|
84
|
+
* @returns Members that remain due after a failure; the timer skips them until the next drive request.
|
|
85
|
+
*/
|
|
86
|
+
private deliverDue;
|
|
87
|
+
/** Earliest wall-clock instant at which this task may admit another delivery. */
|
|
88
|
+
private nextAdmissionAt;
|
|
89
|
+
/** Whether the Workspace registry lists this Session as archived; without a registry, no Session is archived. */
|
|
90
|
+
private archived;
|
|
91
|
+
/**
|
|
92
|
+
* Reconstruct a still-queued last delivery after a Host restart without storing model completion state.
|
|
93
|
+
* @param task - Task whose last delivered message may still wait in its target inbox.
|
|
94
|
+
* @returns Whether the task holds its slot for that delivery; a woken reminder of a `new` target holds none.
|
|
95
|
+
*/
|
|
96
|
+
private recoverOutstandingExecution;
|
|
97
|
+
/**
|
|
98
|
+
* Enqueue one reminder and wait for Session persistence to acknowledge it.
|
|
99
|
+
* The held tasks hold their slots from the enqueue; a failure releases them.
|
|
100
|
+
*/
|
|
101
|
+
private send;
|
|
102
|
+
/** Release each uncommitted held slot now and each committed one at the target Agent's whole-agent idle boundary. */
|
|
103
|
+
private settle;
|
|
104
|
+
/** Release admitted executions at the target Agent's whole-agent idle boundary, then recompute the timer. */
|
|
105
|
+
private watchExecution;
|
|
106
|
+
/** Apply one receipt to the target, involved Sessions, and history while keeping the scheduled occurrence. */
|
|
107
|
+
private delivered;
|
|
108
|
+
/** Apply one timed receipt and advance the rule that owns dispatch to its next target. */
|
|
109
|
+
private advanced;
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the delivery target and its live Agent.
|
|
112
|
+
* A `new` target reserves its Session identity in the task before creation, so a failed delivery retries the same
|
|
113
|
+
* Session instead of creating another. A stored reserved identity is resolved first and created only when no Session
|
|
114
|
+
* has it, because the Session controller creates a Session with a different id when the named Session has already
|
|
115
|
+
* started a turn. A created Session joins the Workspace registered at `create.cwd` and takes the task title; a `new`
|
|
116
|
+
* target's title adds its launch time in the task's time zone. Neither step blocks delivery.
|
|
117
|
+
*/
|
|
118
|
+
private resolveTarget;
|
|
119
|
+
/** Resolve the live Agent of a reserved Session; `undefined` means that no Session has its id yet. */
|
|
120
|
+
private reservedAgent;
|
|
121
|
+
/** Resolve through the embedding's activation owner, with the injected Web controller as fallback. */
|
|
122
|
+
private activate;
|
|
123
|
+
/** Create a reserved Session with the target's working directory and Agent preset, then resolve its live Agent. */
|
|
124
|
+
private createAgent;
|
|
125
|
+
/** Attach a created Session to the Workspace registered at its directory; a failure is logged and does not stop delivery. */
|
|
126
|
+
private joinWorkspace;
|
|
127
|
+
/** Name a created Session that has no title yet; a failure is logged and does not stop delivery. */
|
|
128
|
+
private nameSession;
|
|
41
129
|
}
|
|
42
130
|
//# sourceMappingURL=runtime.d.ts.map
|
package/lib/types/runtime.js
CHANGED
|
@@ -1,32 +1,58 @@
|
|
|
1
|
-
/** Host timer over
|
|
1
|
+
/** Host timer over standalone tasks; target Session activation is a delivery operation. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { createUserMessage } from '@qilin-agent/llm';
|
|
3
|
-
import {
|
|
4
|
+
import { SessionId } from '@qilin-agent/session';
|
|
5
|
+
import { SessionPersistenceNotFoundError } from '@qilin-agent/session-persistence';
|
|
6
|
+
import { RemoteError } from '@qilin-agent/typert-protocol';
|
|
7
|
+
import { isRecurringScheduleRecord, MIN_EVERY_INTERVAL_SECONDS, renderRecurringReminderBatchFraming, renderReminderFraming, resolveRecurringOccurrence, } from "./domain.js";
|
|
8
|
+
import { formatScheduleLocalTime } from "./domain.js";
|
|
4
9
|
import { appendDelivery } from "./delivery-history.js";
|
|
5
10
|
/** Largest delay Node timers represent without clamping. */
|
|
6
11
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
12
|
+
/** Fixed per-task delivery floor interval, timed or manual, of a target other than `new`; it matches the shortest recurrence spacing. */
|
|
13
|
+
export const MIN_DELIVERY_INTERVAL_MS = MIN_EVERY_INTERVAL_SECONDS * 1_000;
|
|
14
|
+
/**
|
|
15
|
+
* Return the Session whose due recurring tasks share one message.
|
|
16
|
+
* @param task - Due task.
|
|
17
|
+
* @returns Target Session of a recurring `session` task, or undefined for a task delivered alone.
|
|
18
|
+
*/
|
|
19
|
+
function batchSessionOf(task) {
|
|
20
|
+
const { target, record } = task;
|
|
21
|
+
return target.kind === 'session' && isRecurringScheduleRecord(record)
|
|
22
|
+
? target.sessionId
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
7
25
|
/** Owns at most one timer across recomputations; delivery and management share the serialized operation. */
|
|
8
26
|
export class ScheduleRuntime {
|
|
9
27
|
ctx;
|
|
10
28
|
tasks;
|
|
11
29
|
transact;
|
|
12
30
|
commit;
|
|
13
|
-
|
|
31
|
+
changed;
|
|
32
|
+
limits;
|
|
33
|
+
resolveAgent;
|
|
14
34
|
timer;
|
|
15
35
|
running;
|
|
36
|
+
executing = new Set();
|
|
16
37
|
stopping = false;
|
|
17
38
|
requested = false;
|
|
18
39
|
/**
|
|
19
40
|
* @param ctx - Host services used to resume and enqueue.
|
|
20
41
|
* @param tasks - Current durable tasks.
|
|
21
42
|
* @param transact - Serialize delivery against management writes.
|
|
22
|
-
* @param commit - Persist task status, target,
|
|
43
|
+
* @param commit - Persist one task value and publish its change; delivery commits status, target, and receipt together.
|
|
44
|
+
* @param changed - Publish a change that has no task write, such as an execution reaching whole-agent idle.
|
|
45
|
+
* @param limits - Resolved retention and new-Session admission limits.
|
|
46
|
+
* @param resolveAgent - Host fallback after embedding activation handlers delegate.
|
|
23
47
|
*/
|
|
24
|
-
constructor(ctx, tasks, transact, commit,
|
|
48
|
+
constructor(ctx, tasks, transact, commit, changed, limits, resolveAgent) {
|
|
25
49
|
this.ctx = ctx;
|
|
26
50
|
this.tasks = tasks;
|
|
27
51
|
this.transact = transact;
|
|
28
52
|
this.commit = commit;
|
|
29
|
-
this.
|
|
53
|
+
this.changed = changed;
|
|
54
|
+
this.limits = limits;
|
|
55
|
+
this.resolveAgent = resolveAgent;
|
|
30
56
|
}
|
|
31
57
|
/**
|
|
32
58
|
* Recompute the nearest obligation after startup or a durable change.
|
|
@@ -59,6 +85,7 @@ export class ScheduleRuntime {
|
|
|
59
85
|
this.ctx.logger.warn(`schedule: dispatch stopped: ${String(error)}`);
|
|
60
86
|
}).finally(() => {
|
|
61
87
|
this.running = undefined;
|
|
88
|
+
/* v8 ignore next -- a request can arrive in the microtask gap between the drive loop and this finalizer. */
|
|
62
89
|
if (this.requested && !this.stopping)
|
|
63
90
|
this.requestDrive();
|
|
64
91
|
});
|
|
@@ -70,6 +97,61 @@ export class ScheduleRuntime {
|
|
|
70
97
|
// requestDrive reports execution failures; teardown only waits for quiescence.
|
|
71
98
|
await this.running?.catch(() => undefined);
|
|
72
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Queue one manual execution without consuming or moving its scheduled occurrence.
|
|
102
|
+
* @param id - Canonical task identity resolved again inside the shared transaction.
|
|
103
|
+
* @param signal - Cancellation checked after entering the transaction and before delivery.
|
|
104
|
+
* @returns Admission and task state after durable Session inbox acknowledgement, or undefined when deleted first.
|
|
105
|
+
*/
|
|
106
|
+
async runNow(id, signal) {
|
|
107
|
+
return this.ctx.agents.withoutInitiator(() => this.transact(async () => {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
const task = this.tasks().find(candidate => candidate.record.id === id);
|
|
110
|
+
if (task === undefined)
|
|
111
|
+
return;
|
|
112
|
+
if (this.executing.has(id)
|
|
113
|
+
|| this.nextAdmissionAt(task) > Date.now()
|
|
114
|
+
|| await this.recoverOutstandingExecution(task)) {
|
|
115
|
+
return { task, admitted: false };
|
|
116
|
+
}
|
|
117
|
+
const target = await this.resolveTarget(task);
|
|
118
|
+
if (this.stopping)
|
|
119
|
+
throw new Error('Schedule runtime is stopping');
|
|
120
|
+
// Sample the occurrence after Session restoration, which can take longer than one recurrence interval.
|
|
121
|
+
const occurrenceAt = new Date(Date.now()).toISOString();
|
|
122
|
+
const held = task.target.kind !== 'new' ? [id] : [];
|
|
123
|
+
const sent = await this.send(target.agent, renderReminderFraming({ ...task.record, scheduledAt: occurrenceAt }), held);
|
|
124
|
+
const committed = new Set();
|
|
125
|
+
try {
|
|
126
|
+
const delivered = this.delivered(task, {
|
|
127
|
+
scheduledAt: occurrenceAt, deliveredAt: sent.deliveredAt, messageId: sent.messageId, targetSessionId: target.sessionId,
|
|
128
|
+
});
|
|
129
|
+
await this.commit(delivered);
|
|
130
|
+
committed.add(id);
|
|
131
|
+
return { task: delivered, admitted: true };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
this.settle(held, committed, sent.execution);
|
|
135
|
+
}
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether a queued or running execution holds this task's slot.
|
|
140
|
+
* @param id - Task identity.
|
|
141
|
+
* @returns Whether a delivery to the task's reused target Session has not yet reached whole-agent idle; a `new` target holds no slot.
|
|
142
|
+
*/
|
|
143
|
+
isExecutionActive(id) {
|
|
144
|
+
return this.executing.has(id);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* End of this task's current minimum delivery interval.
|
|
148
|
+
* @param task - Current durable task state.
|
|
149
|
+
* @returns Epoch milliseconds while the interval remains in the future.
|
|
150
|
+
*/
|
|
151
|
+
deliveryFloorEnd(task) {
|
|
152
|
+
const end = this.nextAdmissionAt(task);
|
|
153
|
+
return end > Date.now() ? end : undefined;
|
|
154
|
+
}
|
|
73
155
|
clearTimer() {
|
|
74
156
|
if (this.timer !== undefined)
|
|
75
157
|
clearTimeout(this.timer);
|
|
@@ -80,83 +162,28 @@ export class ScheduleRuntime {
|
|
|
80
162
|
const failed = new Set();
|
|
81
163
|
const handled = new Set();
|
|
82
164
|
const scanNow = Date.now();
|
|
83
|
-
const due = this.tasks().filter(task => task.status === 'active'
|
|
165
|
+
const due = this.tasks().filter(task => task.status === 'active'
|
|
166
|
+
&& !this.executing.has(task.record.id)
|
|
167
|
+
&& Date.parse(task.record.scheduledAt) <= scanNow);
|
|
84
168
|
for (const task of due) {
|
|
85
169
|
if (this.stopping)
|
|
86
170
|
return;
|
|
87
171
|
if (handled.has(task.record.id))
|
|
88
172
|
continue;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
: [task];
|
|
173
|
+
const session = batchSessionOf(task);
|
|
174
|
+
const group = session === undefined ? [task] : due.filter(candidate => batchSessionOf(candidate) === session);
|
|
92
175
|
for (const member of group)
|
|
93
176
|
handled.add(member.record.id);
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
}
|
|
177
|
+
for (const id of await this.deliverDue(group, session !== undefined))
|
|
178
|
+
failed.add(id);
|
|
154
179
|
}
|
|
155
180
|
if (this.stopping)
|
|
156
181
|
return;
|
|
157
|
-
const next = this.tasks().filter(task => task.status === 'active'
|
|
182
|
+
const next = this.tasks().filter(task => task.status === 'active'
|
|
183
|
+
&& !failed.has(task.record.id)
|
|
184
|
+
&& !this.executing.has(task.record.id))
|
|
158
185
|
.reduce((at, task) => {
|
|
159
|
-
const target = Date.parse(task.record.scheduledAt);
|
|
186
|
+
const target = Math.max(Date.parse(task.record.scheduledAt), this.nextAdmissionAt(task));
|
|
160
187
|
return at === undefined ? target : Math.min(at, target);
|
|
161
188
|
}, undefined);
|
|
162
189
|
if (next !== undefined) {
|
|
@@ -164,5 +191,298 @@ export class ScheduleRuntime {
|
|
|
164
191
|
this.timer.unref();
|
|
165
192
|
}
|
|
166
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Deliver one due group in one message and advance each committed member.
|
|
196
|
+
* @param group - One task, or the due recurring tasks of one target Session.
|
|
197
|
+
* @param batch - Whether the message uses the batch framing.
|
|
198
|
+
* @returns Members that remain due after a failure; the timer skips them until the next drive request.
|
|
199
|
+
*/
|
|
200
|
+
async deliverDue(group, batch) {
|
|
201
|
+
let admitted = group;
|
|
202
|
+
const committed = new Set();
|
|
203
|
+
try {
|
|
204
|
+
const candidates = [];
|
|
205
|
+
for (const member of group) {
|
|
206
|
+
if (this.nextAdmissionAt(member) > Date.now() || await this.recoverOutstandingExecution(member))
|
|
207
|
+
continue;
|
|
208
|
+
candidates.push(member);
|
|
209
|
+
}
|
|
210
|
+
// The check precedes target resolution, so a target made future by clock rollback creates no Session.
|
|
211
|
+
const checkedAt = Date.now();
|
|
212
|
+
admitted = candidates.filter(member => Date.parse(member.record.scheduledAt) <= checkedAt);
|
|
213
|
+
const first = admitted[0];
|
|
214
|
+
if (first === undefined)
|
|
215
|
+
return [];
|
|
216
|
+
const target = await this.resolveTarget(first);
|
|
217
|
+
if (this.stopping)
|
|
218
|
+
return [];
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
// Session restoration can span a wall-clock rollback; future members keep their timer obligation.
|
|
221
|
+
admitted = admitted.filter(member => Date.parse(member.record.scheduledAt) <= now);
|
|
222
|
+
const deliveries = admitted.map(task => ({
|
|
223
|
+
task, occurrence: isRecurringScheduleRecord(task.record)
|
|
224
|
+
? resolveRecurringOccurrence(task.record, now) : { occurrenceAt: task.record.scheduledAt },
|
|
225
|
+
}));
|
|
226
|
+
const single = deliveries[0];
|
|
227
|
+
if (single === undefined)
|
|
228
|
+
return [];
|
|
229
|
+
const text = batch
|
|
230
|
+
? renderRecurringReminderBatchFraming(deliveries.map(({ task, occurrence }) => ({
|
|
231
|
+
record: task.record, occurrenceAt: occurrence.occurrenceAt,
|
|
232
|
+
})))
|
|
233
|
+
: renderReminderFraming({ ...single.task.record, scheduledAt: single.occurrence.occurrenceAt });
|
|
234
|
+
const held = deliveries.filter(({ task }) => task.target.kind !== 'new').map(({ task }) => task.record.id);
|
|
235
|
+
const sent = await this.send(target.agent, text, held);
|
|
236
|
+
try {
|
|
237
|
+
for (const { task, occurrence } of deliveries) {
|
|
238
|
+
await this.commit(this.advanced(task, occurrence, {
|
|
239
|
+
scheduledAt: occurrence.occurrenceAt, deliveredAt: sent.deliveredAt, messageId: sent.messageId,
|
|
240
|
+
targetSessionId: target.sessionId,
|
|
241
|
+
}));
|
|
242
|
+
committed.add(task.record.id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
this.settle(held, committed, sent.execution);
|
|
247
|
+
}
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// Successful commits and targets made future by clock rollback keep their timer obligation.
|
|
252
|
+
const pending = admitted.filter(member => !committed.has(member.record.id));
|
|
253
|
+
const failedAt = Date.now();
|
|
254
|
+
const ids = pending.map(member => member.record.id);
|
|
255
|
+
this.ctx.logger.warn(`schedule: reminders ${JSON.stringify(ids)} were not acknowledged: ${String(error)}`);
|
|
256
|
+
return pending.filter(member => Date.parse(member.record.scheduledAt) <= failedAt).map(member => member.record.id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Earliest wall-clock instant at which this task may admit another delivery. */
|
|
260
|
+
nextAdmissionAt(task) {
|
|
261
|
+
const receipt = task.lastDelivery;
|
|
262
|
+
if (receipt === undefined)
|
|
263
|
+
return Number.NEGATIVE_INFINITY;
|
|
264
|
+
const interval = task.target.kind === 'new' ? this.limits.newSessionIntervalMs : MIN_DELIVERY_INTERVAL_MS;
|
|
265
|
+
const occurrence = Date.parse(receipt.scheduledAt);
|
|
266
|
+
// An occurrence whose successor already keeps the interval anchors the floor, so acknowledgment latency neither
|
|
267
|
+
// accumulates across occurrences nor carries a deferral forward.
|
|
268
|
+
const anchor = Date.parse(task.record.scheduledAt) - occurrence >= interval ? occurrence : Date.parse(receipt.deliveredAt);
|
|
269
|
+
return anchor + interval;
|
|
270
|
+
}
|
|
271
|
+
/** Whether the Workspace registry lists this Session as archived; without a registry, no Session is archived. */
|
|
272
|
+
archived(sessionId) {
|
|
273
|
+
return this.ctx.get('workspaceRegistry')?.archivedSessionIds.includes(sessionId) ?? false;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Reconstruct a still-queued last delivery after a Host restart without storing model completion state.
|
|
277
|
+
* @param task - Task whose last delivered message may still wait in its target inbox.
|
|
278
|
+
* @returns Whether the task holds its slot for that delivery; a woken reminder of a `new` target holds none.
|
|
279
|
+
*/
|
|
280
|
+
async recoverOutstandingExecution(task) {
|
|
281
|
+
if (this.executing.has(task.record.id))
|
|
282
|
+
return true;
|
|
283
|
+
const receipt = task.lastDelivery;
|
|
284
|
+
// The archived-Session gate blocks the turn that a requeued reminder would wake.
|
|
285
|
+
if (receipt === undefined || this.archived(receipt.targetSessionId))
|
|
286
|
+
return false;
|
|
287
|
+
let agent;
|
|
288
|
+
try {
|
|
289
|
+
agent = await this.activate(receipt.targetSessionId);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
this.ctx.logger.warn(`schedule: could not restore the last delivery's Session for task "${task.record.id}": ${String(error)}`);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const message = [...agent.inbox.nextTurn, ...agent.inbox.nextStep].find(candidate => candidate.id === receipt.messageId);
|
|
296
|
+
if (message === undefined)
|
|
297
|
+
return false;
|
|
298
|
+
const held = this.tasks().filter(candidate => candidate.target.kind !== 'new'
|
|
299
|
+
&& candidate.lastDelivery?.messageId === receipt.messageId
|
|
300
|
+
&& candidate.lastDelivery.targetSessionId === receipt.targetSessionId).map(candidate => candidate.record.id);
|
|
301
|
+
for (const id of held)
|
|
302
|
+
this.executing.add(id);
|
|
303
|
+
try {
|
|
304
|
+
if (agent.status === 'idle') {
|
|
305
|
+
// A restored inbox does not wake an idle Agent by itself. Requeue the
|
|
306
|
+
// same identity at the next-turn tail so followup opens its driver
|
|
307
|
+
// without creating a second logical delivery or Session.
|
|
308
|
+
agent.inbox.remove(message.id);
|
|
309
|
+
agent.followup(message);
|
|
310
|
+
const execution = held.length > 0 ? agent.whenIdle() : undefined;
|
|
311
|
+
const flushed = await this.ctx.sessions.flush(agent.session);
|
|
312
|
+
if (!flushed)
|
|
313
|
+
throw new Error('Session persistence did not acknowledge the recovered reminder');
|
|
314
|
+
if (execution !== undefined)
|
|
315
|
+
this.watchExecution(held, execution);
|
|
316
|
+
}
|
|
317
|
+
else if (held.length > 0) {
|
|
318
|
+
this.watchExecution(held, agent.whenIdle());
|
|
319
|
+
}
|
|
320
|
+
return held.includes(task.record.id);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
for (const id of held)
|
|
324
|
+
this.executing.delete(id);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Enqueue one reminder and wait for Session persistence to acknowledge it.
|
|
330
|
+
* The held tasks hold their slots from the enqueue; a failure releases them.
|
|
331
|
+
*/
|
|
332
|
+
async send(agent, text, held) {
|
|
333
|
+
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'schedule' } });
|
|
334
|
+
for (const id of held)
|
|
335
|
+
this.executing.add(id);
|
|
336
|
+
try {
|
|
337
|
+
agent.followup(message);
|
|
338
|
+
// Capture the activity immediately after wakeup. Waiting for durable flush
|
|
339
|
+
// can miss the whole lifecycle of a fast target Agent.
|
|
340
|
+
const execution = held.length > 0 ? agent.whenIdle() : undefined;
|
|
341
|
+
const flushed = await this.ctx.sessions.flush(agent.session);
|
|
342
|
+
if (!flushed)
|
|
343
|
+
throw new Error('Session persistence did not acknowledge the reminder');
|
|
344
|
+
return { messageId: message.id, deliveredAt: new Date(Date.now()).toISOString(), execution };
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
for (const id of held)
|
|
348
|
+
this.executing.delete(id);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** Release each uncommitted held slot now and each committed one at the target Agent's whole-agent idle boundary. */
|
|
353
|
+
settle(held, committed, execution) {
|
|
354
|
+
for (const id of held) {
|
|
355
|
+
if (!committed.has(id))
|
|
356
|
+
this.executing.delete(id);
|
|
357
|
+
}
|
|
358
|
+
const watched = held.filter(id => committed.has(id));
|
|
359
|
+
if (watched.length > 0 && execution !== undefined)
|
|
360
|
+
this.watchExecution(watched, execution);
|
|
361
|
+
}
|
|
362
|
+
/** Release admitted executions at the target Agent's whole-agent idle boundary, then recompute the timer. */
|
|
363
|
+
watchExecution(ids, execution) {
|
|
364
|
+
const finish = () => {
|
|
365
|
+
for (const id of ids)
|
|
366
|
+
this.executing.delete(id);
|
|
367
|
+
if (this.stopping)
|
|
368
|
+
return;
|
|
369
|
+
this.changed();
|
|
370
|
+
this.requestDrive();
|
|
371
|
+
};
|
|
372
|
+
void execution.then(finish, finish);
|
|
373
|
+
}
|
|
374
|
+
/** Apply one receipt to the target, involved Sessions, and history while keeping the scheduled occurrence. */
|
|
375
|
+
delivered(task, receipt) {
|
|
376
|
+
return {
|
|
377
|
+
...task,
|
|
378
|
+
target: task.target.kind === 'new_once' ? { kind: 'session', sessionId: receipt.targetSessionId } : task.target,
|
|
379
|
+
// Each delivery of a `new` target creates a Session; recording them all would grow the task without bound.
|
|
380
|
+
involvedSessionIds: task.target.kind === 'new'
|
|
381
|
+
? task.involvedSessionIds
|
|
382
|
+
: [...new Set([...task.involvedSessionIds, receipt.targetSessionId])],
|
|
383
|
+
updatedAt: new Date(Math.max(Date.parse(receipt.deliveredAt), task.updatedAt === undefined ? Date.parse(receipt.deliveredAt) : Date.parse(task.updatedAt) + 1)).toISOString(),
|
|
384
|
+
pendingDelivery: undefined,
|
|
385
|
+
...appendDelivery(task, receipt, this.limits.retention),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** Apply one timed receipt and advance the rule that owns dispatch to its next target. */
|
|
389
|
+
advanced(task, occurrence, receipt) {
|
|
390
|
+
const scheduledAt = occurrence.nextScheduledAt ?? occurrence.occurrenceAt;
|
|
391
|
+
return {
|
|
392
|
+
...this.delivered(task, receipt),
|
|
393
|
+
record: { ...task.record, scheduledAt },
|
|
394
|
+
status: occurrence.nextScheduledAt === undefined ? 'ended' : 'active',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Resolve the delivery target and its live Agent.
|
|
399
|
+
* A `new` target reserves its Session identity in the task before creation, so a failed delivery retries the same
|
|
400
|
+
* Session instead of creating another. A stored reserved identity is resolved first and created only when no Session
|
|
401
|
+
* has it, because the Session controller creates a Session with a different id when the named Session has already
|
|
402
|
+
* started a turn. A created Session joins the Workspace registered at `create.cwd` and takes the task title; a `new`
|
|
403
|
+
* target's title adds its launch time in the task's time zone. Neither step blocks delivery.
|
|
404
|
+
*/
|
|
405
|
+
async resolveTarget(task) {
|
|
406
|
+
const { target } = task;
|
|
407
|
+
const reserved = target.kind === 'new' ? task.pendingDelivery?.targetSessionId : target.sessionId;
|
|
408
|
+
// An archived Session cannot run the reminder, including a retry in an already-created reserved Session.
|
|
409
|
+
if (reserved !== undefined && this.archived(reserved)) {
|
|
410
|
+
throw new RemoteError('schedule/target-archived', `Session ${reserved} is archived`, { sessionId: reserved });
|
|
411
|
+
}
|
|
412
|
+
if (target.kind === 'session') {
|
|
413
|
+
return { sessionId: target.sessionId, agent: await this.activate(target.sessionId) };
|
|
414
|
+
}
|
|
415
|
+
let sessionId;
|
|
416
|
+
let agent;
|
|
417
|
+
if (target.kind === 'new_once') {
|
|
418
|
+
sessionId = target.sessionId;
|
|
419
|
+
agent = await this.reservedAgent(sessionId);
|
|
420
|
+
}
|
|
421
|
+
else if (task.pendingDelivery === undefined) {
|
|
422
|
+
sessionId = SessionId(`session-${randomUUID()}`);
|
|
423
|
+
await this.commit({ ...task, pendingDelivery: { targetSessionId: sessionId } });
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
sessionId = task.pendingDelivery.targetSessionId;
|
|
427
|
+
agent = await this.reservedAgent(sessionId);
|
|
428
|
+
}
|
|
429
|
+
agent ??= await this.createAgent(sessionId, target.create);
|
|
430
|
+
await this.joinWorkspace(sessionId, target.create.cwd);
|
|
431
|
+
this.nameSession(agent, task);
|
|
432
|
+
return { sessionId, agent };
|
|
433
|
+
}
|
|
434
|
+
/** Resolve the live Agent of a reserved Session; `undefined` means that no Session has its id yet. */
|
|
435
|
+
async reservedAgent(sessionId) {
|
|
436
|
+
try {
|
|
437
|
+
return await this.activate(sessionId);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof SessionPersistenceNotFoundError
|
|
441
|
+
|| (error instanceof RemoteError && error.code === 'session/not-found'))
|
|
442
|
+
return undefined;
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Resolve through the embedding's activation owner, with the injected Web controller as fallback. */
|
|
447
|
+
activate(sessionId) {
|
|
448
|
+
return this.ctx.waterfall('schedule/resolve-agent', sessionId, () => this.resolveAgent(sessionId));
|
|
449
|
+
}
|
|
450
|
+
/** Create a reserved Session with the target's working directory and Agent preset, then resolve its live Agent. */
|
|
451
|
+
async createAgent(sessionId, create) {
|
|
452
|
+
const controller = this.ctx.get('sessionController');
|
|
453
|
+
if (controller === undefined)
|
|
454
|
+
throw new Error('schedule: no Session creation provider');
|
|
455
|
+
await controller.create({
|
|
456
|
+
sessionId,
|
|
457
|
+
...(create.cwd === undefined ? {} : { cwd: create.cwd }),
|
|
458
|
+
...(create.agentPreset === undefined ? {} : { agentPreset: create.agentPreset }),
|
|
459
|
+
});
|
|
460
|
+
return this.activate(sessionId);
|
|
461
|
+
}
|
|
462
|
+
/** Attach a created Session to the Workspace registered at its directory; a failure is logged and does not stop delivery. */
|
|
463
|
+
async joinWorkspace(sessionId, cwd) {
|
|
464
|
+
const registry = this.ctx.get('workspaceRegistry');
|
|
465
|
+
if (registry === undefined || cwd === undefined)
|
|
466
|
+
return;
|
|
467
|
+
try {
|
|
468
|
+
await (await registry.resolveByPath(cwd))?.attachSession(sessionId);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
this.ctx.logger.warn(`schedule: Session ${sessionId} did not join the Workspace at ${cwd}: ${String(error)}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** Name a created Session that has no title yet; a failure is logged and does not stop delivery. */
|
|
475
|
+
nameSession(agent, task) {
|
|
476
|
+
const titles = this.ctx.get('sessionTitle');
|
|
477
|
+
if (titles === undefined || titles.get(agent.session) !== undefined)
|
|
478
|
+
return;
|
|
479
|
+
const { title } = task.record;
|
|
480
|
+
try {
|
|
481
|
+
titles.rename(agent.session, task.target.kind === 'new' ? `${title} · ${formatScheduleLocalTime(task.record, new Date().toISOString()).slice(0, 16).replace('T', ' ')}` : title);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
this.ctx.logger.warn(`schedule: Session ${agent.id} was not named: ${String(error)}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
167
487
|
}
|
|
168
488
|
//# sourceMappingURL=runtime.js.map
|