@trevonistrevon/pi-loop 0.5.0 → 0.5.1

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 (54) hide show
  1. package/README.md +11 -10
  2. package/dist/commands/loop-command.d.ts +22 -0
  3. package/dist/commands/loop-command.js +148 -0
  4. package/dist/commands/tasks-command.d.ts +15 -0
  5. package/dist/commands/tasks-command.js +117 -0
  6. package/dist/goal-coordinator.d.ts +22 -0
  7. package/dist/goal-coordinator.js +28 -0
  8. package/dist/goal-reducer.d.ts +89 -0
  9. package/dist/goal-reducer.js +181 -0
  10. package/dist/goal-store.d.ts +31 -0
  11. package/dist/goal-store.js +298 -0
  12. package/dist/goal-types.d.ts +4 -0
  13. package/dist/index.js +125 -1212
  14. package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
  15. package/dist/runtime/monitor-ondone-runtime.js +49 -0
  16. package/dist/runtime/notification-runtime.d.ts +34 -0
  17. package/dist/runtime/notification-runtime.js +152 -0
  18. package/dist/runtime/scope.d.ts +8 -0
  19. package/dist/runtime/scope.js +33 -0
  20. package/dist/runtime/session-runtime.d.ts +39 -0
  21. package/dist/runtime/session-runtime.js +110 -0
  22. package/dist/runtime/task-backlog-runtime.d.ts +36 -0
  23. package/dist/runtime/task-backlog-runtime.js +105 -0
  24. package/dist/runtime/task-rpc.d.ts +19 -0
  25. package/dist/runtime/task-rpc.js +118 -0
  26. package/dist/store.d.ts +5 -4
  27. package/dist/store.js +50 -44
  28. package/dist/task-store.d.ts +6 -4
  29. package/dist/task-store.js +64 -47
  30. package/dist/tools/loop-tools.d.ts +41 -0
  31. package/dist/tools/loop-tools.js +241 -0
  32. package/dist/tools/monitor-tools.d.ts +25 -0
  33. package/dist/tools/monitor-tools.js +110 -0
  34. package/dist/tools/native-task-tools.d.ts +15 -0
  35. package/dist/tools/native-task-tools.js +127 -0
  36. package/package.json +1 -1
  37. package/src/commands/loop-command.ts +184 -0
  38. package/src/commands/tasks-command.ts +135 -0
  39. package/src/goal-coordinator.ts +58 -0
  40. package/src/goal-reducer.ts +280 -0
  41. package/src/goal-store.ts +315 -0
  42. package/src/goal-types.ts +5 -0
  43. package/src/index.ts +129 -1299
  44. package/src/runtime/monitor-ondone-runtime.ts +75 -0
  45. package/src/runtime/notification-runtime.ts +212 -0
  46. package/src/runtime/scope.ts +37 -0
  47. package/src/runtime/session-runtime.ts +168 -0
  48. package/src/runtime/task-backlog-runtime.ts +163 -0
  49. package/src/runtime/task-rpc.ts +150 -0
  50. package/src/store.ts +51 -45
  51. package/src/task-store.ts +61 -46
  52. package/src/tools/loop-tools.ts +304 -0
  53. package/src/tools/monitor-tools.ts +145 -0
  54. package/src/tools/native-task-tools.ts +144 -0
