@elizaos/plugin-scheduling 2.0.3-beta.5 → 2.0.3-beta.7
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/anchors/anchor-registry.d.ts +33 -0
- package/dist/anchors/anchor-registry.d.ts.map +1 -0
- package/dist/anchors/anchor-registry.js +129 -0
- package/dist/anchors/anchor-registry.js.map +1 -0
- package/dist/dispatch-types.d.ts +28 -0
- package/dist/dispatch-types.d.ts.map +1 -0
- package/dist/dispatch-types.js +1 -0
- package/dist/dispatch-types.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +17 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +8 -0
- package/dist/plugin.js.map +1 -0
- package/dist/scheduled-task/completion-check-registry.d.ts +19 -0
- package/dist/scheduled-task/completion-check-registry.d.ts.map +1 -0
- package/dist/scheduled-task/completion-check-registry.js +113 -0
- package/dist/scheduled-task/completion-check-registry.js.map +1 -0
- package/dist/scheduled-task/consolidation-policy.d.ts +51 -0
- package/dist/scheduled-task/consolidation-policy.d.ts.map +1 -0
- package/dist/scheduled-task/consolidation-policy.js +154 -0
- package/dist/scheduled-task/consolidation-policy.js.map +1 -0
- package/dist/scheduled-task/due.d.ts +19 -0
- package/dist/scheduled-task/due.d.ts.map +1 -0
- package/dist/scheduled-task/due.js +349 -0
- package/dist/scheduled-task/due.js.map +1 -0
- package/dist/scheduled-task/escalation.d.ts +55 -0
- package/dist/scheduled-task/escalation.d.ts.map +1 -0
- package/dist/scheduled-task/escalation.js +99 -0
- package/dist/scheduled-task/escalation.js.map +1 -0
- package/dist/scheduled-task/gate-registry.d.ts +18 -0
- package/dist/scheduled-task/gate-registry.d.ts.map +1 -0
- package/dist/scheduled-task/gate-registry.js +244 -0
- package/dist/scheduled-task/gate-registry.js.map +1 -0
- package/dist/scheduled-task/index.d.ts +20 -0
- package/dist/scheduled-task/index.d.ts.map +1 -0
- package/dist/scheduled-task/index.js +83 -0
- package/dist/scheduled-task/index.js.map +1 -0
- package/dist/scheduled-task/next-fire-at.d.ts +40 -0
- package/dist/scheduled-task/next-fire-at.d.ts.map +1 -0
- package/dist/scheduled-task/next-fire-at.js +202 -0
- package/dist/scheduled-task/next-fire-at.js.map +1 -0
- package/dist/scheduled-task/runner.d.ts +263 -0
- package/dist/scheduled-task/runner.d.ts.map +1 -0
- package/dist/scheduled-task/runner.js +721 -0
- package/dist/scheduled-task/runner.js.map +1 -0
- package/dist/scheduled-task/state-log.d.ts +56 -0
- package/dist/scheduled-task/state-log.d.ts.map +1 -0
- package/dist/scheduled-task/state-log.js +87 -0
- package/dist/scheduled-task/state-log.js.map +1 -0
- package/dist/scheduled-task/types.d.ts +368 -0
- package/dist/scheduled-task/types.d.ts.map +1 -0
- package/dist/scheduled-task/types.js +14 -0
- package/dist/scheduled-task/types.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resetLadderForSnooze,
|
|
3
|
+
resolveEffectiveLadder
|
|
4
|
+
} from "./escalation.js";
|
|
5
|
+
import { computeNextFireAt } from "./next-fire-at.js";
|
|
6
|
+
import { createStateLogger } from "./state-log.js";
|
|
7
|
+
import {
|
|
8
|
+
APPROVAL_DEFAULT_FOLLOWUP_AFTER_MINUTES,
|
|
9
|
+
DEFAULT_TASK_EXECUTION_PROFILE,
|
|
10
|
+
TASK_EXECUTION_PROFILES
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
class ChannelKeyError extends Error {
|
|
13
|
+
constructor(channelKey, available) {
|
|
14
|
+
super(
|
|
15
|
+
`escalation.steps[].channelKey "${channelKey}" is not registered (registered: ${available.join(", ") || "<none>"})`
|
|
16
|
+
);
|
|
17
|
+
this.channelKey = channelKey;
|
|
18
|
+
this.available = available;
|
|
19
|
+
this.name = "ChannelKeyError";
|
|
20
|
+
}
|
|
21
|
+
channelKey;
|
|
22
|
+
available;
|
|
23
|
+
code = "channel_key_unknown";
|
|
24
|
+
}
|
|
25
|
+
function createInMemoryScheduledTaskStore() {
|
|
26
|
+
const map = /* @__PURE__ */ new Map();
|
|
27
|
+
return {
|
|
28
|
+
async upsert(task) {
|
|
29
|
+
map.set(task.taskId, structuredClone(task));
|
|
30
|
+
},
|
|
31
|
+
async claimForFire({ taskId, firedAtIso }) {
|
|
32
|
+
const existing = map.get(taskId);
|
|
33
|
+
if (existing?.state.status !== "scheduled") {
|
|
34
|
+
return { kind: "raced" };
|
|
35
|
+
}
|
|
36
|
+
const next = structuredClone(existing);
|
|
37
|
+
next.state.status = "fired";
|
|
38
|
+
next.state.firedAt = firedAtIso;
|
|
39
|
+
map.set(taskId, next);
|
|
40
|
+
return { kind: "fired", task: structuredClone(next) };
|
|
41
|
+
},
|
|
42
|
+
async get(taskId) {
|
|
43
|
+
const found = map.get(taskId);
|
|
44
|
+
return found ? structuredClone(found) : null;
|
|
45
|
+
},
|
|
46
|
+
async findByIdempotencyKey(key) {
|
|
47
|
+
for (const t of map.values()) {
|
|
48
|
+
if (t.idempotencyKey === key) {
|
|
49
|
+
return structuredClone(t);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
},
|
|
54
|
+
async list(filter) {
|
|
55
|
+
let view = Array.from(map.values()).map((t) => structuredClone(t));
|
|
56
|
+
if (!filter) return view;
|
|
57
|
+
if (filter.kind) view = view.filter((t) => t.kind === filter.kind);
|
|
58
|
+
if (filter.status) {
|
|
59
|
+
const allowed = Array.isArray(filter.status) ? new Set(filter.status) : /* @__PURE__ */ new Set([filter.status]);
|
|
60
|
+
view = view.filter((t) => allowed.has(t.state.status));
|
|
61
|
+
}
|
|
62
|
+
if (filter.subject) {
|
|
63
|
+
view = view.filter(
|
|
64
|
+
(t) => t.subject?.kind === filter.subject?.kind && t.subject?.id === filter.subject?.id
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (filter.source) view = view.filter((t) => t.source === filter.source);
|
|
68
|
+
if (filter.firedSince) {
|
|
69
|
+
view = view.filter(
|
|
70
|
+
(t) => typeof t.state.firedAt === "string" && t.state.firedAt >= (filter.firedSince ?? "")
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (filter.ownerVisibleOnly) view = view.filter((t) => t.ownerVisible);
|
|
74
|
+
return view;
|
|
75
|
+
},
|
|
76
|
+
async delete(taskId) {
|
|
77
|
+
map.delete(taskId);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const TestNoopScheduledTaskDispatcher = {
|
|
82
|
+
async dispatch() {
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const ALL_PROFILES_AVAILABLE = new Set(
|
|
86
|
+
TASK_EXECUTION_PROFILES
|
|
87
|
+
);
|
|
88
|
+
function defaultTaskIdGenerator() {
|
|
89
|
+
return `st_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
90
|
+
}
|
|
91
|
+
function isTerminal(status) {
|
|
92
|
+
return status === "completed" || status === "skipped" || status === "expired" || status === "failed" || status === "dismissed";
|
|
93
|
+
}
|
|
94
|
+
function isRecurringTrigger(trigger) {
|
|
95
|
+
return trigger.kind === "cron" || trigger.kind === "interval" || trigger.kind === "relative_to_anchor" || trigger.kind === "during_window";
|
|
96
|
+
}
|
|
97
|
+
function setEscalationCursor(task, cursor) {
|
|
98
|
+
task.metadata = {
|
|
99
|
+
...task.metadata ?? {},
|
|
100
|
+
escalationCursor: { ...cursor }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function clearEscalationCursor(task) {
|
|
104
|
+
if (task.metadata && "escalationCursor" in task.metadata) {
|
|
105
|
+
const next = { ...task.metadata };
|
|
106
|
+
delete next.escalationCursor;
|
|
107
|
+
task.metadata = next;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function stripServerManaged(task) {
|
|
111
|
+
const { taskId: _id, state: _state, ...rest } = task;
|
|
112
|
+
return rest;
|
|
113
|
+
}
|
|
114
|
+
function createScheduledTaskRunner(deps) {
|
|
115
|
+
const newTaskId = deps.newTaskId ?? defaultTaskIdGenerator;
|
|
116
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
117
|
+
const dispatcher = deps.dispatcher;
|
|
118
|
+
const logger = createStateLogger({
|
|
119
|
+
store: deps.logStore,
|
|
120
|
+
agentId: deps.agentId,
|
|
121
|
+
now
|
|
122
|
+
});
|
|
123
|
+
async function evaluateGates(task) {
|
|
124
|
+
const compose = task.shouldFire?.compose ?? "first_deny";
|
|
125
|
+
const gates = task.shouldFire?.gates ?? [];
|
|
126
|
+
if (gates.length === 0) {
|
|
127
|
+
return { decision: { kind: "allow" } };
|
|
128
|
+
}
|
|
129
|
+
const ownerFacts = await deps.ownerFacts();
|
|
130
|
+
const ctx = {
|
|
131
|
+
task,
|
|
132
|
+
nowIso: now().toISOString(),
|
|
133
|
+
ownerFacts,
|
|
134
|
+
activity: deps.activity,
|
|
135
|
+
subjectStore: deps.subjectStore
|
|
136
|
+
};
|
|
137
|
+
const decisions = [];
|
|
138
|
+
for (const gateRef of gates) {
|
|
139
|
+
const contrib = deps.gates.get(gateRef.kind);
|
|
140
|
+
if (!contrib) {
|
|
141
|
+
return {
|
|
142
|
+
gateKind: gateRef.kind,
|
|
143
|
+
decision: {
|
|
144
|
+
kind: "deny",
|
|
145
|
+
reason: `unknown gate kind: ${gateRef.kind}`
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const decision = await contrib.evaluate(task, ctx);
|
|
150
|
+
decisions.push({ gateKind: gateRef.kind, decision });
|
|
151
|
+
if (compose === "first_deny" && decision.kind !== "allow") {
|
|
152
|
+
return { gateKind: gateRef.kind, decision };
|
|
153
|
+
}
|
|
154
|
+
if (compose === "any" && decision.kind === "allow") {
|
|
155
|
+
return { gateKind: gateRef.kind, decision: { kind: "allow" } };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (compose === "all") {
|
|
159
|
+
const denied = decisions.find((d) => d.decision.kind !== "allow");
|
|
160
|
+
if (denied) return denied;
|
|
161
|
+
return { decision: { kind: "allow" } };
|
|
162
|
+
}
|
|
163
|
+
if (compose === "any") {
|
|
164
|
+
const lastDeny = decisions.reverse().find((d) => d.decision.kind === "deny");
|
|
165
|
+
if (lastDeny) return lastDeny;
|
|
166
|
+
const lastDefer = decisions.find((d) => d.decision.kind === "defer");
|
|
167
|
+
if (lastDefer) return lastDefer;
|
|
168
|
+
return {
|
|
169
|
+
decision: { kind: "deny", reason: "any: no gate allowed" }
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { decision: { kind: "allow" } };
|
|
173
|
+
}
|
|
174
|
+
async function shouldDeferForGlobalPause(task) {
|
|
175
|
+
if (task.respectsGlobalPause === false) return { paused: false };
|
|
176
|
+
const pause = await deps.globalPause.current(now());
|
|
177
|
+
if (!pause.active) return { paused: false };
|
|
178
|
+
return {
|
|
179
|
+
paused: true,
|
|
180
|
+
reason: pause.reason ? `global_pause: ${pause.reason}` : "global_pause"
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function persist(task) {
|
|
184
|
+
const nextFireAtIso = await resolveNextFireAt(task);
|
|
185
|
+
await deps.store.upsert(task, { nextFireAtIso });
|
|
186
|
+
return structuredClone(task);
|
|
187
|
+
}
|
|
188
|
+
async function resolveNextFireAt(task) {
|
|
189
|
+
if (isTerminal(task.state.status)) return null;
|
|
190
|
+
const ownerFacts = await deps.ownerFacts();
|
|
191
|
+
return computeNextFireAt(task, {
|
|
192
|
+
now: now(),
|
|
193
|
+
ownerFacts,
|
|
194
|
+
anchors: deps.anchors
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function schedule(input) {
|
|
198
|
+
if (input.idempotencyKey) {
|
|
199
|
+
const existing = await deps.store.findByIdempotencyKey(
|
|
200
|
+
input.idempotencyKey
|
|
201
|
+
);
|
|
202
|
+
if (existing) return existing;
|
|
203
|
+
}
|
|
204
|
+
if (deps.channelKeys && input.escalation?.steps) {
|
|
205
|
+
const registered = deps.channelKeys();
|
|
206
|
+
for (const step of input.escalation.steps) {
|
|
207
|
+
if (!registered.has(step.channelKey)) {
|
|
208
|
+
throw new ChannelKeyError(
|
|
209
|
+
step.channelKey,
|
|
210
|
+
Array.from(registered).sort()
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const withApprovalDefaults = applyApprovalCompletionDefault(input);
|
|
216
|
+
const initialState = {
|
|
217
|
+
status: "scheduled",
|
|
218
|
+
followupCount: 0
|
|
219
|
+
};
|
|
220
|
+
const task = {
|
|
221
|
+
taskId: newTaskId(),
|
|
222
|
+
...withApprovalDefaults,
|
|
223
|
+
state: initialState
|
|
224
|
+
};
|
|
225
|
+
await persist(task);
|
|
226
|
+
await logger.log(task.taskId, "scheduled", {
|
|
227
|
+
detail: {
|
|
228
|
+
kind: task.kind,
|
|
229
|
+
priority: task.priority,
|
|
230
|
+
triggerKind: task.trigger.kind
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
if (task.completionCheck?.followupAfterMinutes && task.pipeline?.onSkip && task.pipeline.onSkip.length > 0) {
|
|
234
|
+
await logger.log(task.taskId, "edited", {
|
|
235
|
+
reason: "validation: pipeline.onSkip overrides completionCheck.followupAfterMinutes"
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return task;
|
|
239
|
+
}
|
|
240
|
+
function applyApprovalCompletionDefault(input) {
|
|
241
|
+
if (input.kind !== "approval") return input;
|
|
242
|
+
const onSkipEmpty = !input.pipeline?.onSkip || input.pipeline.onSkip.length === 0;
|
|
243
|
+
if (!onSkipEmpty) return input;
|
|
244
|
+
if (input.completionCheck?.followupAfterMinutes !== void 0) return input;
|
|
245
|
+
const baseCheck = input.completionCheck ?? { kind: "user_acknowledged" };
|
|
246
|
+
return {
|
|
247
|
+
...input,
|
|
248
|
+
completionCheck: {
|
|
249
|
+
...baseCheck,
|
|
250
|
+
followupAfterMinutes: APPROVAL_DEFAULT_FOLLOWUP_AFTER_MINUTES
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function list(filter) {
|
|
255
|
+
return deps.store.list(filter);
|
|
256
|
+
}
|
|
257
|
+
async function applySnooze(task, payload) {
|
|
258
|
+
const minutes = payload?.minutes;
|
|
259
|
+
const untilIso = payload?.untilIso;
|
|
260
|
+
let newFireAtIso;
|
|
261
|
+
if (typeof untilIso === "string") {
|
|
262
|
+
newFireAtIso = new Date(untilIso).toISOString();
|
|
263
|
+
} else if (typeof minutes === "number" && minutes > 0) {
|
|
264
|
+
newFireAtIso = new Date(now().getTime() + minutes * 6e4).toISOString();
|
|
265
|
+
} else {
|
|
266
|
+
throw new Error("snooze: provide minutes or untilIso");
|
|
267
|
+
}
|
|
268
|
+
const reopenStatus = "scheduled";
|
|
269
|
+
task.state.status = reopenStatus;
|
|
270
|
+
task.state.firedAt = newFireAtIso;
|
|
271
|
+
task.state.lastDecisionLog = `snoozed until ${newFireAtIso} (ladder reset)`;
|
|
272
|
+
setEscalationCursor(task, resetLadderForSnooze(newFireAtIso));
|
|
273
|
+
await persist(task);
|
|
274
|
+
await logger.log(task.taskId, "snoozed", {
|
|
275
|
+
reason: `until ${newFireAtIso}`,
|
|
276
|
+
detail: { newFireAtIso }
|
|
277
|
+
});
|
|
278
|
+
return task;
|
|
279
|
+
}
|
|
280
|
+
async function applySkip(task, payload) {
|
|
281
|
+
task.state.status = "skipped";
|
|
282
|
+
task.state.lastDecisionLog = payload?.reason ?? "user skipped";
|
|
283
|
+
await persist(task);
|
|
284
|
+
await logger.log(task.taskId, "skipped", {
|
|
285
|
+
reason: payload?.reason ?? "user skipped"
|
|
286
|
+
});
|
|
287
|
+
await runPipeline(task, "skipped");
|
|
288
|
+
return task;
|
|
289
|
+
}
|
|
290
|
+
async function applyComplete(task, payload) {
|
|
291
|
+
task.state.status = "completed";
|
|
292
|
+
task.state.completedAt = now().toISOString();
|
|
293
|
+
task.state.lastDecisionLog = payload?.reason ?? "completed";
|
|
294
|
+
await persist(task);
|
|
295
|
+
await logger.log(task.taskId, "completed", { reason: payload?.reason });
|
|
296
|
+
await runPipeline(task, "completed");
|
|
297
|
+
return task;
|
|
298
|
+
}
|
|
299
|
+
async function applyDismiss(task, payload) {
|
|
300
|
+
task.state.status = "dismissed";
|
|
301
|
+
task.state.lastDecisionLog = payload?.reason ?? "dismissed";
|
|
302
|
+
await persist(task);
|
|
303
|
+
await logger.log(task.taskId, "dismissed", { reason: payload?.reason });
|
|
304
|
+
return task;
|
|
305
|
+
}
|
|
306
|
+
async function applyEscalate(task, payload) {
|
|
307
|
+
task.state.followupCount += 1;
|
|
308
|
+
task.state.lastFollowupAt = now().toISOString();
|
|
309
|
+
task.state.lastDecisionLog = "escalated";
|
|
310
|
+
await persist(task);
|
|
311
|
+
await logger.log(task.taskId, "escalated", {
|
|
312
|
+
reason: payload?.force ? "force=true" : void 0
|
|
313
|
+
});
|
|
314
|
+
return task;
|
|
315
|
+
}
|
|
316
|
+
async function applyAcknowledge(task) {
|
|
317
|
+
task.state.status = "acknowledged";
|
|
318
|
+
task.state.acknowledgedAt = now().toISOString();
|
|
319
|
+
task.state.lastDecisionLog = "acknowledged";
|
|
320
|
+
await persist(task);
|
|
321
|
+
await logger.log(task.taskId, "acknowledged");
|
|
322
|
+
return task;
|
|
323
|
+
}
|
|
324
|
+
async function applyEdit(task, payload) {
|
|
325
|
+
if (!payload) return task;
|
|
326
|
+
const banned = ["taskId", "state"];
|
|
327
|
+
for (const key of banned) {
|
|
328
|
+
if (key in payload) {
|
|
329
|
+
throw new Error(`edit: ${String(key)} is read-only`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
Object.assign(task, payload);
|
|
333
|
+
await persist(task);
|
|
334
|
+
await logger.log(task.taskId, "edited", {
|
|
335
|
+
detail: { keys: Object.keys(payload) }
|
|
336
|
+
});
|
|
337
|
+
return task;
|
|
338
|
+
}
|
|
339
|
+
async function applyReopen(task, payload) {
|
|
340
|
+
if (!isTerminal(task.state.status)) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`reopen: task ${task.taskId} is not in a terminal state (status=${task.state.status})`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const windowHours = (() => {
|
|
346
|
+
const raw = task.metadata?.reopenWindowHours;
|
|
347
|
+
return typeof raw === "number" && raw > 0 ? raw : 24;
|
|
348
|
+
})();
|
|
349
|
+
const referenceIso = task.state.lastFollowupAt ?? task.state.firedAt ?? task.state.completedAt ?? now().toISOString();
|
|
350
|
+
const expiresMs = new Date(referenceIso).getTime() + windowHours * 60 * 60 * 1e3;
|
|
351
|
+
if (now().getTime() > expiresMs) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`reopen: window expired (>${windowHours}h since ${referenceIso})`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
task.state.status = "scheduled";
|
|
357
|
+
task.state.lastDecisionLog = payload?.reason ?? "reopened";
|
|
358
|
+
clearEscalationCursor(task);
|
|
359
|
+
await persist(task);
|
|
360
|
+
await logger.log(task.taskId, "reopened", { reason: payload?.reason });
|
|
361
|
+
return task;
|
|
362
|
+
}
|
|
363
|
+
async function apply(taskId, verb, payload) {
|
|
364
|
+
const task = await deps.store.get(taskId);
|
|
365
|
+
if (!task) {
|
|
366
|
+
throw new Error(`apply: task ${taskId} not found`);
|
|
367
|
+
}
|
|
368
|
+
switch (verb) {
|
|
369
|
+
case "snooze":
|
|
370
|
+
return applySnooze(
|
|
371
|
+
task,
|
|
372
|
+
payload
|
|
373
|
+
);
|
|
374
|
+
case "skip":
|
|
375
|
+
return applySkip(task, payload);
|
|
376
|
+
case "complete":
|
|
377
|
+
return applyComplete(task, payload);
|
|
378
|
+
case "dismiss":
|
|
379
|
+
return applyDismiss(task, payload);
|
|
380
|
+
case "escalate":
|
|
381
|
+
return applyEscalate(task, payload);
|
|
382
|
+
case "acknowledge":
|
|
383
|
+
return applyAcknowledge(task);
|
|
384
|
+
case "edit":
|
|
385
|
+
return applyEdit(
|
|
386
|
+
task,
|
|
387
|
+
payload
|
|
388
|
+
);
|
|
389
|
+
case "reopen":
|
|
390
|
+
return applyReopen(task, payload);
|
|
391
|
+
default: {
|
|
392
|
+
const exhaustive = verb;
|
|
393
|
+
throw new Error(`apply: unknown verb ${String(exhaustive)}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function runPipeline(parent, outcome) {
|
|
398
|
+
const refs = (() => {
|
|
399
|
+
switch (outcome) {
|
|
400
|
+
case "completed":
|
|
401
|
+
return parent.pipeline?.onComplete;
|
|
402
|
+
case "skipped":
|
|
403
|
+
return parent.pipeline?.onSkip;
|
|
404
|
+
case "failed":
|
|
405
|
+
return parent.pipeline?.onFail;
|
|
406
|
+
// expired / dismissed do not propagate; pipeline.onSkip captures
|
|
407
|
+
// the user-skip case explicitly.
|
|
408
|
+
default:
|
|
409
|
+
return void 0;
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
412
|
+
if (!refs || refs.length === 0) return [];
|
|
413
|
+
const created = [];
|
|
414
|
+
for (const ref of refs) {
|
|
415
|
+
if (typeof ref === "string") {
|
|
416
|
+
const child = await deps.store.get(ref);
|
|
417
|
+
if (child) {
|
|
418
|
+
child.state.pipelineParentId = parent.taskId;
|
|
419
|
+
await persist(child);
|
|
420
|
+
await logger.log(child.taskId, "edited", {
|
|
421
|
+
reason: `pipeline.${outcomeToFieldName(outcome)} parent=${parent.taskId}`
|
|
422
|
+
});
|
|
423
|
+
created.push(child);
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const cloned = structuredClone(ref);
|
|
428
|
+
const childInput = stripServerManaged(cloned);
|
|
429
|
+
const fresh = await schedule(childInput);
|
|
430
|
+
fresh.state.pipelineParentId = parent.taskId;
|
|
431
|
+
await persist(fresh);
|
|
432
|
+
created.push(fresh);
|
|
433
|
+
}
|
|
434
|
+
return created;
|
|
435
|
+
}
|
|
436
|
+
function outcomeToFieldName(outcome) {
|
|
437
|
+
switch (outcome) {
|
|
438
|
+
case "completed":
|
|
439
|
+
return "onComplete";
|
|
440
|
+
case "skipped":
|
|
441
|
+
return "onSkip";
|
|
442
|
+
case "failed":
|
|
443
|
+
return "onFail";
|
|
444
|
+
default:
|
|
445
|
+
return outcome;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function pipeline(taskId, outcome) {
|
|
449
|
+
const task = await deps.store.get(taskId);
|
|
450
|
+
if (!task) throw new Error(`pipeline: task ${taskId} not found`);
|
|
451
|
+
if (!isTerminal(task.state.status) && task.state.status !== outcome) {
|
|
452
|
+
task.state.status = outcome;
|
|
453
|
+
task.state.lastDecisionLog = `pipeline: ${outcome}`;
|
|
454
|
+
if (outcome === "completed" && !task.state.completedAt) {
|
|
455
|
+
task.state.completedAt = now().toISOString();
|
|
456
|
+
}
|
|
457
|
+
await persist(task);
|
|
458
|
+
await logger.log(task.taskId, outcomeToLogTransition(outcome), {
|
|
459
|
+
reason: `pipeline: ${outcome}`
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return runPipeline(task, outcome);
|
|
463
|
+
}
|
|
464
|
+
function outcomeToLogTransition(outcome) {
|
|
465
|
+
return outcome;
|
|
466
|
+
}
|
|
467
|
+
async function fire(taskId, args) {
|
|
468
|
+
const result = await fireWithResult(taskId, args);
|
|
469
|
+
switch (result.kind) {
|
|
470
|
+
case "fired":
|
|
471
|
+
case "skipped":
|
|
472
|
+
case "dispatch_failed":
|
|
473
|
+
return result.task;
|
|
474
|
+
case "raced": {
|
|
475
|
+
const winner = await deps.store.get(result.taskId);
|
|
476
|
+
if (winner) return winner;
|
|
477
|
+
throw new Error(`fire: task ${result.taskId} not found after race`);
|
|
478
|
+
}
|
|
479
|
+
default: {
|
|
480
|
+
const _exhaustive = result;
|
|
481
|
+
throw new Error("fire: unreachable");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function fireWithResult(taskId, args) {
|
|
486
|
+
const task = await deps.store.get(taskId);
|
|
487
|
+
if (!task) throw new Error(`fire: task ${taskId} not found`);
|
|
488
|
+
if (isTerminal(task.state.status)) {
|
|
489
|
+
const canRefire = args?.allowTerminalRefire === true && task.state.status !== "dismissed" && isRecurringTrigger(task.trigger);
|
|
490
|
+
if (!canRefire) {
|
|
491
|
+
return {
|
|
492
|
+
kind: "skipped",
|
|
493
|
+
task,
|
|
494
|
+
reason: `terminal:${task.state.status}`
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
task.state.status = "scheduled";
|
|
498
|
+
delete task.state.acknowledgedAt;
|
|
499
|
+
delete task.state.completedAt;
|
|
500
|
+
task.state.lastDecisionLog = "recurrence refire";
|
|
501
|
+
clearEscalationCursor(task);
|
|
502
|
+
await persist(task);
|
|
503
|
+
}
|
|
504
|
+
await logger.log(task.taskId, "fire_attempt", {
|
|
505
|
+
detail: { eventPayload: args?.eventPayload ? "present" : "absent" }
|
|
506
|
+
});
|
|
507
|
+
const pause = await shouldDeferForGlobalPause(task);
|
|
508
|
+
if (pause.paused) {
|
|
509
|
+
task.state.status = "skipped";
|
|
510
|
+
task.state.lastDecisionLog = pause.reason ?? "global_pause";
|
|
511
|
+
await persist(task);
|
|
512
|
+
await logger.log(task.taskId, "skipped", {
|
|
513
|
+
reason: pause.reason ?? "global_pause"
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
kind: "skipped",
|
|
517
|
+
task,
|
|
518
|
+
reason: pause.reason ?? "global_pause"
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const gateOutcome = await evaluateGates(task);
|
|
522
|
+
if (gateOutcome.decision.kind === "deny") {
|
|
523
|
+
task.state.status = "skipped";
|
|
524
|
+
task.state.lastDecisionLog = `${gateOutcome.gateKind ?? "gate"}: ${gateOutcome.decision.reason}`;
|
|
525
|
+
await persist(task);
|
|
526
|
+
await logger.log(task.taskId, "skipped", {
|
|
527
|
+
reason: task.state.lastDecisionLog
|
|
528
|
+
});
|
|
529
|
+
await runPipeline(task, "skipped");
|
|
530
|
+
return {
|
|
531
|
+
kind: "skipped",
|
|
532
|
+
task,
|
|
533
|
+
reason: task.state.lastDecisionLog
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (gateOutcome.decision.kind === "defer") {
|
|
537
|
+
const offset = "offsetMinutes" in gateOutcome.decision.until ? gateOutcome.decision.until.offsetMinutes : Math.max(
|
|
538
|
+
1,
|
|
539
|
+
Math.round(
|
|
540
|
+
(new Date(gateOutcome.decision.until.atIso).getTime() - now().getTime()) / 6e4
|
|
541
|
+
)
|
|
542
|
+
);
|
|
543
|
+
task.state.lastDecisionLog = `${gateOutcome.gateKind ?? "gate"}: deferred ${offset}m (${gateOutcome.decision.reason})`;
|
|
544
|
+
const newFireMs = now().getTime() + offset * 6e4;
|
|
545
|
+
task.state.firedAt = new Date(newFireMs).toISOString();
|
|
546
|
+
await persist(task);
|
|
547
|
+
await logger.log(task.taskId, "snoozed", {
|
|
548
|
+
reason: `gate-defer: ${gateOutcome.decision.reason}`,
|
|
549
|
+
detail: { offsetMinutes: offset }
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
kind: "skipped",
|
|
553
|
+
task,
|
|
554
|
+
reason: `gate-defer:${gateOutcome.decision.reason}`
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const fireAtIso = now().toISOString();
|
|
558
|
+
const claim = await deps.store.claimForFire({
|
|
559
|
+
taskId: task.taskId,
|
|
560
|
+
firedAtIso: fireAtIso
|
|
561
|
+
});
|
|
562
|
+
if (claim.kind === "raced") {
|
|
563
|
+
return { kind: "raced", taskId: task.taskId };
|
|
564
|
+
}
|
|
565
|
+
const claimed = claim.task;
|
|
566
|
+
claimed.state.lastDecisionLog = "fired";
|
|
567
|
+
setEscalationCursor(claimed, {
|
|
568
|
+
stepIndex: -1,
|
|
569
|
+
lastDispatchedAt: fireAtIso
|
|
570
|
+
});
|
|
571
|
+
await persist(claimed);
|
|
572
|
+
await logger.log(claimed.taskId, "fired");
|
|
573
|
+
const hostCaps = deps.hostCapabilities?.() ?? ALL_PROFILES_AVAILABLE;
|
|
574
|
+
const taskProfile = claimed.executionProfile ?? DEFAULT_TASK_EXECUTION_PROFILE;
|
|
575
|
+
const substituted = !hostCaps.has(taskProfile);
|
|
576
|
+
const dispatchChannelKey = substituted ? "in_app" : pickChannelKey(claimed);
|
|
577
|
+
if (substituted) {
|
|
578
|
+
await logger.log(claimed.taskId, "substituted", {
|
|
579
|
+
reason: "host_incapable",
|
|
580
|
+
detail: {
|
|
581
|
+
originalProfile: taskProfile,
|
|
582
|
+
substituteProfile: "notify-only",
|
|
583
|
+
availableProfiles: Array.from(hostCaps)
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
let dispatchResult;
|
|
588
|
+
try {
|
|
589
|
+
dispatchResult = await dispatcher.dispatch({
|
|
590
|
+
taskId: claimed.taskId,
|
|
591
|
+
firedAtIso: fireAtIso,
|
|
592
|
+
channelKey: dispatchChannelKey,
|
|
593
|
+
intensity: pickIntensity(claimed),
|
|
594
|
+
promptInstructions: claimed.promptInstructions,
|
|
595
|
+
contextRequest: claimed.contextRequest,
|
|
596
|
+
output: claimed.output
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
600
|
+
const reason = `dispatch_failed: ${wrapped.message}`;
|
|
601
|
+
claimed.state.status = "failed";
|
|
602
|
+
claimed.state.lastDecisionLog = reason;
|
|
603
|
+
claimed.metadata = {
|
|
604
|
+
...claimed.metadata ?? {},
|
|
605
|
+
lastDispatchError: {
|
|
606
|
+
name: wrapped.name,
|
|
607
|
+
message: wrapped.message
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
await persist(claimed);
|
|
611
|
+
await logger.log(claimed.taskId, "failed", {
|
|
612
|
+
reason,
|
|
613
|
+
detail: {
|
|
614
|
+
errorName: wrapped.name,
|
|
615
|
+
message: wrapped.message
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
await runPipeline(claimed, "failed");
|
|
619
|
+
return { kind: "dispatch_failed", task: claimed, error: wrapped };
|
|
620
|
+
}
|
|
621
|
+
if (dispatchResult) {
|
|
622
|
+
claimed.metadata = {
|
|
623
|
+
...claimed.metadata ?? {},
|
|
624
|
+
lastDispatchResult: dispatchResult
|
|
625
|
+
};
|
|
626
|
+
await persist(claimed);
|
|
627
|
+
}
|
|
628
|
+
return { kind: "fired", task: claimed };
|
|
629
|
+
}
|
|
630
|
+
function pickChannelKey(task) {
|
|
631
|
+
if (task.output?.destination === "channel" && typeof task.output.target === "string") {
|
|
632
|
+
const [channelKey] = task.output.target.split(":", 1);
|
|
633
|
+
if (channelKey) return channelKey;
|
|
634
|
+
}
|
|
635
|
+
if (task.escalation?.steps && task.escalation.steps.length > 0) {
|
|
636
|
+
return task.escalation.steps[0]?.channelKey ?? "in_app";
|
|
637
|
+
}
|
|
638
|
+
return "in_app";
|
|
639
|
+
}
|
|
640
|
+
function pickIntensity(task) {
|
|
641
|
+
if (task.priority === "high") return "urgent";
|
|
642
|
+
if (task.priority === "medium") return "normal";
|
|
643
|
+
return "soft";
|
|
644
|
+
}
|
|
645
|
+
async function evaluateCompletion(taskId, signal) {
|
|
646
|
+
const task = await deps.store.get(taskId);
|
|
647
|
+
if (!task) throw new Error(`evaluateCompletion: task ${taskId} not found`);
|
|
648
|
+
if (!task.completionCheck) return task;
|
|
649
|
+
const contrib = deps.completionChecks.get(task.completionCheck.kind);
|
|
650
|
+
if (!contrib) return task;
|
|
651
|
+
const ownerFacts = await deps.ownerFacts();
|
|
652
|
+
const ctx = {
|
|
653
|
+
task,
|
|
654
|
+
nowIso: now().toISOString(),
|
|
655
|
+
ownerFacts,
|
|
656
|
+
activity: deps.activity,
|
|
657
|
+
subjectStore: deps.subjectStore,
|
|
658
|
+
acknowledged: signal.acknowledged === true,
|
|
659
|
+
repliedSinceFiredAt: signal.repliedAtIso ? { atIso: signal.repliedAtIso } : void 0
|
|
660
|
+
};
|
|
661
|
+
const completed = await contrib.shouldComplete(task, ctx);
|
|
662
|
+
if (!completed) return task;
|
|
663
|
+
return applyComplete(task, { reason: `completion-check:${contrib.kind}` });
|
|
664
|
+
}
|
|
665
|
+
async function rolloverStateLog(opts) {
|
|
666
|
+
const days = opts?.retentionDays ?? 90;
|
|
667
|
+
const olderThanIso = new Date(
|
|
668
|
+
now().getTime() - days * 24 * 60 * 60 * 1e3
|
|
669
|
+
).toISOString();
|
|
670
|
+
return deps.logStore.rollupOlderThan({
|
|
671
|
+
agentId: deps.agentId,
|
|
672
|
+
olderThanIso
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
function inspectRegistries() {
|
|
676
|
+
return {
|
|
677
|
+
gates: deps.gates.list().map((g) => g.kind),
|
|
678
|
+
completionChecks: deps.completionChecks.list().map((c) => c.kind),
|
|
679
|
+
ladders: deps.ladders.list().map((l) => l.ladderKey),
|
|
680
|
+
anchors: deps.anchors.list().map((a) => a.anchorKey),
|
|
681
|
+
consolidationPolicies: deps.consolidation.list().map((p) => p.anchorKey)
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async function getEscalationCursor(taskId) {
|
|
685
|
+
const task = await deps.store.get(taskId);
|
|
686
|
+
if (!task) return null;
|
|
687
|
+
const raw = task.metadata?.escalationCursor;
|
|
688
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
689
|
+
const cursor = raw;
|
|
690
|
+
if (typeof cursor.stepIndex !== "number" || typeof cursor.lastDispatchedAt !== "string") {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
const ladder = resolveEffectiveLadder(task, deps.ladders);
|
|
694
|
+
const stepIndex = cursor.stepIndex;
|
|
695
|
+
const channelKey = stepIndex >= 0 && stepIndex < ladder.steps.length ? ladder.steps[stepIndex]?.channelKey ?? "in_app" : ladder.steps[0]?.channelKey ?? "in_app";
|
|
696
|
+
return {
|
|
697
|
+
stepIndex,
|
|
698
|
+
lastFiredAt: cursor.lastDispatchedAt,
|
|
699
|
+
channelKey
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
schedule,
|
|
704
|
+
list,
|
|
705
|
+
apply,
|
|
706
|
+
pipeline,
|
|
707
|
+
fire,
|
|
708
|
+
fireWithResult,
|
|
709
|
+
evaluateCompletion,
|
|
710
|
+
rolloverStateLog,
|
|
711
|
+
inspectRegistries,
|
|
712
|
+
getEscalationCursor
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
export {
|
|
716
|
+
ChannelKeyError,
|
|
717
|
+
TestNoopScheduledTaskDispatcher,
|
|
718
|
+
createInMemoryScheduledTaskStore,
|
|
719
|
+
createScheduledTaskRunner
|
|
720
|
+
};
|
|
721
|
+
//# sourceMappingURL=runner.js.map
|