@elizaos/plugin-scheduling 2.0.3-beta.6 → 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.
Files changed (57) hide show
  1. package/dist/anchors/anchor-registry.d.ts +33 -0
  2. package/dist/anchors/anchor-registry.d.ts.map +1 -0
  3. package/dist/anchors/anchor-registry.js +129 -0
  4. package/dist/anchors/anchor-registry.js.map +1 -0
  5. package/dist/dispatch-types.d.ts +28 -0
  6. package/dist/dispatch-types.d.ts.map +1 -0
  7. package/dist/dispatch-types.js +1 -0
  8. package/dist/dispatch-types.js.map +1 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +18 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/plugin.d.ts +17 -0
  14. package/dist/plugin.d.ts.map +1 -0
  15. package/dist/plugin.js +8 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/scheduled-task/completion-check-registry.d.ts +19 -0
  18. package/dist/scheduled-task/completion-check-registry.d.ts.map +1 -0
  19. package/dist/scheduled-task/completion-check-registry.js +113 -0
  20. package/dist/scheduled-task/completion-check-registry.js.map +1 -0
  21. package/dist/scheduled-task/consolidation-policy.d.ts +51 -0
  22. package/dist/scheduled-task/consolidation-policy.d.ts.map +1 -0
  23. package/dist/scheduled-task/consolidation-policy.js +154 -0
  24. package/dist/scheduled-task/consolidation-policy.js.map +1 -0
  25. package/dist/scheduled-task/due.d.ts +19 -0
  26. package/dist/scheduled-task/due.d.ts.map +1 -0
  27. package/dist/scheduled-task/due.js +349 -0
  28. package/dist/scheduled-task/due.js.map +1 -0
  29. package/dist/scheduled-task/escalation.d.ts +55 -0
  30. package/dist/scheduled-task/escalation.d.ts.map +1 -0
  31. package/dist/scheduled-task/escalation.js +99 -0
  32. package/dist/scheduled-task/escalation.js.map +1 -0
  33. package/dist/scheduled-task/gate-registry.d.ts +18 -0
  34. package/dist/scheduled-task/gate-registry.d.ts.map +1 -0
  35. package/dist/scheduled-task/gate-registry.js +244 -0
  36. package/dist/scheduled-task/gate-registry.js.map +1 -0
  37. package/dist/scheduled-task/index.d.ts +20 -0
  38. package/dist/scheduled-task/index.d.ts.map +1 -0
  39. package/dist/scheduled-task/index.js +83 -0
  40. package/dist/scheduled-task/index.js.map +1 -0
  41. package/dist/scheduled-task/next-fire-at.d.ts +40 -0
  42. package/dist/scheduled-task/next-fire-at.d.ts.map +1 -0
  43. package/dist/scheduled-task/next-fire-at.js +202 -0
  44. package/dist/scheduled-task/next-fire-at.js.map +1 -0
  45. package/dist/scheduled-task/runner.d.ts +263 -0
  46. package/dist/scheduled-task/runner.d.ts.map +1 -0
  47. package/dist/scheduled-task/runner.js +721 -0
  48. package/dist/scheduled-task/runner.js.map +1 -0
  49. package/dist/scheduled-task/state-log.d.ts +56 -0
  50. package/dist/scheduled-task/state-log.d.ts.map +1 -0
  51. package/dist/scheduled-task/state-log.js +87 -0
  52. package/dist/scheduled-task/state-log.js.map +1 -0
  53. package/dist/scheduled-task/types.d.ts +368 -0
  54. package/dist/scheduled-task/types.d.ts.map +1 -0
  55. package/dist/scheduled-task/types.js +14 -0
  56. package/dist/scheduled-task/types.js.map +1 -0
  57. 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