@trevonistrevon/pi-loop 0.4.11 → 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.
- package/README.md +20 -9
- package/dist/commands/loop-command.d.ts +22 -0
- package/dist/commands/loop-command.js +148 -0
- package/dist/commands/tasks-command.d.ts +15 -0
- package/dist/commands/tasks-command.js +117 -0
- package/dist/coordinator.d.ts +35 -0
- package/dist/coordinator.js +56 -0
- package/dist/goal-coordinator.d.ts +22 -0
- package/dist/goal-coordinator.js +28 -0
- package/dist/goal-reducer.d.ts +89 -0
- package/dist/goal-reducer.js +181 -0
- package/dist/goal-store.d.ts +31 -0
- package/dist/goal-store.js +298 -0
- package/dist/goal-types.d.ts +82 -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 +130 -1087
- 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/runtime/monitor-ondone-runtime.d.ts +13 -0
- package/dist/runtime/monitor-ondone-runtime.js +49 -0
- package/dist/runtime/notification-runtime.d.ts +34 -0
- package/dist/runtime/notification-runtime.js +152 -0
- package/dist/runtime/scope.d.ts +8 -0
- package/dist/runtime/scope.js +33 -0
- package/dist/runtime/session-runtime.d.ts +39 -0
- package/dist/runtime/session-runtime.js +110 -0
- package/dist/runtime/task-backlog-runtime.d.ts +36 -0
- package/dist/runtime/task-backlog-runtime.js +105 -0
- package/dist/runtime/task-rpc.d.ts +19 -0
- package/dist/runtime/task-rpc.js +118 -0
- package/dist/store.d.ts +7 -4
- package/dist/store.js +129 -49
- 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 +8 -4
- package/dist/task-store.js +102 -33
- package/dist/tools/loop-tools.d.ts +41 -0
- package/dist/tools/loop-tools.js +241 -0
- package/dist/tools/monitor-tools.d.ts +25 -0
- package/dist/tools/monitor-tools.js +110 -0
- package/dist/tools/native-task-tools.d.ts +15 -0
- package/dist/tools/native-task-tools.js +127 -0
- 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/commands/loop-command.ts +184 -0
- package/src/commands/tasks-command.ts +135 -0
- package/src/coordinator.ts +115 -0
- package/src/goal-coordinator.ts +58 -0
- package/src/goal-reducer.ts +280 -0
- package/src/goal-store.ts +315 -0
- package/src/goal-types.ts +104 -0
- package/src/goal-verifier.ts +241 -0
- package/src/index.ts +134 -1147
- 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/runtime/monitor-ondone-runtime.ts +75 -0
- package/src/runtime/notification-runtime.ts +212 -0
- package/src/runtime/scope.ts +37 -0
- package/src/runtime/session-runtime.ts +168 -0
- package/src/runtime/task-backlog-runtime.ts +163 -0
- package/src/runtime/task-rpc.ts +150 -0
- package/src/store.ts +132 -50
- package/src/task-backlog-coordinator.ts +32 -0
- package/src/task-reducer.ts +152 -0
- package/src/task-store.ts +103 -31
- package/src/tools/loop-tools.ts +304 -0
- package/src/tools/monitor-tools.ts +145 -0
- package/src/tools/native-task-tools.ts +144 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TaskStore } from "../task-store.js";
|
|
4
|
+
import type { LoopEntry } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export interface TaskRuntimeBridgeOptions {
|
|
7
|
+
pi: ExtensionAPI;
|
|
8
|
+
isTasksAvailable: () => boolean;
|
|
9
|
+
setTasksAvailable: (available: boolean) => void;
|
|
10
|
+
getNativeTaskStore: () => TaskStore | undefined;
|
|
11
|
+
onNativeTaskCreated?: (taskStore: TaskStore) => void;
|
|
12
|
+
onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
|
|
13
|
+
debug?: (...args: unknown[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TaskRuntimeBridge {
|
|
17
|
+
checkTasksVersion(): void;
|
|
18
|
+
autoCreateTask(entry: LoopEntry): Promise<string | undefined>;
|
|
19
|
+
hasPendingTasks(): Promise<number>;
|
|
20
|
+
cleanDoneTasks(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): TaskRuntimeBridge {
|
|
24
|
+
const {
|
|
25
|
+
pi,
|
|
26
|
+
isTasksAvailable,
|
|
27
|
+
setTasksAvailable,
|
|
28
|
+
getNativeTaskStore,
|
|
29
|
+
onNativeTaskCreated,
|
|
30
|
+
onNativeTasksPruned,
|
|
31
|
+
debug,
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
function checkTasksVersion() {
|
|
35
|
+
const requestId = randomUUID();
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
unsub();
|
|
38
|
+
}, 5000);
|
|
39
|
+
const unsub = pi.events.on(`tasks:rpc:ping:reply:${requestId}`, (raw: unknown) => {
|
|
40
|
+
unsub();
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
const remoteVersion = (raw as { data?: { version?: number } })?.data?.version;
|
|
43
|
+
if (remoteVersion !== undefined) setTasksAvailable(true);
|
|
44
|
+
});
|
|
45
|
+
pi.events.emit("tasks:rpc:ping", { requestId });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
|
|
49
|
+
if (!entry.autoTask) return undefined;
|
|
50
|
+
if (isTasksAvailable()) {
|
|
51
|
+
try {
|
|
52
|
+
const requestId = randomUUID();
|
|
53
|
+
const taskId = await new Promise<string | undefined>((resolve) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
unsub();
|
|
56
|
+
resolve(undefined);
|
|
57
|
+
}, 5000);
|
|
58
|
+
const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
|
|
59
|
+
unsub();
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
const reply = raw as { success: boolean; data?: { id: string } };
|
|
62
|
+
if (reply.success && reply.data) resolve(reply.data.id);
|
|
63
|
+
else resolve(undefined);
|
|
64
|
+
});
|
|
65
|
+
pi.events.emit("tasks:rpc:create", {
|
|
66
|
+
requestId,
|
|
67
|
+
subject: entry.prompt.slice(0, 80),
|
|
68
|
+
description: `Auto-created from loop #${entry.id}`,
|
|
69
|
+
metadata: { loopId: entry.id, trigger: entry.trigger },
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return taskId;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
79
|
+
if (!nativeTaskStore) return undefined;
|
|
80
|
+
const task = nativeTaskStore.create(
|
|
81
|
+
entry.prompt.slice(0, 80),
|
|
82
|
+
`Auto-created from loop #${entry.id}`,
|
|
83
|
+
{ loopId: entry.id, trigger: entry.trigger },
|
|
84
|
+
);
|
|
85
|
+
onNativeTaskCreated?.(nativeTaskStore);
|
|
86
|
+
return task.id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function hasPendingTasks(): Promise<number> {
|
|
90
|
+
if (isTasksAvailable()) {
|
|
91
|
+
try {
|
|
92
|
+
const requestId = randomUUID();
|
|
93
|
+
const count = await new Promise<number>((resolve) => {
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
unsub();
|
|
96
|
+
resolve(-1);
|
|
97
|
+
}, 3000);
|
|
98
|
+
const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
|
|
99
|
+
unsub();
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
const reply = raw as { success: boolean; data?: { pending: number } };
|
|
102
|
+
resolve(reply.success && reply.data ? reply.data.pending : -1);
|
|
103
|
+
});
|
|
104
|
+
pi.events.emit("tasks:rpc:pending", { requestId });
|
|
105
|
+
});
|
|
106
|
+
return count;
|
|
107
|
+
} catch {
|
|
108
|
+
return -1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return getNativeTaskStore()?.pendingCount() ?? -1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function cleanDoneTasks(): Promise<void> {
|
|
116
|
+
if (isTasksAvailable()) {
|
|
117
|
+
try {
|
|
118
|
+
const requestId = randomUUID();
|
|
119
|
+
await new Promise<void>((resolve) => {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
unsub();
|
|
122
|
+
resolve();
|
|
123
|
+
}, 3000);
|
|
124
|
+
const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
|
|
125
|
+
unsub();
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
debug?.("tasks:rpc:clean — done tasks swept");
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
pi.events.emit("tasks:rpc:clean", { requestId });
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// timeout or error, ignore
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
139
|
+
if (!nativeTaskStore) return;
|
|
140
|
+
nativeTaskStore.pruneCompleted();
|
|
141
|
+
await onNativeTasksPruned?.(nativeTaskStore);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
checkTasksVersion,
|
|
146
|
+
autoCreateTask,
|
|
147
|
+
hasPendingTasks,
|
|
148
|
+
cleanDoneTasks,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
-
import
|
|
4
|
+
import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
|
|
5
|
+
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
7
8
|
const LOCK_RETRY_MS = 50;
|
|
8
9
|
const LOCK_MAX_RETRIES = 100;
|
|
9
10
|
const MAX_LOOPS = 25;
|
|
10
|
-
const MAX_EXPIRY_DAYS = 7;
|
|
11
11
|
|
|
12
12
|
function acquireLock(lockPath: string): void {
|
|
13
13
|
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
@@ -95,29 +95,41 @@ export class LoopStore {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
private toReducerState(): LoopReducerState {
|
|
99
|
+
return {
|
|
100
|
+
nextId: this.nextId,
|
|
101
|
+
loopsById: Object.fromEntries(this.loops.entries()),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private applyReducerEvent(event: LoopReducerEvent): void {
|
|
106
|
+
const result = reduceLoopState(this.toReducerState(), event);
|
|
107
|
+
this.nextId = result.state.nextId;
|
|
108
|
+
this.loops = new Map(Object.entries(result.state.loopsById));
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
99
112
|
return this.withLock(() => {
|
|
100
113
|
if (this.loops.size >= MAX_LOOPS) {
|
|
101
114
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
102
115
|
}
|
|
103
116
|
const now = Date.now();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
};
|
|
119
|
-
this.loops.
|
|
120
|
-
return entry;
|
|
117
|
+
this.applyReducerEvent({
|
|
118
|
+
type: "LOOP_CREATED",
|
|
119
|
+
at: now,
|
|
120
|
+
source: "tool",
|
|
121
|
+
entityType: "loop",
|
|
122
|
+
payload: {
|
|
123
|
+
prompt,
|
|
124
|
+
trigger,
|
|
125
|
+
recurring: opts.recurring,
|
|
126
|
+
autoTask: opts.autoTask,
|
|
127
|
+
taskBacklog: opts.taskBacklog,
|
|
128
|
+
readOnly: opts.readOnly,
|
|
129
|
+
maxFires: opts.maxFires,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return this.loops.get(String(this.nextId - 1))!;
|
|
121
133
|
});
|
|
122
134
|
}
|
|
123
135
|
|
|
@@ -131,37 +143,89 @@ export class LoopStore {
|
|
|
131
143
|
return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
pause(id: string): LoopEntry | undefined {
|
|
147
|
+
return this.withLock(() => {
|
|
148
|
+
const entry = this.loops.get(id);
|
|
149
|
+
if (!entry) return undefined;
|
|
150
|
+
this.applyReducerEvent({
|
|
151
|
+
type: "LOOP_PAUSED",
|
|
152
|
+
at: Date.now(),
|
|
153
|
+
source: "tool",
|
|
154
|
+
entityType: "loop",
|
|
155
|
+
entityId: id,
|
|
156
|
+
payload: { id },
|
|
157
|
+
});
|
|
158
|
+
return this.loops.get(id);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
resume(id: string): LoopEntry | undefined {
|
|
163
|
+
return this.withLock(() => {
|
|
164
|
+
const entry = this.loops.get(id);
|
|
165
|
+
if (!entry) return undefined;
|
|
166
|
+
this.applyReducerEvent({
|
|
167
|
+
type: "LOOP_RESUMED",
|
|
168
|
+
at: Date.now(),
|
|
169
|
+
source: "tool",
|
|
170
|
+
entityType: "loop",
|
|
171
|
+
entityId: id,
|
|
172
|
+
payload: { id },
|
|
173
|
+
});
|
|
174
|
+
return this.loops.get(id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fire(id: string): LoopEntry | undefined {
|
|
135
179
|
return this.withLock(() => {
|
|
136
180
|
const entry = this.loops.get(id);
|
|
137
|
-
if (!entry) return
|
|
181
|
+
if (!entry) return undefined;
|
|
182
|
+
this.applyReducerEvent({
|
|
183
|
+
type: "LOOP_FIRED",
|
|
184
|
+
at: Date.now(),
|
|
185
|
+
source: "system",
|
|
186
|
+
entityType: "loop",
|
|
187
|
+
entityId: id,
|
|
188
|
+
payload: { id },
|
|
189
|
+
});
|
|
190
|
+
return this.loops.get(id);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
|
|
195
|
+
return this.withLock(() => {
|
|
196
|
+
const current = this.loops.get(id);
|
|
197
|
+
if (!current) return { entry: undefined, changedFields: [] };
|
|
138
198
|
|
|
139
199
|
const changedFields: string[] = [];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
changedFields.push("status");
|
|
143
|
-
}
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
|
|
144
202
|
if (fields.trigger !== undefined) {
|
|
145
|
-
|
|
203
|
+
current.trigger = fields.trigger;
|
|
146
204
|
changedFields.push("trigger");
|
|
147
205
|
}
|
|
148
206
|
if (fields.prompt !== undefined) {
|
|
149
|
-
|
|
207
|
+
current.prompt = fields.prompt;
|
|
150
208
|
changedFields.push("prompt");
|
|
151
209
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
changedFields.push("fireCount");
|
|
210
|
+
if (changedFields.length > 0) {
|
|
211
|
+
current.updatedAt = now;
|
|
155
212
|
}
|
|
156
|
-
|
|
157
|
-
return { entry, changedFields };
|
|
213
|
+
|
|
214
|
+
return { entry: this.loops.get(id), changedFields };
|
|
158
215
|
});
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
delete(id: string): boolean {
|
|
162
219
|
return this.withLock(() => {
|
|
163
220
|
if (!this.loops.has(id)) return false;
|
|
164
|
-
this.
|
|
221
|
+
this.applyReducerEvent({
|
|
222
|
+
type: "LOOP_DELETED",
|
|
223
|
+
at: Date.now(),
|
|
224
|
+
source: "tool",
|
|
225
|
+
entityType: "loop",
|
|
226
|
+
entityId: id,
|
|
227
|
+
payload: { id },
|
|
228
|
+
});
|
|
165
229
|
return true;
|
|
166
230
|
});
|
|
167
231
|
}
|
|
@@ -170,11 +234,17 @@ export class LoopStore {
|
|
|
170
234
|
return this.withLock(() => {
|
|
171
235
|
const now = Date.now();
|
|
172
236
|
let count = 0;
|
|
173
|
-
for (const [id, entry] of this.loops) {
|
|
174
|
-
if (now
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
237
|
+
for (const [id, entry] of [...this.loops.entries()]) {
|
|
238
|
+
if (now < entry.expiresAt) continue;
|
|
239
|
+
this.applyReducerEvent({
|
|
240
|
+
type: "LOOP_EXPIRED",
|
|
241
|
+
at: now,
|
|
242
|
+
source: "system",
|
|
243
|
+
entityType: "loop",
|
|
244
|
+
entityId: id,
|
|
245
|
+
payload: { id, reason: "expires_at" },
|
|
246
|
+
});
|
|
247
|
+
count++;
|
|
178
248
|
}
|
|
179
249
|
return count;
|
|
180
250
|
});
|
|
@@ -183,26 +253,38 @@ export class LoopStore {
|
|
|
183
253
|
expireEventLoops(sessionStartedAt: number): number {
|
|
184
254
|
return this.withLock(() => {
|
|
185
255
|
let count = 0;
|
|
186
|
-
const
|
|
187
|
-
for (const [id, entry] of this.loops) {
|
|
256
|
+
for (const [id, entry] of [...this.loops.entries()]) {
|
|
188
257
|
if (entry.status !== "active") continue;
|
|
189
|
-
if (entry.trigger.type
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
258
|
+
if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
|
|
259
|
+
if (entry.createdAt >= sessionStartedAt) continue;
|
|
260
|
+
this.applyReducerEvent({
|
|
261
|
+
type: "LOOP_EXPIRED",
|
|
262
|
+
at: sessionStartedAt,
|
|
263
|
+
source: "session",
|
|
264
|
+
entityType: "loop",
|
|
265
|
+
entityId: id,
|
|
266
|
+
payload: { id, reason: "resume_event_stale" },
|
|
267
|
+
});
|
|
268
|
+
count++;
|
|
195
269
|
}
|
|
196
|
-
for (const id of toDelete) this.loops.delete(id);
|
|
197
270
|
return count;
|
|
198
271
|
});
|
|
199
272
|
}
|
|
200
273
|
|
|
201
274
|
clearAll(): number {
|
|
202
275
|
return this.withLock(() => {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
276
|
+
const ids = [...this.loops.keys()];
|
|
277
|
+
for (const id of ids) {
|
|
278
|
+
this.applyReducerEvent({
|
|
279
|
+
type: "LOOP_DELETED",
|
|
280
|
+
at: Date.now(),
|
|
281
|
+
source: "system",
|
|
282
|
+
entityType: "loop",
|
|
283
|
+
entityId: id,
|
|
284
|
+
payload: { id },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return ids.length;
|
|
206
288
|
});
|
|
207
289
|
}
|
|
208
290
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
|
|
2
|
+
|
|
3
|
+
export type TaskBacklogEvent = ReducerEvent<
|
|
4
|
+
"TASK_BACKLOG_EVALUATED",
|
|
5
|
+
{ pendingCount: number; threshold: number }
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type TaskBacklogEffect =
|
|
9
|
+
| ReducerEffect<"ENSURE_AUTO_TASK_WORKER", { pendingCount: number; threshold: number }>
|
|
10
|
+
| ReducerEffect<"CLEANUP_TASK_BACKLOG_LOOPS", { pendingCount: number }>;
|
|
11
|
+
|
|
12
|
+
export function reduceTaskBacklogEvent(event: TaskBacklogEvent): TaskBacklogEffect[] {
|
|
13
|
+
if (event.type !== "TASK_BACKLOG_EVALUATED") return [];
|
|
14
|
+
|
|
15
|
+
const { pendingCount, threshold } = event.payload;
|
|
16
|
+
if (pendingCount < 0) return [];
|
|
17
|
+
if (pendingCount === 0) {
|
|
18
|
+
return [{
|
|
19
|
+
type: "CLEANUP_TASK_BACKLOG_LOOPS",
|
|
20
|
+
entityType: "task",
|
|
21
|
+
payload: { pendingCount },
|
|
22
|
+
}];
|
|
23
|
+
}
|
|
24
|
+
if (pendingCount >= threshold) {
|
|
25
|
+
return [{
|
|
26
|
+
type: "ENSURE_AUTO_TASK_WORKER",
|
|
27
|
+
entityType: "task",
|
|
28
|
+
payload: { pendingCount, threshold },
|
|
29
|
+
}];
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { TaskEntry } from "./task-types.js";
|
|
2
|
+
|
|
3
|
+
export interface TaskReducerState {
|
|
4
|
+
nextId: number;
|
|
5
|
+
tasksById: Record<string, TaskEntry>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type TaskReducerEvent =
|
|
9
|
+
| {
|
|
10
|
+
type: "TASK_CREATED";
|
|
11
|
+
at: number;
|
|
12
|
+
source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
13
|
+
entityType?: "task";
|
|
14
|
+
entityId?: string;
|
|
15
|
+
payload: {
|
|
16
|
+
subject: string;
|
|
17
|
+
description: string;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
type: "TASK_STARTED" | "TASK_COMPLETED" | "TASK_REOPENED" | "TASK_DELETED";
|
|
23
|
+
at: number;
|
|
24
|
+
source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
25
|
+
entityType?: "task";
|
|
26
|
+
entityId?: string;
|
|
27
|
+
payload: { id: string };
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: "TASK_UPDATED";
|
|
31
|
+
at: number;
|
|
32
|
+
source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
33
|
+
entityType?: "task";
|
|
34
|
+
entityId?: string;
|
|
35
|
+
payload: {
|
|
36
|
+
id: string;
|
|
37
|
+
subject?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: "TASKS_PRUNED";
|
|
43
|
+
at: number;
|
|
44
|
+
source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
45
|
+
entityType?: "task";
|
|
46
|
+
entityId?: string;
|
|
47
|
+
payload: {
|
|
48
|
+
reason: "git_commit" | "zero_pending_cleanup" | "manual";
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type TaskReducerEffect =
|
|
53
|
+
| {
|
|
54
|
+
type: "PERSIST_TASK";
|
|
55
|
+
entityType: "task";
|
|
56
|
+
entityId: string;
|
|
57
|
+
payload: { task: TaskEntry };
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
type: "DELETE_TASK";
|
|
61
|
+
entityType: "task";
|
|
62
|
+
entityId: string;
|
|
63
|
+
payload: { id: string };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface TaskReduceResult {
|
|
67
|
+
state: TaskReducerState;
|
|
68
|
+
effects: TaskReducerEffect[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cloneState(state: TaskReducerState): TaskReducerState {
|
|
72
|
+
return {
|
|
73
|
+
nextId: state.nextId,
|
|
74
|
+
tasksById: { ...state.tasksById },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent): TaskReduceResult {
|
|
79
|
+
if (event.type === "TASK_CREATED") {
|
|
80
|
+
const next = cloneState(state);
|
|
81
|
+
const id = String(next.nextId++);
|
|
82
|
+
const task: TaskEntry = {
|
|
83
|
+
id,
|
|
84
|
+
subject: event.payload.subject,
|
|
85
|
+
description: event.payload.description,
|
|
86
|
+
status: "pending",
|
|
87
|
+
createdAt: event.at,
|
|
88
|
+
updatedAt: event.at,
|
|
89
|
+
metadata: event.payload.metadata,
|
|
90
|
+
};
|
|
91
|
+
next.tasksById[id] = task;
|
|
92
|
+
return {
|
|
93
|
+
state: next,
|
|
94
|
+
effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (event.type === "TASKS_PRUNED") {
|
|
99
|
+
const next = cloneState(state);
|
|
100
|
+
const effects: TaskReducerEffect[] = [];
|
|
101
|
+
for (const [id, task] of Object.entries(next.tasksById)) {
|
|
102
|
+
if (task.status !== "completed") continue;
|
|
103
|
+
delete next.tasksById[id];
|
|
104
|
+
effects.push({ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } });
|
|
105
|
+
}
|
|
106
|
+
return { state: next, effects };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const id = event.payload.id;
|
|
110
|
+
const current = state.tasksById[id];
|
|
111
|
+
if (!current) return { state, effects: [] };
|
|
112
|
+
|
|
113
|
+
if (event.type === "TASK_DELETED") {
|
|
114
|
+
const next = cloneState(state);
|
|
115
|
+
delete next.tasksById[id];
|
|
116
|
+
return {
|
|
117
|
+
state: next,
|
|
118
|
+
effects: [{ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const next = cloneState(state);
|
|
123
|
+
const task: TaskEntry = { ...current };
|
|
124
|
+
|
|
125
|
+
if (event.type === "TASK_STARTED") {
|
|
126
|
+
task.status = "in_progress";
|
|
127
|
+
task.updatedAt = event.at;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (event.type === "TASK_COMPLETED") {
|
|
131
|
+
task.status = "completed";
|
|
132
|
+
task.updatedAt = event.at;
|
|
133
|
+
task.completedAt = event.at;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (event.type === "TASK_REOPENED") {
|
|
137
|
+
task.status = "pending";
|
|
138
|
+
task.updatedAt = event.at;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (event.type === "TASK_UPDATED") {
|
|
142
|
+
if (event.payload.subject !== undefined) task.subject = event.payload.subject;
|
|
143
|
+
if (event.payload.description !== undefined) task.description = event.payload.description;
|
|
144
|
+
task.updatedAt = event.at;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
next.tasksById[id] = task;
|
|
148
|
+
return {
|
|
149
|
+
state: next,
|
|
150
|
+
effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
|
|
151
|
+
};
|
|
152
|
+
}
|