@trevonistrevon/pi-loop 0.4.10 → 0.5.0

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 (45) hide show
  1. package/README.md +11 -1
  2. package/dist/coordinator.d.ts +35 -0
  3. package/dist/coordinator.js +56 -0
  4. package/dist/goal-types.d.ts +78 -0
  5. package/dist/goal-types.js +1 -0
  6. package/dist/goal-verifier.d.ts +20 -0
  7. package/dist/goal-verifier.js +198 -0
  8. package/dist/index.js +182 -40
  9. package/dist/loop-reducer.d.ts +63 -0
  10. package/dist/loop-reducer.js +67 -0
  11. package/dist/monitor-completion-coordinator.d.ts +10 -0
  12. package/dist/monitor-completion-coordinator.js +13 -0
  13. package/dist/monitor-manager.d.ts +2 -0
  14. package/dist/monitor-manager.js +107 -29
  15. package/dist/monitor-reducer.d.ts +82 -0
  16. package/dist/monitor-reducer.js +69 -0
  17. package/dist/notification-reducer.d.ts +81 -0
  18. package/dist/notification-reducer.js +65 -0
  19. package/dist/store.d.ts +2 -0
  20. package/dist/store.js +118 -44
  21. package/dist/task-backlog-coordinator.d.ts +12 -0
  22. package/dist/task-backlog-coordinator.js +22 -0
  23. package/dist/task-reducer.d.ts +66 -0
  24. package/dist/task-reducer.js +76 -0
  25. package/dist/task-store.d.ts +2 -0
  26. package/dist/task-store.js +82 -30
  27. package/docs/architecture/goal-state-schema.md +505 -0
  28. package/docs/architecture/state-machine-migration.md +546 -0
  29. package/docs/architecture/state-machine-reducer-event-model.md +823 -0
  30. package/docs/architecture/state-machine-test-matrix.md +249 -0
  31. package/docs/architecture/state-machine-transition-map.md +436 -0
  32. package/package.json +1 -1
  33. package/src/coordinator.ts +115 -0
  34. package/src/goal-types.ts +99 -0
  35. package/src/goal-verifier.ts +241 -0
  36. package/src/index.ts +209 -39
  37. package/src/loop-reducer.ts +148 -0
  38. package/src/monitor-completion-coordinator.ts +24 -0
  39. package/src/monitor-manager.ts +115 -27
  40. package/src/monitor-reducer.ts +166 -0
  41. package/src/notification-reducer.ts +155 -0
  42. package/src/store.ts +119 -43
  43. package/src/task-backlog-coordinator.ts +32 -0
  44. package/src/task-reducer.ts +152 -0
  45. package/src/task-store.ts +84 -27
package/src/index.ts CHANGED
@@ -18,10 +18,30 @@ import { randomUUID } from "node:crypto";
18
18
  import { join, resolve } from "node:path";
19
19
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
+ import {
22
+ createCoordinator,
23
+ type ReducerEffect,
24
+ type ReducerEvent,
25
+ type ReducerHandler,
26
+ } from "./coordinator.js";
21
27
  import { parseInterval } from "./loop-parse.js";
28
+ import {
29
+ type MonitorCompletionEvent,
30
+ reduceMonitorCompletionEvent,
31
+ } from "./monitor-completion-coordinator.js";
22
32
  import { MonitorManager } from "./monitor-manager.js";
33
+ import {
34
+ type NotificationReducerEvent,
35
+ type NotificationReducerState,
36
+ type ReducerNotification,
37
+ reduceNotificationState,
38
+ } from "./notification-reducer.js";
23
39
  import { CronScheduler } from "./scheduler.js";
24
40
  import { LoopStore } from "./store.js";
41
+ import {
42
+ reduceTaskBacklogEvent,
43
+ type TaskBacklogEvent,
44
+ } from "./task-backlog-coordinator.js";
25
45
  import { TaskStore } from "./task-store.js";
26
46
  import { TriggerSystem } from "./trigger-system.js";
27
47
  import type { LoopEntry, Trigger } from "./types.js";
@@ -196,13 +216,97 @@ export default function (pi: ExtensionAPI) {
196
216
  if (nativeTaskStore) {
197
217
  nativeTaskStore.sweepCompleted();
198
218
  widget.update();
199
- await cleanupTaskBacklogLoops();
219
+ await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
200
220
  }
201
221
  }
202
222
 
