@trevonistrevon/pi-loop 0.4.11 → 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.
- package/README.md +10 -0
- package/dist/coordinator.d.ts +35 -0
- package/dist/coordinator.js +56 -0
- package/dist/goal-types.d.ts +78 -0
- package/dist/goal-types.js +1 -0
- package/dist/goal-verifier.d.ts +20 -0
- package/dist/goal-verifier.js +198 -0
- package/dist/index.js +170 -40
- package/dist/loop-reducer.d.ts +63 -0
- package/dist/loop-reducer.js +67 -0
- package/dist/monitor-completion-coordinator.d.ts +10 -0
- package/dist/monitor-completion-coordinator.js +13 -0
- package/dist/monitor-manager.d.ts +2 -0
- package/dist/monitor-manager.js +107 -29
- package/dist/monitor-reducer.d.ts +82 -0
- package/dist/monitor-reducer.js +69 -0
- package/dist/notification-reducer.d.ts +81 -0
- package/dist/notification-reducer.js +65 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +118 -44
- package/dist/task-backlog-coordinator.d.ts +12 -0
- package/dist/task-backlog-coordinator.js +22 -0
- package/dist/task-reducer.d.ts +66 -0
- package/dist/task-reducer.js +76 -0
- package/dist/task-store.d.ts +2 -0
- package/dist/task-store.js +82 -30
- package/docs/architecture/goal-state-schema.md +505 -0
- package/docs/architecture/state-machine-migration.md +546 -0
- package/docs/architecture/state-machine-reducer-event-model.md +823 -0
- package/docs/architecture/state-machine-test-matrix.md +249 -0
- package/docs/architecture/state-machine-transition-map.md +436 -0
- package/package.json +1 -1
- package/src/coordinator.ts +115 -0
- package/src/goal-types.ts +99 -0
- package/src/goal-verifier.ts +241 -0
- package/src/index.ts +196 -39
- package/src/loop-reducer.ts +148 -0
- package/src/monitor-completion-coordinator.ts +24 -0
- package/src/monitor-manager.ts +115 -27
- package/src/monitor-reducer.ts +166 -0
- package/src/notification-reducer.ts +155 -0
- package/src/store.ts +119 -43
- package/src/task-backlog-coordinator.ts +32 -0
- package/src/task-reducer.ts +152 -0
- 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
|
|
219
|
+
await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
200
220
|
}
|
|
201
221
|
}
|
|
202
222
|
|
|
203
|
-
let
|
|
204
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
420
|
-
|
|
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
|
|
428
|
-
|
|
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";
|
|
@@ -635,11 +764,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
635
764
|
|
|
636
765
|
function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
|
|
637
766
|
const deliver = () => {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
+
});
|
|
643
775
|
};
|
|
644
776
|
|
|
645
777
|
const registered = monitorManager.onComplete(monitorId, deliver);
|
|
@@ -1097,6 +1229,28 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
1097
1229
|
return { entry, created: true };
|
|
1098
1230
|
}
|
|
1099
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
|
+
|
|
1100
1254
|
async function createNativeTaskInteractively(ui: ExtensionUIContext) {
|
|
1101
1255
|
if (!nativeTaskStore) {
|
|
1102
1256
|
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
@@ -1113,11 +1267,11 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
1113
1267
|
description: entry.description,
|
|
1114
1268
|
status: entry.status,
|
|
1115
1269
|
});
|
|
1116
|
-
const
|
|
1270
|
+
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1117
1271
|
widget.update();
|
|
1118
1272
|
ui.notify(`Task #${entry.id} created`, "info");
|
|
1119
|
-
if (
|
|
1120
|
-
ui.notify(`Worker loop #${
|
|
1273
|
+
if (backlog.created && backlog.entry) {
|
|
1274
|
+
ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
1121
1275
|
}
|
|
1122
1276
|
}
|
|
1123
1277
|
|
|
@@ -1178,7 +1332,7 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
1178
1332
|
}
|
|
1179
1333
|
|
|
1180
1334
|
widget.update();
|
|
1181
|
-
await
|
|
1335
|
+
await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1182
1336
|
return viewNativeTasks(ui);
|
|
1183
1337
|
}
|
|
1184
1338
|
|
|
@@ -1206,11 +1360,11 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
|
|
|
1206
1360
|
description: entry.description,
|
|
1207
1361
|
status: entry.status,
|
|
1208
1362
|
});
|
|
1209
|
-
const
|
|
1363
|
+
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1210
1364
|
widget.update();
|
|
1211
1365
|
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
1212
|
-
if (
|
|
1213
|
-
ctx.ui.notify(`Worker loop #${
|
|
1366
|
+
if (backlog.created && backlog.entry) {
|
|
1367
|
+
ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
1214
1368
|
}
|
|
1215
1369
|
return;
|
|
1216
1370
|
}
|
|
@@ -1244,11 +1398,11 @@ Fields:
|
|
|
1244
1398
|
description: entry.description,
|
|
1245
1399
|
status: entry.status,
|
|
1246
1400
|
});
|
|
1247
|
-
const
|
|
1401
|
+
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1248
1402
|
widget.update();
|
|
1249
1403
|
|
|
1250
|
-
const autoLoopMsg =
|
|
1251
|
-
? `\nWorker loop #${
|
|
1404
|
+
const autoLoopMsg = backlog.created && backlog.entry
|
|
1405
|
+
? `\nWorker loop #${backlog.entry.id} auto-created`
|
|
1252
1406
|
: "";
|
|
1253
1407
|
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
|
|
1254
1408
|
},
|
|
@@ -1306,9 +1460,12 @@ Parameters: id (required), status, subject, description`,
|
|
|
1306
1460
|
});
|
|
1307
1461
|
if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
|
|
1308
1462
|
widget.update();
|
|
1309
|
-
await
|
|
1463
|
+
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1310
1464
|
const statusMsg = status ? ` → ${status}` : "";
|
|
1311
|
-
|
|
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}`));
|
|
1312
1469
|
},
|
|
1313
1470
|
});
|
|
1314
1471
|
|
|
@@ -1323,7 +1480,7 @@ Parameters: id (required), status, subject, description`,
|
|
|
1323
1480
|
const deleted = taskStore.delete(params.id);
|
|
1324
1481
|
widget.update();
|
|
1325
1482
|
if (deleted) {
|
|
1326
|
-
await
|
|
1483
|
+
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1327
1484
|
return Promise.resolve(textResult(`Task #${params.id} deleted`));
|
|
1328
1485
|
}
|
|
1329
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
|
+
}
|