@@ -0,0 +1,75 @@
1
+ import {
2
+ createCoordinator,
3
+ type ReducerEffect,
4
+ type ReducerEvent,
5
+ type ReducerHandler,
6
+ } from "../coordinator.js";
7
+ import {
8
+ type MonitorCompletionEvent,
9
+ reduceMonitorCompletionEvent,
10
+ } from "../monitor-completion-coordinator.js";
11
+ import type { MonitorManager } from "../monitor-manager.js";
12
+ import type { LoopEntry } from "../types.js";
13
+
14
+ export interface MonitorOnDoneRuntimeOptions {
15
+ monitorManager: MonitorManager;
16
+ getLoop: (id: string) => LoopEntry | undefined;
17
+ deleteLoop: (id: string) => void;
18
+ onLoopFire: (entry: LoopEntry) => void;
19
+ debug?: (...args: unknown[]) => void;
20
+ }
21
+
22
+ export interface MonitorOnDoneRuntime {
23
+ register(doneLoop: LoopEntry, monitorId: string): void;
24
+ }
25
+
26
+ export function createMonitorOnDoneRuntime(options: MonitorOnDoneRuntimeOptions): MonitorOnDoneRuntime {
27
+ const { monitorManager, getLoop, deleteLoop, onLoopFire, debug } = options;
28
+
29
+ const monitorCompletionReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
30
+ if (incoming.type !== "MONITOR_ONDONE_TRIGGERED") return [];
31
+ return reduceMonitorCompletionEvent(incoming as MonitorCompletionEvent);
32
+ };
33
+
34
+ const monitorCompletionCoordinator = createCoordinator({
35
+ reducers: [monitorCompletionReducerHandler],
36
+ effectHandlers: {
37
+ DELIVER_MONITOR_ONDONE_WAKE: (effect: ReducerEffect) => {
38
+ const { loopId, monitorId } = effect.payload as { loopId: string; monitorId: string };
39
+ const current = getLoop(loopId);
40
+ if (!current) return;
41
+ debug?.(`onDone loop #${loopId} — monitor #${monitorId} completed, delivering through coordinator`);
42
+ onLoopFire(current);
43
+ deleteLoop(loopId);
44
+ },
45
+ },
46
+ });
47
+
48
+ function register(doneLoop: LoopEntry, monitorId: string): void {
49
+ const deliver = () => {
50
+ void monitorCompletionCoordinator.dispatch({
51
+ type: "MONITOR_ONDONE_TRIGGERED",
52
+ at: Date.now(),
53
+ source: "monitor",
54
+ entityType: "monitor",
55
+ entityId: monitorId,
56
+ payload: { loopId: doneLoop.id, monitorId },
57
+ });
58
+ };
59
+
60
+ const registered = monitorManager.onComplete(monitorId, deliver);
61
+ if (registered) return;
62
+
63
+ const monitor = monitorManager.get(monitorId);
64
+ if (monitor && monitor.status !== "running") {
65
+ if (monitor.status === "completed") {
66
+ deliver();
67
+ return;
68
+ }
69
+ debug?.(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
70
+ deleteLoop(doneLoop.id);
71
+ }
72
+ }
73
+
74
+ return { register };
75
+ }
@@ -0,0 +1,212 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ createCoordinator,
4
+ type ReducerEffect,
5
+ type ReducerEvent,
6
+ type ReducerHandler,
7
+ } from "../coordinator.js";
8
+ import {
9
+ type NotificationReducerEvent,
10
+ type NotificationReducerState,
11
+ type ReducerNotification,
12
+ reduceNotificationState,
13
+ } from "../notification-reducer.js";
14
+ import type { Trigger } from "../types.js";
15
+
16
+ export interface LoopFireEvent {
17
+ loopId: string;
18
+ prompt: string;
19
+ trigger: Trigger | string;
20
+ timestamp: number;
21
+ readOnly?: boolean;
22
+ recurring?: boolean;
23
+ autoTask?: boolean;
24
+ }
25
+
26
+ export interface PendingNotification extends LoopFireEvent {
27
+ key: string;
28
+ message: string;
29
+ }
30
+
31
+ export interface NotificationRuntimeOptions {
32
+ pi: ExtensionAPI;
33
+ hasPendingTasks: () => Promise<number>;
34
+ cleanDoneTasks: () => Promise<void>;
35
+ getHasPendingMessages: () => boolean;
36
+ debug?: (...args: unknown[]) => void;
37
+ }
38
+
39
+ export interface NotificationRuntime {
40
+ syncRuntimeState(options?: { agentRunning?: boolean; hasPendingMessages?: boolean }): void;
41
+ queueOrDeliverNotification(data: LoopFireEvent): Promise<void>;
42
+ flushPendingNotifications(options?: { ignorePendingMessages?: boolean }): Promise<void>;
43
+ clear(reason: "session_shutdown" | "session_switch"): void;
44
+ }
45
+
46
+ export function createNotificationRuntime(options: NotificationRuntimeOptions): NotificationRuntime {
47
+ const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
48
+
49
+ let notificationState: NotificationReducerState = {
50
+ notificationsByKey: {},
51
+ agentRunning: false,
52
+ hasPendingMessages: false,
53
+ };
54
+ let flushPromise: Promise<void> | undefined;
55
+ let notificationCoordinatorDelivered = false;
56
+ let notificationCoordinatorDeliveredSuccessfully = false;
57
+
58
+ const notificationReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
59
+ const result = reduceNotificationState(notificationState, incoming as NotificationReducerEvent);
60
+ notificationState = result.state;
61
+ return result.effects;
62
+ };
63
+
64
+ const notificationCoordinator = createCoordinator({
65
+ reducers: [notificationReducerHandler],
66
+ effectHandlers: {
67
+ REQUEST_NOTIFICATION_FLUSH: () => {},
68
+ DELIVER_NOTIFICATION: async (effect: ReducerEffect) => {
69
+ notificationCoordinatorDelivered = true;
70
+ notificationCoordinatorDeliveredSuccessfully = await deliverNotification(
71
+ (effect.payload as { notification: ReducerNotification }).notification,
72
+ );
73
+ },
74
+ },
75
+ });
76
+
77
+ function applyNotificationEvent(event: NotificationReducerEvent) {
78
+ const result = reduceNotificationState(notificationState, event);
79
+ notificationState = result.state;
80
+ return result;
81
+ }
82
+
83
+ function syncRuntimeState(options?: { agentRunning?: boolean; hasPendingMessages?: boolean }) {
84
+ applyNotificationEvent({
85
+ type: "NOTIFICATION_RUNTIME_UPDATED",
86
+ at: Date.now(),
87
+ source: "system",
88
+ entityType: "notification",
89
+ payload: {
90
+ agentRunning: options?.agentRunning ?? notificationState.agentRunning,
91
+ hasPendingMessages: options?.hasPendingMessages ?? getHasPendingMessages(),
92
+ },
93
+ });
94
+ }
95
+
96
+ function buildLoopFireMessage(data: LoopFireEvent): string {
97
+ const triggerInfo = typeof data.trigger === "string"
98
+ ? data.trigger
99
+ : data.trigger?.type === "cron"
100
+ ? `schedule: ${data.trigger.schedule}`
101
+ : data.trigger?.type === "event"
102
+ ? `event: ${data.trigger.source}`
103
+ : "hybrid";
104
+
105
+ const loopId = data.loopId || "?";
106
+ const prompt = data.prompt || "loop fired";
107
+ const constraint = data.readOnly
108
+ ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
109
+ : "";
110
+
111
+ return [
112
+ `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
113
+ prompt,
114
+ ].join("\n");
115
+ }
116
+
117
+ function buildPendingNotification(data: LoopFireEvent): PendingNotification {
118
+ const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
119
+ return {
120
+ ...data,
121
+ key,
122
+ message: buildLoopFireMessage(data),
123
+ };
124
+ }
125
+
126
+ async function deliverNotification(notification: ReducerNotification): Promise<boolean> {
127
+ if (notification.autoTask) {
128
+ const pending = await hasPendingTasks();
129
+ if (pending === 0) {
130
+ debug?.(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
131
+ await cleanDoneTasks();
132
+ return false;
133
+ }
134
+ }
135
+
136
+ syncRuntimeState({ agentRunning: true });
137
+ pi.sendMessage({
138
+ customType: "pi-loop",
139
+ content: notification.message,
140
+ display: false,
141
+ details: {
142
+ loopId: notification.loopId,
143
+ trigger: notification.trigger,
144
+ recurring: notification.recurring,
145
+ readOnly: notification.readOnly,
146
+ autoTask: notification.autoTask,
147
+ timestamp: notification.timestamp,
148
+ },
149
+ }, {
150
+ deliverAs: "steer",
151
+ triggerTurn: true,
152
+ });
153
+ return true;
154
+ }
155
+
156
+ async function flushPendingNotifications(options?: { ignorePendingMessages?: boolean }): Promise<void> {
157
+ if (flushPromise) return flushPromise;
158
+
159
+ flushPromise = (async () => {
160
+ syncRuntimeState({ hasPendingMessages: getHasPendingMessages() });
161
+
162
+ while (true) {
163
+ notificationCoordinatorDelivered = false;
164
+ notificationCoordinatorDeliveredSuccessfully = false;
165
+ await notificationCoordinator.dispatch({
166
+ type: "NOTIFICATION_FLUSH_REQUESTED",
167
+ at: Date.now(),
168
+ source: "system",
169
+ entityType: "notification",
170
+ payload: { ignorePendingMessages: options?.ignorePendingMessages },
171
+ });
172
+ if (!notificationCoordinatorDelivered) return;
173
+ if (notificationCoordinatorDeliveredSuccessfully) return;
174
+ }
175
+ })().finally(() => {
176
+ flushPromise = undefined;
177
+ });
178
+
179
+ return flushPromise;
180
+ }
181
+
182
+ async function queueOrDeliverNotification(data: LoopFireEvent): Promise<void> {
183
+ const notification = buildPendingNotification(data);
184
+ applyNotificationEvent({
185
+ type: "NOTIFICATION_QUEUED",
186
+ at: notification.timestamp,
187
+ source: "system",
188
+ entityType: "notification",
189
+ entityId: notification.key,
190
+ payload: { notification },
191
+ });
192
+ await flushPendingNotifications();
193
+ }
194
+
195
+ function clear(reason: "session_shutdown" | "session_switch") {
196
+ syncRuntimeState({ agentRunning: false, hasPendingMessages: false });
197
+ applyNotificationEvent({
198
+ type: "NOTIFICATION_CLEARED",
199
+ at: Date.now(),
200
+ source: "session",
201
+ entityType: "notification",
202
+ payload: { reason },
203
+ });
204
+ }
205
+
206
+ return {
207
+ syncRuntimeState,
208
+ queueOrDeliverNotification,
209
+ flushPendingNotifications,
210
+ clear,
211
+ };
212
+ }
@@ -0,0 +1,37 @@
1
+ import { join, resolve } from "node:path";
2
+
3
+ export type LoopScope = "memory" | "session" | "project";
4
+
5
+ export interface ScopeOptions {
6
+ piLoopEnv?: string;
7
+ loopScope: LoopScope;
8
+ cwd?: string;
9
+ }
10
+
11
+ export function resolveLoopStorePath(options: ScopeOptions, sessionId?: string): string | undefined {
12
+ const cwd = options.cwd ?? process.cwd();
13
+ const { piLoopEnv, loopScope } = options;
14
+
15
+ if (piLoopEnv === "off") return undefined;
16
+ if (piLoopEnv?.startsWith("/")) return piLoopEnv;
17
+ if (piLoopEnv?.startsWith(".")) return resolve(piLoopEnv);
18
+ if (piLoopEnv) return piLoopEnv;
19
+ if (loopScope === "memory") return undefined;
20
+ if (loopScope === "session" && sessionId) {
21
+ return join(cwd, ".pi", "loops", `loops-${sessionId}.json`);
22
+ }
23
+ if (loopScope === "session") return undefined;
24
+ return join(cwd, ".pi", "loops", "loops.json");
25
+ }
26
+
27
+ export function resolveTaskStorePath(options: ScopeOptions, sessionId?: string): string | undefined {
28
+ const cwd = options.cwd ?? process.cwd();
29
+ const { loopScope } = options;
30
+
31
+ if (loopScope === "memory") return undefined;
32
+ if (loopScope === "session" && sessionId) {
33
+ return join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`);
34
+ }
35
+ if (loopScope === "session") return undefined;
36
+ return join(cwd, ".pi", "tasks", "tasks.json");
37
+ }
@@ -0,0 +1,168 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { LoopStore } from "../store.js";
3
+ import type { NotificationRuntime } from "./notification-runtime.js";
4
+ import type { LoopScope } from "./scope.js";
5
+
6
+ export interface SessionSwitchEvent {
7
+ reason?: string;
8
+ }
9
+
10
+ export interface SessionRuntimeOptions {
11
+ pi: ExtensionAPI;
12
+ getLoopScope: () => LoopScope;
13
+ getPiLoopEnv: () => string | undefined;
14
+ recreateSessionStore: (sessionId: string) => void;
15
+ clearAllLoops: () => void;
16
+ getStore: () => LoopStore;
17
+ getScheduler: () => { nextFire(id: string): number | undefined; pump(now: number, filter?: (entry: { id: string }) => boolean): void };
18
+ getTriggerSystem: () => { start(): void; stop(): void };
19
+ setLatestCtx: (ctx: ExtensionContext) => void;
20
+ setSessionId: (sessionId: string | undefined) => void;
21
+ widget: { setUICtx(ui: ExtensionContext["ui"]): void; update(): void };
22
+ notificationRuntime: NotificationRuntime;
23
+ flushPendingNotifications: (options?: { ignorePendingMessages?: boolean }) => Promise<void>;
24
+ cleanupTaskBacklogLoops: () => Promise<number>;
25
+ hasPendingTasks: () => Promise<number>;
26
+ cleanDoneTasks: () => Promise<void>;
27
+ }
28
+
29
+ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): void {
30
+ const {
31
+ pi,
32
+ getLoopScope,
33
+ getPiLoopEnv,
34
+ recreateSessionStore,
35
+ clearAllLoops,
36
+ getStore,
37
+ getScheduler,
38
+ getTriggerSystem,
39
+ setLatestCtx,
40
+ setSessionId,
41
+ widget,
42
+ notificationRuntime,
43
+ flushPendingNotifications,
44
+ cleanupTaskBacklogLoops,
45
+ hasPendingTasks,
46
+ cleanDoneTasks,
47
+ } = options;
48
+
49
+ let storeUpgraded = false;
50
+ let persistedShown = false;
51
+
52
+ function upgradeStoreIfNeeded(ctx: ExtensionContext) {
53
+ if (storeUpgraded) return;
54
+ if (getLoopScope() === "session" && !getPiLoopEnv()) {
55
+ recreateSessionStore(ctx.sessionManager.getSessionId());
56
+ }
57
+ storeUpgraded = true;
58
+ }
59
+
60
+ function showPersistedLoops(_isResume = false) {
61
+ if (persistedShown) return;
62
+ persistedShown = true;
63
+ const sessionStartedAt = Date.now();
64
+ const loops = getStore().list();
65
+ if (loops.length > 0) {
66
+ getStore().clearExpired();
67
+ getStore().expireEventLoops(sessionStartedAt);
68
+ getTriggerSystem().start();
69
+ widget.update();
70
+ }
71
+ }
72
+
73
+ async function pumpLoops(): Promise<void> {
74
+ const pendingTasks = new Map<string, boolean>();
75
+ for (const entry of getStore().list()) {
76
+ if (entry.status !== "active") continue;
77
+ if (!entry.autoTask) continue;
78
+ if (entry.trigger.type !== "cron" && entry.trigger.type !== "hybrid") continue;
79
+ const nextFire = getScheduler().nextFire(entry.id);
80
+ if (!nextFire || Date.now() < nextFire) continue;
81
+ const pending = await hasPendingTasks();
82
+ if (pending <= 0) pendingTasks.set(entry.id, true);
83
+ }
84
+ getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
85
+ }
86
+
87
+ pi.on("turn_start", async (_event, ctx) => {
88
+ setLatestCtx(ctx);
89
+ setSessionId(ctx.sessionManager.getSessionId());
90
+ widget.setUICtx(ctx.ui);
91
+ upgradeStoreIfNeeded(ctx);
92
+ widget.update();
93
+ await pumpLoops();
94
+ });
95
+
96
+ pi.on("before_agent_start", async (_event, ctx) => {
97
+ setLatestCtx(ctx);
98
+ widget.setUICtx(ctx.ui);
99
+ upgradeStoreIfNeeded(ctx);
100
+ showPersistedLoops();
101
+ widget.update();
102
+ });
103
+
104
+ pi.on("agent_start", async (_event, ctx) => {
105
+ notificationRuntime.syncRuntimeState({
106
+ agentRunning: true,
107
+ hasPendingMessages: ctx.hasPendingMessages(),
108
+ });
109
+ setLatestCtx(ctx);
110
+ widget.setUICtx(ctx.ui);
111
+ });
112
+
113
+ pi.on("agent_end", async (_event, ctx) => {
114
+ setLatestCtx(ctx);
115
+ widget.setUICtx(ctx.ui);
116
+ notificationRuntime.syncRuntimeState({
117
+ agentRunning: false,
118
+ hasPendingMessages: ctx.hasPendingMessages(),
119
+ });
120
+ await flushPendingNotifications({ ignorePendingMessages: true });
121
+ await cleanupTaskBacklogLoops();
122
+ await pumpLoops();
123
+ });
124
+
125
+ pi.on("session_shutdown", async () => {
126
+ notificationRuntime.clear("session_shutdown");
127
+ });
128
+
129
+ pi.on("session_switch" as never, async (event: SessionSwitchEvent, ctx: ExtensionContext) => {
130
+ setLatestCtx(ctx);
131
+ widget.setUICtx(ctx.ui);
132
+ getTriggerSystem().stop();
133
+ notificationRuntime.clear("session_switch");
134
+ setSessionId(undefined);
135
+
136
+ const isResume = event?.reason === "resume";
137
+ storeUpgraded = false;
138
+ persistedShown = false;
139
+
140
+ if (!isResume && getLoopScope() === "memory") {
141
+ clearAllLoops();
142
+ }
143
+
144
+ upgradeStoreIfNeeded(ctx);
145
+ showPersistedLoops(isResume);
146
+ widget.update();
147
+ });
148
+
149
+ pi.on("tool_execution_end", async (event: unknown, ctx: ExtensionContext) => {
150
+ setLatestCtx(ctx);
151
+ widget.setUICtx(ctx.ui);
152
+
153
+ const typed = event as {
154
+ toolName?: string;
155
+ isError?: boolean;
156
+ args?: { command?: string };
157
+ input?: { command?: string };
158
+ };
159
+
160
+ if (typed.toolName !== "bash" || typed.isError) return;
161
+
162
+ const command = typed.args?.command ?? typed.input?.command;
163
+ if (typeof command !== "string") return;
164
+ if (!/^\s*git\s+commit\b/i.test(command)) return;
165
+
166
+ await cleanDoneTasks();
167
+ });
168
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ createCoordinator,
3
+ type ReducerEvent,
4
+ type ReducerHandler,
5
+ } from "../coordinator.js";
6
+ import {
7
+ reduceTaskBacklogEvent,
8
+ type TaskBacklogEvent,
9
+ } from "../task-backlog-coordinator.js";
10
+ import type { TaskStore } from "../task-store.js";
11
+ import type { LoopEntry, Trigger } from "../types.js";
12
+
13
+ export const AUTO_TASK_WORKER_THRESHOLD = 5;
14
+ export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
15
+
16
+ export interface TaskBacklogRuntimeOptions {
17
+ getLoops: () => LoopEntry[];
18
+ createLoop: (trigger: Trigger, prompt: string, options: {
19
+ recurring: boolean;
20
+ taskBacklog?: boolean;
21
+ maxFires?: number;
22
+ }) => LoopEntry;
23
+ deleteLoop: (id: string) => void;
24
+ addTrigger: (entry: LoopEntry) => void;
25
+ removeTrigger: (id: string) => void;
26
+ updateWidget: () => void;
27
+ hasPendingTasks: () => Promise<number>;
28
+ bootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
29
+ triggerHasEventSource: (trigger: Trigger | string, source: string) => boolean;
30
+ debug?: (...args: unknown[]) => void;
31
+ }
32
+
33
+ export interface TaskBacklogRuntime {
34
+ cleanupTaskBacklogLoops(): Promise<number>;
35
+ ensureAutoTaskWorkerLoop(taskStore: TaskStore): Promise<{ entry?: LoopEntry; created: boolean }>;
36
+ evaluateTaskBacklog(taskStore?: TaskStore, pendingCount?: number): Promise<{ entry?: LoopEntry; created: boolean; cleaned: number }>;
37
+ isAutoTaskWorkerLoop(entry: LoopEntry): boolean;
38
+ isTaskBacklogLoop(entry: LoopEntry): boolean;
39
+ findAutoTaskWorkerLoop(): LoopEntry | undefined;
40
+ }
41
+
42
+ export function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): TaskBacklogRuntime {
43
+ const {
44
+ getLoops,
45
+ createLoop,
46
+ deleteLoop,
47
+ addTrigger,
48
+ removeTrigger,
49
+ updateWidget,
50
+ hasPendingTasks,
51
+ bootstrapTaskLoop,
52
+ triggerHasEventSource,
53
+ debug,
54
+ } = options;
55
+
56
+ function isAutoTaskWorkerLoop(entry: LoopEntry): boolean {
57
+ return entry.status === "active"
58
+ && entry.prompt === AUTO_TASK_WORKER_PROMPT
59
+ && triggerHasEventSource(entry.trigger, "tasks:created");
60
+ }
61
+
62
+ function isTaskBacklogLoop(entry: LoopEntry): boolean {
63
+ return entry.status === "active"
64
+ && triggerHasEventSource(entry.trigger, "tasks:created")
65
+ && (entry.taskBacklog === true || isAutoTaskWorkerLoop(entry));
66
+ }
67
+
68
+ function findAutoTaskWorkerLoop(): LoopEntry | undefined {
69
+ return getLoops().find(isAutoTaskWorkerLoop);
70
+ }
71
+
72
+ async function cleanupTaskBacklogLoops(): Promise<number> {
73
+ const backlogLoops = getLoops().filter(isTaskBacklogLoop);
74
+ if (backlogLoops.length === 0) return 0;
75
+
76
+ const pending = await hasPendingTasks();
77
+ if (pending < 0 || pending > 0) return 0;
78
+
79
+ for (const entry of backlogLoops) {
80
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
81
+ removeTrigger(entry.id);
82
+ deleteLoop(entry.id);
83
+ }
84
+ updateWidget();
85
+ return backlogLoops.length;
86
+ }
87
+
88
+ async function ensureAutoTaskWorkerLoop(taskStore: TaskStore): Promise<{ entry?: LoopEntry; created: boolean }> {
89
+ if (taskStore.pendingCount() < AUTO_TASK_WORKER_THRESHOLD) return { created: false };
90
+
91
+ const existing = findAutoTaskWorkerLoop();
92
+ if (existing) return { entry: existing, created: false };
93
+
94
+ const trigger: Trigger = {
95
+ type: "hybrid",
96
+ cron: "*/5 * * * *",
97
+ event: { source: "tasks:created" },
98
+ debounceMs: 30000,
99
+ };
100
+ const entry = createLoop(trigger, AUTO_TASK_WORKER_PROMPT, {
101
+ recurring: true,
102
+ taskBacklog: true,
103
+ maxFires: 30,
104
+ });
105
+ addTrigger(entry);
106
+ await bootstrapTaskLoop(entry);
107
+ updateWidget();
108
+ return { entry, created: true };
109
+ }
110
+
111
+ let taskBacklogCoordinatorStore: TaskStore | undefined;
112
+ let taskBacklogCoordinatorWorker: { entry?: LoopEntry; created: boolean } = { created: false };
113
+ let taskBacklogCoordinatorCleanupCount = 0;
114
+
115
+ const taskBacklogReducerHandler: ReducerHandler = (incoming: ReducerEvent) => {
116
+ if (incoming.type !== "TASK_BACKLOG_EVALUATED") return [];
117
+ return reduceTaskBacklogEvent(incoming as TaskBacklogEvent);
118
+ };
119
+
120
+ const taskBacklogCoordinator = createCoordinator({
121
+ reducers: [taskBacklogReducerHandler],
122
+ effectHandlers: {
123
+ ENSURE_AUTO_TASK_WORKER: async () => {
124
+ if (!taskBacklogCoordinatorStore) return;
125
+ taskBacklogCoordinatorWorker = await ensureAutoTaskWorkerLoop(taskBacklogCoordinatorStore);
126
+ },
127
+ CLEANUP_TASK_BACKLOG_LOOPS: async () => {
128
+ taskBacklogCoordinatorCleanupCount = await cleanupTaskBacklogLoops();
129
+ },
130
+ },
131
+ });
132
+
133
+ async function evaluateTaskBacklog(taskStore?: TaskStore, pendingCount?: number): Promise<{ entry?: LoopEntry; created: boolean; cleaned: number }> {
134
+ const resolvedPending = pendingCount ?? (taskStore ? taskStore.pendingCount() : await hasPendingTasks());
135
+ taskBacklogCoordinatorStore = taskStore;
136
+ taskBacklogCoordinatorWorker = { created: false };
137
+ taskBacklogCoordinatorCleanupCount = 0;
138
+
139
+ await taskBacklogCoordinator.dispatch({
140
+ type: "TASK_BACKLOG_EVALUATED",
141
+ at: Date.now(),
142
+ source: "system",
143
+ entityType: "task",
144
+ payload: { pendingCount: resolvedPending, threshold: AUTO_TASK_WORKER_THRESHOLD },
145
+ });
146
+
147
+ taskBacklogCoordinatorStore = undefined;
148
+ return {
149
+ entry: taskBacklogCoordinatorWorker.entry,
150
+ created: taskBacklogCoordinatorWorker.created,
151
+ cleaned: taskBacklogCoordinatorCleanupCount,
152
+ };
153
+ }
154
+
155
+ return {
156
+ cleanupTaskBacklogLoops,
157
+ ensureAutoTaskWorkerLoop,
158
+ evaluateTaskBacklog,
159
+ isAutoTaskWorkerLoop,
160
+ isTaskBacklogLoop,
161
+ findAutoTaskWorkerLoop,
162
+ };
163
+ }