203
- let agentRunning = false;
204
- const pendingNotifications = new Map<string, PendingNotification>();
223
+ let notificationState: NotificationReducerState = {
224
+ notificationsByKey: {},
225
+ agentRunning: false,
226
+ hasPendingMessages: false,
227
+ };
205
228
  let flushPromise: Promise<void> | undefined;
229
+ let notificationCoordinatorDelivered = false;
230
+ let notificationCoordinatorDeliveredSuccessfully = false;
231
+
232
+ const notificationReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
233
+ const result = reduceNotificationState(notificationState, incoming as NotificationReducerEvent);
234
+ notificationState = result.state;
235
+ return result.effects;
236
+ };
237
+
238
+ const notificationCoordinator = createCoordinator({
239
+ reducers: [notificationReducerHandler],
240
+ effectHandlers: {
241
+ REQUEST_NOTIFICATION_FLUSH: () => {},
242
+ DELIVER_NOTIFICATION: async (effect: ReducerEffect) => {
243
+ notificationCoordinatorDelivered = true;
244
+ notificationCoordinatorDeliveredSuccessfully = await deliverNotification(
245
+ (effect.payload as { notification: ReducerNotification }).notification,
246
+ );
247
+ },
248
+ },
249
+ });
250
+
251
+ const monitorCompletionReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
252
+ if (incoming.type !== "MONITOR_ONDONE_TRIGGERED") return [];
253
+ return reduceMonitorCompletionEvent(incoming as MonitorCompletionEvent);
254
+ };
255
+
256
+ const monitorCompletionCoordinator = createCoordinator({
257
+ reducers: [monitorCompletionReducerHandler],
258
+ effectHandlers: {
259
+ DELIVER_MONITOR_ONDONE_WAKE: (effect: ReducerEffect) => {
260
+ const { loopId, monitorId } = effect.payload as { loopId: string; monitorId: string };
261
+ const current = store.get(loopId);
262
+ if (!current) return;
263
+ debug(`onDone loop #${loopId} — monitor #${monitorId} completed, delivering through coordinator`);
264
+ onLoopFire(current);
265
+ store.delete(loopId);
266
+ },
267
+ },
268
+ });
269
+
270
+ let taskBacklogCoordinatorStore: TaskStore | undefined;
271
+ let taskBacklogCoordinatorWorker: { entry?: LoopEntry; created: boolean } = { created: false };
272
+ let taskBacklogCoordinatorCleanupCount = 0;
273
+
274
+ const taskBacklogReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
275
+ if (incoming.type !== "TASK_BACKLOG_EVALUATED") return [];
276
+ return reduceTaskBacklogEvent(incoming as TaskBacklogEvent);
277
+ };
278
+
279
+ const taskBacklogCoordinator = createCoordinator({
280
+ reducers: [taskBacklogReducerHandler],
281
+ effectHandlers: {
282
+ ENSURE_AUTO_TASK_WORKER: async () => {
283
+ if (!taskBacklogCoordinatorStore) return;
284
+ taskBacklogCoordinatorWorker = await ensureAutoTaskWorkerLoop(taskBacklogCoordinatorStore);
285
+ },
286
+ CLEANUP_TASK_BACKLOG_LOOPS: async () => {
287
+ taskBacklogCoordinatorCleanupCount = await cleanupTaskBacklogLoops();
288
+ },
289
+ },
290
+ });
291
+
292
+ function applyNotificationEvent(event: NotificationReducerEvent) {
293
+ const result = reduceNotificationState(notificationState, event);
294
+ notificationState = result.state;
295
+ return result;
296
+ }
297
+
298
+ function syncNotificationRuntimeState(options?: { agentRunning?: boolean; hasPendingMessages?: boolean }) {
299
+ applyNotificationEvent({
300
+ type: "NOTIFICATION_RUNTIME_UPDATED",
301
+ at: Date.now(),
302
+ source: "system",
303
+ entityType: "notification",
304
+ payload: {
305
+ agentRunning: options?.agentRunning ?? notificationState.agentRunning,
306
+ hasPendingMessages: options?.hasPendingMessages ?? (_latestCtx?.hasPendingMessages() ?? false),
307
+ },
308
+ });
309
+ }
206
310
 
