@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.
- package/README.md +11 -1
- 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 +182 -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 +209 -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";
|
|
@@ -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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
1270
|
+
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1104
1271
|
widget.update();
|
|
1105
1272
|
ui.notify(`Task #${entry.id} created`, "info");
|
|
1106
|
-
if (
|
|
1107
|
-
ui.notify(`Worker loop #${
|
|
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
|
|
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
|
|
1363
|
+
const backlog = await evaluateTaskBacklog(nativeTaskStore, nativeTaskStore.pendingCount());
|
|
1197
1364
|
widget.update();
|
|
1198
1365
|
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
1199
|
-
if (
|
|
1200
|
-
ctx.ui.notify(`Worker loop #${
|
|
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
|
|
1401
|
+
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1235
1402
|
widget.update();
|
|
1236
1403
|
|
|
1237
|
-
const autoLoopMsg =
|
|
1238
|
-
? `\nWorker loop #${
|
|
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
|
|
1463
|
+
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
1297
1464
|
const statusMsg = status ? ` → ${status}` : "";
|
|
1298
|
-
|
|
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
|
|
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
|
+
}
|