207
311
  function buildLoopFireMessage(data: LoopFireEvent): string {
208
312
  const triggerInfo = typeof data.trigger === "string"
@@ -263,7 +367,7 @@ export default function (pi: ExtensionAPI) {
263
367
  return true;
264
368
  }
265
369
 
266
- async function deliverNotification(notification: PendingNotification): Promise<boolean> {
370
+ async function deliverNotification(notification: ReducerNotification): Promise<boolean> {
267
371
  if (notification.autoTask) {
268
372
  const pending = await hasPendingTasks();
269
373
  if (pending === 0) {
@@ -273,7 +377,7 @@ export default function (pi: ExtensionAPI) {
273
377
  }
274
378
  }
275
379
 
276
- agentRunning = true;
380
+ syncNotificationRuntimeState({ agentRunning: true });
277
381
  pi.sendMessage({
278
382
  customType: "pi-loop",
279
383
  content: notification.message,
@@ -297,16 +401,22 @@ export default function (pi: ExtensionAPI) {
297
401
  if (flushPromise) return flushPromise;
298
402
 
299
403
  flushPromise = (async () => {
300
- if (agentRunning) return;
301
- if (!options?.ignorePendingMessages && _latestCtx?.hasPendingMessages()) return;
302
-
303
- const entries = [...pendingNotifications.entries()]
304
- .sort(([, left], [, right]) => left.timestamp - right.timestamp);
404
+ syncNotificationRuntimeState({
405
+ hasPendingMessages: _latestCtx?.hasPendingMessages() ?? false,
406
+ });
305
407
 
306
- for (const [key, notification] of entries) {
307
- pendingNotifications.delete(key);
308
- const delivered = await deliverNotification(notification);
309
- if (delivered) return;
408
+ while (true) {
409
+ notificationCoordinatorDelivered = false;
410
+ notificationCoordinatorDeliveredSuccessfully = false;
411
+ await notificationCoordinator.dispatch({
412
+ type: "NOTIFICATION_FLUSH_REQUESTED",
413
+ at: Date.now(),
414
+ source: "system",
415
+ entityType: "notification",
416
+ payload: { ignorePendingMessages: options?.ignorePendingMessages },
417
+ });
418
+ if (!notificationCoordinatorDelivered) return;
419
+ if (notificationCoordinatorDeliveredSuccessfully) return;
310
420
  }
311
421
  })().finally(() => {
312
422
  flushPromise = undefined;
@@ -317,7 +427,14 @@ export default function (pi: ExtensionAPI) {
317
427
 
318
428
  async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
319
429
  const notification = buildPendingNotification(data);
320
- pendingNotifications.set(notification.key, notification);
430
+ applyNotificationEvent({
431
+ type: "NOTIFICATION_QUEUED",
432
+ at: notification.timestamp,
433
+ source: "system",
434
+ entityType: "notification",
435
+ entityId: notification.key,
436
+ payload: { notification },
437
+ });
321
438
  await flushPendingNotifications();
322
439
  }
323
440
 
@@ -401,31 +518,43 @@ export default function (pi: ExtensionAPI) {
401
518
  });
402
519
 
403
520
  pi.on("agent_start", async (_event, ctx) => {
404
- agentRunning = true;
521
+ syncNotificationRuntimeState({ agentRunning: true, hasPendingMessages: ctx.hasPendingMessages() });
405
522
  _latestCtx = ctx;
406
523
  widget.setUICtx(ctx.ui);
407
524
  });
408
525
 
409
526
  pi.on("agent_end", async (_event, ctx) => {
410
- agentRunning = false;
411
527
  _latestCtx = ctx;
412
528
  widget.setUICtx(ctx.ui);
529
+ syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: ctx.hasPendingMessages() });
413
530
  await flushPendingNotifications({ ignorePendingMessages: true });
414
531
  await cleanupTaskBacklogLoops();
415
532
  await pumpLoops();
416
533
  });
417
534
 
418
535
  pi.on("session_shutdown", async () => {
419
- agentRunning = false;
420
- pendingNotifications.clear();
536
+ syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: false });
537
+ applyNotificationEvent({
538
+ type: "NOTIFICATION_CLEARED",
539
+ at: Date.now(),
540
+ source: "session",
541
+ entityType: "notification",
542
+ payload: { reason: "session_shutdown" },
543
+ });
421
544
  });
422
545
 
423
546
  pi.on("session_switch" as any, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
424
547
  _latestCtx = ctx;
425
548
  widget.setUICtx(ctx.ui);
426
549
  triggerSystem.stop();
427
- agentRunning = false;
428
- pendingNotifications.clear();
550
+ syncNotificationRuntimeState({ agentRunning: false, hasPendingMessages: false });
551
+ applyNotificationEvent({
552
+ type: "NOTIFICATION_CLEARED",
553
+ at: Date.now(),
554
+ source: "session",
555
+ entityType: "notification",
556
+ payload: { reason: "session_switch" },
557
+ });
429
558
  _sessionId = undefined;
430
559
 
431
560
  const isResume = event?.reason === "resume";
@@ -441,6 +570,19 @@ export default function (pi: ExtensionAPI) {
441
570
  widget.update();
442
571
  });
443
572
 
573
+ pi.on("tool_execution_end", async (event: any, ctx: ExtensionContext) => {
574
+ _latestCtx = ctx;
575
+ widget.setUICtx(ctx.ui);
576
+
577
+ if (event?.toolName !== "bash" || event?.isError) return;
578
+
579
+ const command = event?.args?.command ?? event?.input?.command;
580
+ if (typeof command !== "string") return;
581
+ if (!/^\s*git\s+commit\b/i.test(command)) return;
582
+
583
+ await cleanDoneTasks();
584
+ });
585
+
444
586
  // ── Dynamic loop pump — fires cron/hybrid loops on idle instead of wall-clock timers ──
445
587
 
446
588
  async function pumpLoops(): Promise<void> {
@@ -622,11 +764,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
622
764
 
623
765
  function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
624
766
  const deliver = () => {
625
- const current = store.get(doneLoop.id);
626
- if (!current) return;
627
- debug(`onDone loop #${doneLoop.id} — monitor #${monitorId} completed, delivering directly`);
628
- onLoopFire(current);
629
- store.delete(doneLoop.id);
767
+ void monitorCompletionCoordinator.dispatch({
768
+ type: "MONITOR_ONDONE_TRIGGERED",
769
+ at: Date.now(),
770
+ source: "monitor",
771
+ entityType: "monitor",
772
+ entityId: monitorId,
773
+ payload: { loopId: doneLoop.id, monitorId },
774
+ });
630
775
  };
631
776
 
632
777
  const registered = monitorManager.onComplete(monitorId, deliver);
@@ -1084,6 +1229,28 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1084
1229
  return { entry, created: true };
1085
1230
  }
1086
1231
 
1232
+ async function evaluateTaskBacklog(taskStore?: TaskStore, pendingCount?: number): Promise<{ entry?: LoopEntry; created: boolean; cleaned: number }> {
1233
+ const resolvedPending = pendingCount ?? (taskStore ? taskStore.pendingCount() : await hasPendingTasks());
1234
+ taskBacklogCoordinatorStore = taskStore;
1235
+ taskBacklogCoordinatorWorker = { created: false };
1236
+ taskBacklogCoordinatorCleanupCount = 0;
1237
+
1238
+ await taskBacklogCoordinator.dispatch({
1239
+ type: "TASK_BACKLOG_EVALUATED",
1240
+ at: Date.now(),
1241
+ source: "system",
1242
+ entityType: "task",
1243
+ payload: { pendingCount: resolvedPending, threshold: AUTO_TASK_WORKER_THRESHOLD },
1244
+ });
1245
+
1246
+ taskBacklogCoordinatorStore = undefined;
1247
+ return {
1248
+ entry: taskBacklogCoordinatorWorker.entry,
1249
+ created: taskBacklogCoordinatorWorker.created,
1250
+ cleaned: taskBacklogCoordinatorCleanupCount,
1251
+ };
1252
+ }
1253
+
1087
1254
  async function createNativeTaskInteractively(ui: ExtensionUIContext) {
1088
1255
  if (!nativeTaskStore) {
1089
1256
  ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
@@ -1100,11 +1267,11 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1100
1267
  description: entry.description,
1101
1268
  status: entry.status,
1102
1269
  });
1103
- const worker = await ensureAutoTaskWorkerLoop(nativeTaskStore);
1270
+ const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
1104
1271
  widget.update();
1105
1272
  ui.notify(`Task #${entry.id} created`, "info");
1106
- if (worker.created && worker.entry) {
1107
- ui.notify(`Worker loop #${worker.entry.id} auto-created`, "info");
1273
+ if (backlog.created && backlog.entry) {
1274
+ ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
1108
1275
  }
1109
1276
  }
1110
1277
 
@@ -1165,7 +1332,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1165
1332
  }
1166
1333
 
1167
1334
  widget.update();
1168
- await cleanupTaskBacklogLoops();
1335
+ await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
1169
1336
  return viewNativeTasks(ui);
1170
1337
  }
1171
1338
 
@@ -1193,11 +1360,11 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
1193
1360
  description: entry.description,
1194
1361
  status: entry.status,
1195
1362
  });
1196
- const worker = await ensureAutoTaskWorkerLoop(nativeTaskStore);
1363
+ const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
1197
1364
  widget.update();
1198
1365
  ctx.ui.notify(`Task #${entry.id} created`, "info");
1199
- if (worker.created && worker.entry) {
1200
- ctx.ui.notify(`Worker loop #${worker.entry.id} auto-created`, "info");
1366
+ if (backlog.created && backlog.entry) {
1367
+ ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
1201
1368
  }
1202
1369
  return;
1203
1370
  }
@@ -1231,11 +1398,11 @@ Fields:
1231
1398
  description: entry.description,
1232
1399
  status: entry.status,
1233
1400
  });
1234
- const worker = await ensureAutoTaskWorkerLoop(taskStore);
1401
+ const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
1235
1402
  widget.update();
1236
1403
 
1237
- const autoLoopMsg = worker.created && worker.entry
1238
- ? `\nWorker loop #${worker.entry.id} auto-created`
1404
+ const autoLoopMsg = backlog.created && backlog.entry
1405
+ ? `\nWorker loop #${backlog.entry.id} auto-created`
1239
1406
  : "";
1240
1407
  return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
1241
1408
  },
@@ -1293,9 +1460,12 @@ Parameters: id (required), status, subject, description`,
1293
1460
  });
1294
1461
  if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
1295
1462
  widget.update();
1296
- await cleanupTaskBacklogLoops();
1463
+ const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
1297
1464
  const statusMsg = status ? ` → ${status}` : "";
1298
- return Promise.resolve(textResult(`Task #${id} updated${statusMsg}`));
1465
+ const autoLoopMsg = backlog.created && backlog.entry
1466
+ ? `\nWorker loop #${backlog.entry.id} auto-created`
1467
+ : "";
1468
+ return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
1299
1469
  },
1300
1470
  });
1301
1471
 
@@ -1310,7 +1480,7 @@ Parameters: id (required), status, subject, description`,
1310
1480
  const deleted = taskStore.delete(params.id);
1311
1481
  widget.update();
1312
1482
  if (deleted) {
1313
- await cleanupTaskBacklogLoops();
1483
+ await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
1314
1484
  return Promise.resolve(textResult(`Task #${params.id} deleted`));
1315
1485
  }
1316
1486
  return Promise.resolve(textResult(`Task #${params.id} not found`));
@@ -0,0 +1,148 @@
1
+ import type { LoopEntry, Trigger } from "./types.js";
2
+
3
+ export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4
+
5
+ type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
6
+
7
+ export interface LoopReducerState {
8
+ nextId: number;
9
+ loopsById: Record<string, LoopEntry>;
10
+ }
11
+
12
+ export type LoopReducerEvent =
13
+ | {
14
+ type: "LOOP_CREATED";
15
+ at: number;
16
+ source: ReducerSource;
17
+ entityType?: "loop";
18
+ entityId?: string;
19
+ payload: {
20
+ prompt: string;
21
+ trigger: Trigger;
22
+ recurring: boolean;
23
+ autoTask?: boolean;
24
+ taskBacklog?: boolean;
25
+ readOnly?: boolean;
26
+ maxFires?: number;
27
+ };
28
+ }
29
+ | {
30
+ type:
31
+ | "LOOP_PAUSED"
32
+ | "LOOP_RESUMED"
33
+ | "LOOP_FIRED"
34
+ | "LOOP_DELETED"
35
+ | "LOOP_MAX_FIRES_REACHED"
36
+ | "LOOP_BACKLOG_EMPTY";
37
+ at: number;
38
+ source: ReducerSource;
39
+ entityType?: "loop";
40
+ entityId?: string;
41
+ payload: { id: string };
42
+ }
43
+ | {
44
+ type: "LOOP_EXPIRED";
45
+ at: number;
46
+ source: ReducerSource;
47
+ entityType?: "loop";
48
+ entityId?: string;
49
+ payload: {
50
+ id: string;
51
+ reason: "expires_at" | "resume_event_stale" | "already_completed_monitor";
52
+ };
53
+ };
54
+
55
+ export type LoopReducerEffect =
56
+ | {
57
+ type: "PERSIST_LOOP";
58
+ entityType: "loop";
59
+ entityId: string;
60
+ payload: { loop: LoopEntry };
61
+ }
62
+ | {
63
+ type: "DELETE_LOOP";
64
+ entityType: "loop";
65
+ entityId: string;
66
+ payload: { id: string };
67
+ };
68
+
69
+ export interface LoopReduceResult {
70
+ state: LoopReducerState;
71
+ effects: LoopReducerEffect[];
72
+ }
73
+
74
+ function cloneState(state: LoopReducerState): LoopReducerState {
75
+ return {
76
+ nextId: state.nextId,
77
+ loopsById: { ...state.loopsById },
78
+ };
79
+ }
80
+
81
+ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent): LoopReduceResult {
82
+ if (event.type === "LOOP_CREATED") {
83
+ const next = cloneState(state);
84
+ const id = String(next.nextId++);
85
+ const loop: LoopEntry = {
86
+ id,
87
+ prompt: event.payload.prompt,
88
+ trigger: event.payload.trigger,
89
+ status: "active",
90
+ recurring: event.payload.recurring,
91
+ createdAt: event.at,
92
+ updatedAt: event.at,
93
+ expiresAt: event.at + MAX_LOOP_EXPIRY_MS,
94
+ autoTask: event.payload.autoTask,
95
+ taskBacklog: event.payload.taskBacklog,
96
+ readOnly: event.payload.readOnly,
97
+ maxFires: event.payload.maxFires,
98
+ fireCount: 0,
99
+ };
100
+ next.loopsById[id] = loop;
101
+ return {
102
+ state: next,
103
+ effects: [{ type: "PERSIST_LOOP", entityType: "loop", entityId: id, payload: { loop } }],
104
+ };
105
+ }
106
+
107
+ const id = event.payload.id;
108
+ const current = state.loopsById[id];
109
+ if (!current) return { state, effects: [] };
110
+
111
+ if (
112
+ event.type === "LOOP_DELETED"
113
+ || event.type === "LOOP_MAX_FIRES_REACHED"
114
+ || event.type === "LOOP_EXPIRED"
115
+ || event.type === "LOOP_BACKLOG_EMPTY"
116
+ ) {
117
+ const next = cloneState(state);
118
+ delete next.loopsById[id];
119
+ return {
120
+ state: next,
121
+ effects: [{ type: "DELETE_LOOP", entityType: "loop", entityId: id, payload: { id } }],
122
+ };
123
+ }
124
+
125
+ const next = cloneState(state);
126
+ const loop: LoopEntry = { ...current };
127
+
128
+ if (event.type === "LOOP_PAUSED") {
129
+ loop.status = "paused";
130
+ loop.updatedAt = event.at;
131
+ }
132
+
133
+ if (event.type === "LOOP_RESUMED") {
134
+ loop.status = "active";
135
+ loop.updatedAt = event.at;
136
+ }
137
+
138
+ if (event.type === "LOOP_FIRED") {
139
+ loop.fireCount = (loop.fireCount ?? 0) + 1;
140
+ loop.updatedAt = event.at;
141
+ }
142
+
143
+ next.loopsById[id] = loop;
144
+ return {
145
+ state: next,
146
+ effects: [{ type: "PERSIST_LOOP", entityType: "loop", entityId: id, payload: { loop } }],
147
+ };
148
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
2
+
3
+ export type MonitorCompletionEvent = ReducerEvent<
4
+ "MONITOR_ONDONE_TRIGGERED",
5
+ { loopId: string; monitorId: string }
6
+ >;
7
+
8
+ export type MonitorCompletionEffect = ReducerEffect<
9
+ "DELIVER_MONITOR_ONDONE_WAKE",
10
+ { loopId: string; monitorId: string }
11
+ >;
12
+
13
+ export function reduceMonitorCompletionEvent(event: MonitorCompletionEvent): MonitorCompletionEffect[] {
14
+ if (event.type !== "MONITOR_ONDONE_TRIGGERED") return [];
15
+ return [{
16
+ type: "DELIVER_MONITOR_ONDONE_WAKE",
17
+ entityType: "monitor",
18
+ entityId: event.payload.monitorId,
19
+ payload: {
20
+ loopId: event.payload.loopId,
21
+ monitorId: event.payload.monitorId,
22
+ },
23
+ }];
24
+ }