@trevonistrevon/pi-loop 0.5.4 → 0.5.6
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/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-final.json +32 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/commands/index.html +131 -0
- package/coverage/src/commands/loop-command.ts.html +634 -0
- package/coverage/src/commands/tasks-command.ts.html +490 -0
- package/coverage/src/coordinator.ts.html +430 -0
- package/coverage/src/goal-coordinator.ts.html +259 -0
- package/coverage/src/goal-reducer.ts.html +982 -0
- package/coverage/src/goal-store.ts.html +742 -0
- package/coverage/src/goal-verifier.ts.html +808 -0
- package/coverage/src/index.html +386 -0
- package/coverage/src/index.ts.html +1063 -0
- package/coverage/src/loop-parse.ts.html +574 -0
- package/coverage/src/loop-reducer.ts.html +559 -0
- package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
- package/coverage/src/monitor-manager.ts.html +934 -0
- package/coverage/src/monitor-reducer.ts.html +583 -0
- package/coverage/src/notification-reducer.ts.html +550 -0
- package/coverage/src/reducer-backed-store.ts.html +589 -0
- package/coverage/src/runtime/index.html +191 -0
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
- package/coverage/src/runtime/notification-runtime.ts.html +721 -0
- package/coverage/src/runtime/scope.ts.html +196 -0
- package/coverage/src/runtime/session-runtime.ts.html +589 -0
- package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
- package/coverage/src/runtime/task-rpc.ts.html +535 -0
- package/coverage/src/scheduler.ts.html +400 -0
- package/coverage/src/store.ts.html +667 -0
- package/coverage/src/task-backlog-coordinator.ts.html +181 -0
- package/coverage/src/task-reducer.ts.html +550 -0
- package/coverage/src/task-store.ts.html +526 -0
- package/coverage/src/tools/index.html +146 -0
- package/coverage/src/tools/loop-tools.ts.html +1000 -0
- package/coverage/src/tools/monitor-tools.ts.html +547 -0
- package/coverage/src/tools/native-task-tools.ts.html +535 -0
- package/coverage/src/trigger-system.ts.html +547 -0
- package/coverage/src/ui/index.html +116 -0
- package/coverage/src/ui/widget.ts.html +292 -0
- package/dist/goal-reducer.d.ts +9 -0
- package/dist/goal-reducer.js +11 -2
- package/dist/goal-store.d.ts +4 -15
- package/dist/goal-store.js +32 -155
- package/dist/index.js +5 -1
- package/dist/loop-reducer.d.ts +7 -0
- package/dist/loop-reducer.js +9 -0
- package/dist/monitor-manager.d.ts +9 -0
- package/dist/monitor-manager.js +33 -12
- package/dist/reducer-backed-store.d.ts +65 -0
- package/dist/reducer-backed-store.js +160 -0
- package/dist/store.d.ts +4 -16
- package/dist/store.js +25 -157
- package/dist/task-reducer.js +3 -0
- package/dist/task-store.d.ts +4 -15
- package/dist/task-store.js +25 -148
- package/dist/tools/monitor-tools.js +9 -0
- package/dist/trigger-system.js +2 -1
- package/package.json +4 -1
- package/src/goal-reducer.ts +20 -1
- package/src/goal-store.ts +35 -143
- package/src/index.ts +5 -1
- package/src/loop-reducer.ts +10 -0
- package/src/monitor-manager.ts +35 -12
- package/src/reducer-backed-store.ts +168 -0
- package/src/store.ts +28 -142
- package/src/task-reducer.ts +3 -0
- package/src/task-store.ts +28 -136
- package/src/tools/monitor-tools.ts +9 -0
- package/src/trigger-system.ts +2 -1
- package/vitest.config.ts +25 -0
- package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
- package/tmp/perf-cap-data/loops.json +0 -455
- package/tmp/perf-cap-data/tasks.json +0 -1
- package/tmp/perf-data/loops-0.json +0 -1
- package/tmp/perf-data/loops-10.json +0 -1
- package/tmp/perf-data/loops-100.json +0 -1
- package/tmp/perf-data/loops-1000.json +0 -1
- package/tmp/perf-data/loops-500.json +0 -1
- package/tmp/perf-data/tasks-0.json +0 -1
- package/tmp/perf-data/tasks-10.json +0 -1
- package/tmp/perf-data/tasks-100.json +0 -1
- package/tmp/perf-data/tasks-1000.json +0 -1
- package/tmp/perf-data/tasks-500.json +0 -1
- package/tmp/perf-data-session/loops-0.json +0 -4
- package/tmp/perf-data-session/loops-100.json +0 -1805
- package/tmp/perf-data-session/loops-1000.json +0 -18005
- package/tmp/perf-data-session/loops-25.json +0 -455
- package/tmp/perf-data-session/loops-500.json +0 -9005
- package/tmp/perf-data-session/tasks-0.json +0 -1
- package/tmp/perf-data-session/tasks-100.json +0 -1
- package/tmp/perf-data-session/tasks-1000.json +0 -1
- package/tmp/perf-data-session/tasks-25.json +0 -1
- package/tmp/perf-data-session/tasks-500.json +0 -1
- package/tmp/perf-session-init.js +0 -98
- package/tmp/perf-startup-audit.js +0 -80
package/src/goal-reducer.ts
CHANGED
|
@@ -82,6 +82,15 @@ export type GoalReducerEffect =
|
|
|
82
82
|
entityType: "goal";
|
|
83
83
|
entityId: string;
|
|
84
84
|
payload: { id: string };
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
// Diagnostic: a transition was dropped because the goal is in a terminal
|
|
88
|
+
// state. Surfaced (rather than silently ignored) so the effect sink can log
|
|
89
|
+
// it. Carries no state change.
|
|
90
|
+
type: "GOAL_TRANSITION_REJECTED";
|
|
91
|
+
entityType: "goal";
|
|
92
|
+
entityId: string;
|
|
93
|
+
payload: { id: string; status: GoalEntry["status"]; attempted: string };
|
|
85
94
|
};
|
|
86
95
|
|
|
87
96
|
export interface GoalReduceResult {
|
|
@@ -160,7 +169,17 @@ export function reduceGoalState(state: GoalReducerState, event: GoalReducerEvent
|
|
|
160
169
|
const id = event.payload.id;
|
|
161
170
|
const current = state.goalsById[id];
|
|
162
171
|
if (!current) return { state, effects: [] };
|
|
163
|
-
if (isTerminal(current) && event.type !== "GOAL_ARCHIVED")
|
|
172
|
+
if (isTerminal(current) && event.type !== "GOAL_ARCHIVED") {
|
|
173
|
+
return {
|
|
174
|
+
state,
|
|
175
|
+
effects: [{
|
|
176
|
+
type: "GOAL_TRANSITION_REJECTED",
|
|
177
|
+
entityType: "goal",
|
|
178
|
+
entityId: id,
|
|
179
|
+
payload: { id, status: current.status, attempted: event.type },
|
|
180
|
+
}],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
164
183
|
|
|
165
184
|
const next = cloneState(state);
|
|
166
185
|
const goal: GoalEntry = {
|
package/src/goal-store.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
1
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { type GoalReducerEvent, reduceGoalState } from "./goal-reducer.js";
|
|
5
4
|
import type {
|
|
6
5
|
GoalCriteria,
|
|
@@ -10,121 +9,24 @@ import type {
|
|
|
10
9
|
GoalScope,
|
|
11
10
|
GoalStoreData,
|
|
12
11
|
} from "./goal-types.js";
|
|
12
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
13
13
|
|
|
14
14
|
const GOALS_DIR = join(homedir(), ".pi", "goals");
|
|
15
|
-
const LOCK_RETRY_MS = 50;
|
|
16
|
-
const LOCK_MAX_RETRIES = 100;
|
|
17
15
|
const MAX_GOALS = 200;
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
21
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
22
|
-
try {
|
|
23
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
24
|
-
return;
|
|
25
|
-
} catch (e: any) {
|
|
26
|
-
if (e.code === "EEXIST") {
|
|
27
|
-
try {
|
|
28
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
29
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
30
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
} catch { /* ignore read errors */ }
|
|
34
|
-
const start = Date.now();
|
|
35
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
throw e;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function releaseLock(lockPath: string): void {
|
|
45
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isProcessRunning(pid: number): boolean {
|
|
49
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export class GoalStore {
|
|
53
|
-
private filePath: string | undefined;
|
|
54
|
-
private lockPath: string | undefined;
|
|
55
|
-
private lastLoadedSignature: string | undefined;
|
|
56
|
-
|
|
57
|
-
private nextId = 1;
|
|
58
|
-
private goals = new Map<string, GoalEntry>();
|
|
59
|
-
|
|
17
|
+
export class GoalStore extends ReducerBackedStore<GoalEntry, GoalReducerState, GoalReducerEvent, GoalStoreData> {
|
|
60
18
|
constructor(listIdOrPath?: string) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const stat = statSync(this.filePath);
|
|
73
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private load(force = false): void {
|
|
77
|
-
if (!this.filePath) return;
|
|
78
|
-
const signature = this.getFileSignature();
|
|
79
|
-
if (!signature) return;
|
|
80
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
81
|
-
try {
|
|
82
|
-
const data: GoalStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
83
|
-
this.nextId = data.nextId;
|
|
84
|
-
this.goals.clear();
|
|
85
|
-
for (const goal of data.goals) {
|
|
86
|
-
this.goals.set(goal.id, goal);
|
|
87
|
-
}
|
|
88
|
-
this.lastLoadedSignature = signature;
|
|
89
|
-
} catch { /* corrupt file — start fresh */ }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private save(): void {
|
|
93
|
-
if (!this.filePath) return;
|
|
94
|
-
const data: GoalStoreData = {
|
|
95
|
-
nextId: this.nextId,
|
|
96
|
-
goals: Array.from(this.goals.values()),
|
|
97
|
-
};
|
|
98
|
-
const tmpPath = this.filePath + ".tmp";
|
|
99
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
100
|
-
renameSync(tmpPath, this.filePath);
|
|
101
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private withLock<T>(fn: () => T): T {
|
|
105
|
-
if (!this.lockPath) return fn();
|
|
106
|
-
acquireLock(this.lockPath);
|
|
107
|
-
try {
|
|
108
|
-
this.load(true);
|
|
109
|
-
const result = fn();
|
|
110
|
-
this.save();
|
|
111
|
-
return result;
|
|
112
|
-
} finally {
|
|
113
|
-
releaseLock(this.lockPath);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private toReducerState(): GoalReducerState {
|
|
118
|
-
return {
|
|
119
|
-
nextId: this.nextId,
|
|
120
|
-
goalsById: Object.fromEntries(this.goals.entries()),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private applyReducerEvent(event: GoalReducerEvent): void {
|
|
125
|
-
const result = reduceGoalState(this.toReducerState(), event);
|
|
126
|
-
this.nextId = result.state.nextId;
|
|
127
|
-
this.goals = new Map(Object.entries(result.state.goalsById));
|
|
19
|
+
super(
|
|
20
|
+
{
|
|
21
|
+
baseDir: GOALS_DIR,
|
|
22
|
+
reduce: (state, event) => reduceGoalState(state, event),
|
|
23
|
+
toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
|
|
24
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
|
|
25
|
+
serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
|
|
26
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
|
|
27
|
+
},
|
|
28
|
+
listIdOrPath,
|
|
29
|
+
);
|
|
128
30
|
}
|
|
129
31
|
|
|
130
32
|
create(
|
|
@@ -135,7 +37,7 @@ export class GoalStore {
|
|
|
135
37
|
metadata?: Record<string, unknown>,
|
|
136
38
|
): GoalEntry {
|
|
137
39
|
return this.withLock(() => {
|
|
138
|
-
if (this.
|
|
40
|
+
if (this.entries.size >= MAX_GOALS) {
|
|
139
41
|
throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
|
|
140
42
|
}
|
|
141
43
|
this.applyReducerEvent({
|
|
@@ -151,23 +53,13 @@ export class GoalStore {
|
|
|
151
53
|
metadata,
|
|
152
54
|
},
|
|
153
55
|
});
|
|
154
|
-
return this.
|
|
56
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
155
57
|
});
|
|
156
58
|
}
|
|
157
59
|
|
|
158
|
-
get(id: string): GoalEntry | undefined {
|
|
159
|
-
if (this.filePath) this.load();
|
|
160
|
-
return this.goals.get(id);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
list(): GoalEntry[] {
|
|
164
|
-
if (this.filePath) this.load();
|
|
165
|
-
return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
60
|
activate(id: string): GoalEntry | undefined {
|
|
169
61
|
return this.withLock(() => {
|
|
170
|
-
if (!this.
|
|
62
|
+
if (!this.entries.has(id)) return undefined;
|
|
171
63
|
this.applyReducerEvent({
|
|
172
64
|
type: "GOAL_ACTIVATED",
|
|
173
65
|
at: Date.now(),
|
|
@@ -176,13 +68,13 @@ export class GoalStore {
|
|
|
176
68
|
entityId: id,
|
|
177
69
|
payload: { id },
|
|
178
70
|
});
|
|
179
|
-
return this.
|
|
71
|
+
return this.entries.get(id);
|
|
180
72
|
});
|
|
181
73
|
}
|
|
182
74
|
|
|
183
75
|
recordProgress(id: string, progress: GoalProgressSnapshot): GoalEntry | undefined {
|
|
184
76
|
return this.withLock(() => {
|
|
185
|
-
if (!this.
|
|
77
|
+
if (!this.entries.has(id)) return undefined;
|
|
186
78
|
this.applyReducerEvent({
|
|
187
79
|
type: "GOAL_PROGRESS_RECORDED",
|
|
188
80
|
at: Date.now(),
|
|
@@ -191,13 +83,13 @@ export class GoalStore {
|
|
|
191
83
|
entityId: id,
|
|
192
84
|
payload: { id, progress },
|
|
193
85
|
});
|
|
194
|
-
return this.
|
|
86
|
+
return this.entries.get(id);
|
|
195
87
|
});
|
|
196
88
|
}
|
|
197
89
|
|
|
198
90
|
markVerificationStarted(id: string): GoalEntry | undefined {
|
|
199
91
|
return this.withLock(() => {
|
|
200
|
-
if (!this.
|
|
92
|
+
if (!this.entries.has(id)) return undefined;
|
|
201
93
|
this.applyReducerEvent({
|
|
202
94
|
type: "GOAL_VERIFICATION_STARTED",
|
|
203
95
|
at: Date.now(),
|
|
@@ -206,13 +98,13 @@ export class GoalStore {
|
|
|
206
98
|
entityId: id,
|
|
207
99
|
payload: { id },
|
|
208
100
|
});
|
|
209
|
-
return this.
|
|
101
|
+
return this.entries.get(id);
|
|
210
102
|
});
|
|
211
103
|
}
|
|
212
104
|
|
|
213
105
|
markVerified(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
214
106
|
return this.withLock(() => {
|
|
215
|
-
if (!this.
|
|
107
|
+
if (!this.entries.has(id)) return undefined;
|
|
216
108
|
this.applyReducerEvent({
|
|
217
109
|
type: "GOAL_VERIFICATION_PASSED",
|
|
218
110
|
at: Date.now(),
|
|
@@ -221,13 +113,13 @@ export class GoalStore {
|
|
|
221
113
|
entityId: id,
|
|
222
114
|
payload: { id, reason, progress },
|
|
223
115
|
});
|
|
224
|
-
return this.
|
|
116
|
+
return this.entries.get(id);
|
|
225
117
|
});
|
|
226
118
|
}
|
|
227
119
|
|
|
228
120
|
markFailed(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
229
121
|
return this.withLock(() => {
|
|
230
|
-
if (!this.
|
|
122
|
+
if (!this.entries.has(id)) return undefined;
|
|
231
123
|
this.applyReducerEvent({
|
|
232
124
|
type: "GOAL_FAILED",
|
|
233
125
|
at: Date.now(),
|
|
@@ -236,13 +128,13 @@ export class GoalStore {
|
|
|
236
128
|
entityId: id,
|
|
237
129
|
payload: { id, reason, progress },
|
|
238
130
|
});
|
|
239
|
-
return this.
|
|
131
|
+
return this.entries.get(id);
|
|
240
132
|
});
|
|
241
133
|
}
|
|
242
134
|
|
|
243
135
|
markBlocked(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
244
136
|
return this.withLock(() => {
|
|
245
|
-
if (!this.
|
|
137
|
+
if (!this.entries.has(id)) return undefined;
|
|
246
138
|
this.applyReducerEvent({
|
|
247
139
|
type: "GOAL_BLOCKED",
|
|
248
140
|
at: Date.now(),
|
|
@@ -251,13 +143,13 @@ export class GoalStore {
|
|
|
251
143
|
entityId: id,
|
|
252
144
|
payload: { id, reason, progress },
|
|
253
145
|
});
|
|
254
|
-
return this.
|
|
146
|
+
return this.entries.get(id);
|
|
255
147
|
});
|
|
256
148
|
}
|
|
257
149
|
|
|
258
150
|
unblock(id: string): GoalEntry | undefined {
|
|
259
151
|
return this.withLock(() => {
|
|
260
|
-
if (!this.
|
|
152
|
+
if (!this.entries.has(id)) return undefined;
|
|
261
153
|
this.applyReducerEvent({
|
|
262
154
|
type: "GOAL_UNBLOCKED",
|
|
263
155
|
at: Date.now(),
|
|
@@ -266,7 +158,7 @@ export class GoalStore {
|
|
|
266
158
|
entityId: id,
|
|
267
159
|
payload: { id },
|
|
268
160
|
});
|
|
269
|
-
return this.
|
|
161
|
+
return this.entries.get(id);
|
|
270
162
|
});
|
|
271
163
|
}
|
|
272
164
|
|
|
@@ -281,7 +173,7 @@ export class GoalStore {
|
|
|
281
173
|
},
|
|
282
174
|
): GoalEntry | undefined {
|
|
283
175
|
return this.withLock(() => {
|
|
284
|
-
if (!this.
|
|
176
|
+
if (!this.entries.has(id)) return undefined;
|
|
285
177
|
if (
|
|
286
178
|
fields.title === undefined
|
|
287
179
|
&& fields.description === undefined
|
|
@@ -289,7 +181,7 @@ export class GoalStore {
|
|
|
289
181
|
&& fields.criteria === undefined
|
|
290
182
|
&& fields.metadata === undefined
|
|
291
183
|
) {
|
|
292
|
-
return this.
|
|
184
|
+
return this.entries.get(id);
|
|
293
185
|
}
|
|
294
186
|
this.applyReducerEvent({
|
|
295
187
|
type: "GOAL_UPDATED",
|
|
@@ -306,13 +198,13 @@ export class GoalStore {
|
|
|
306
198
|
metadata: fields.metadata,
|
|
307
199
|
},
|
|
308
200
|
});
|
|
309
|
-
return this.
|
|
201
|
+
return this.entries.get(id);
|
|
310
202
|
});
|
|
311
203
|
}
|
|
312
204
|
|
|
313
205
|
archive(id: string, reason?: string): GoalEntry | undefined {
|
|
314
206
|
return this.withLock(() => {
|
|
315
|
-
if (!this.
|
|
207
|
+
if (!this.entries.has(id)) return undefined;
|
|
316
208
|
this.applyReducerEvent({
|
|
317
209
|
type: "GOAL_ARCHIVED",
|
|
318
210
|
at: Date.now(),
|
|
@@ -321,7 +213,7 @@ export class GoalStore {
|
|
|
321
213
|
entityId: id,
|
|
322
214
|
payload: { id, reason },
|
|
323
215
|
});
|
|
324
|
-
return this.
|
|
216
|
+
return this.entries.get(id);
|
|
325
217
|
});
|
|
326
218
|
}
|
|
327
219
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { registerLoopCommand } from "./commands/loop-command.js";
|
|
19
19
|
import { registerTasksCommand } from "./commands/tasks-command.js";
|
|
20
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
20
21
|
import { MonitorManager } from "./monitor-manager.js";
|
|
21
22
|
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
|
|
22
23
|
import {
|
|
@@ -54,6 +55,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
55
|
let scheduler: CronScheduler;
|
|
55
56
|
let triggerSystem: TriggerSystem;
|
|
56
57
|
const widget = new LoopWidget(store, monitorManager);
|
|
58
|
+
// Repaint the status bar when a monitor finishes/prunes on its own (no tool
|
|
59
|
+
// call), so stale monitors don't linger in the count between turns.
|
|
60
|
+
monitorManager.setOnChange(() => widget.update());
|
|
57
61
|
widget.setTaskSummaryProvider(() => {
|
|
58
62
|
if (!nativeTaskStore) return { count: 0 };
|
|
59
63
|
const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
|
|
@@ -178,7 +182,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
182
|
function onLoopFire(entry: LoopEntry): void {
|
|
179
183
|
debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
|
|
180
184
|
|
|
181
|
-
if (
|
|
185
|
+
if (atMaxFires(entry)) {
|
|
182
186
|
debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
|
|
183
187
|
store.delete(entry.id);
|
|
184
188
|
return;
|
package/src/loop-reducer.ts
CHANGED
|
@@ -2,6 +2,16 @@ import type { LoopEntry, Trigger } from "./types.js";
|
|
|
2
2
|
|
|
3
3
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
7
|
+
* `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
|
|
8
|
+
* `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
|
|
9
|
+
* only the predicate is shared.
|
|
10
|
+
*/
|
|
11
|
+
export function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean {
|
|
12
|
+
return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
6
16
|
|
|
7
17
|
export interface LoopReducerState {
|
package/src/monitor-manager.ts
CHANGED
|
@@ -11,9 +11,20 @@ import type { MonitorEntry, MonitorProcess } from "./types.js";
|
|
|
11
11
|
export class MonitorManager {
|
|
12
12
|
private processes = new Map<string, MonitorProcess>();
|
|
13
13
|
private nextId = 1;
|
|
14
|
+
private onChange: (() => void) | undefined;
|
|
14
15
|
|
|
15
16
|
constructor(private pi: ExtensionAPI) {}
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Register a callback fired when a monitor's status changes or it is pruned
|
|
20
|
+
* (e.g. autonomous completion/error/stop/prune with no tool call). Used to
|
|
21
|
+
* repaint the status widget, which otherwise only refreshes on turn
|
|
22
|
+
* boundaries and explicit tool actions. Not fired for output lines.
|
|
23
|
+
*/
|
|
24
|
+
setOnChange(cb: () => void): void {
|
|
25
|
+
this.onChange = cb;
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
private toReducerState(): MonitorReducerState {
|
|
18
29
|
return {
|
|
19
30
|
nextId: this.nextId,
|
|
@@ -34,9 +45,31 @@ export class MonitorManager {
|
|
|
34
45
|
}
|
|
35
46
|
process.entry = updated;
|
|
36
47
|
}
|
|
48
|
+
// Repaint on status/set transitions, but not on the frequent output-line
|
|
49
|
+
// events (which never change the visible count).
|
|
50
|
+
if (event.type !== "MONITOR_OUTPUT" && event.type !== "MONITOR_ONDONE_REGISTERED") {
|
|
51
|
+
this.onChange?.();
|
|
52
|
+
}
|
|
37
53
|
return result;
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
// Remove a finished monitor (completed/errored/stopped) after a brief delay so
|
|
57
|
+
// tool consumers can still read its final state via MonitorList. Shared by
|
|
58
|
+
// finish() and stop() so every terminal status is pruned consistently — a
|
|
59
|
+
// stopped monitor that is never pruned lingers in list() and the widget count.
|
|
60
|
+
private schedulePrune(id: string): void {
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
this.applyReducerEvent({
|
|
63
|
+
type: "MONITOR_PRUNED",
|
|
64
|
+
at: Date.now(),
|
|
65
|
+
source: "system",
|
|
66
|
+
entityType: "monitor",
|
|
67
|
+
entityId: id,
|
|
68
|
+
payload: { id },
|
|
69
|
+
});
|
|
70
|
+
}, 30000);
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
create(command: string, description?: string, timeout = 300000): MonitorEntry {
|
|
41
74
|
const now = Date.now();
|
|
42
75
|
const result = reduceMonitorState(this.toReducerState(), {
|
|
@@ -132,18 +165,7 @@ export class MonitorManager {
|
|
|
132
165
|
bp.completionCallbacks = [];
|
|
133
166
|
for (const resolve of bp.waiters) resolve();
|
|
134
167
|
bp.waiters = [];
|
|
135
|
-
|
|
136
|
-
// consumers have time to read the final state via MonitorList.
|
|
137
|
-
setTimeout(() => {
|
|
138
|
-
this.applyReducerEvent({
|
|
139
|
-
type: "MONITOR_PRUNED",
|
|
140
|
-
at: Date.now(),
|
|
141
|
-
source: "system",
|
|
142
|
-
entityType: "monitor",
|
|
143
|
-
entityId: id,
|
|
144
|
-
payload: { id },
|
|
145
|
-
});
|
|
146
|
-
}, 30000);
|
|
168
|
+
this.schedulePrune(id);
|
|
147
169
|
};
|
|
148
170
|
|
|
149
171
|
child.on("close", (code) => {
|
|
@@ -214,6 +236,7 @@ export class MonitorManager {
|
|
|
214
236
|
reason: "manual",
|
|
215
237
|
},
|
|
216
238
|
});
|
|
239
|
+
this.schedulePrune(id);
|
|
217
240
|
bp.proc.kill("SIGTERM");
|
|
218
241
|
|
|
219
242
|
await new Promise<void>((resolve) => {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import type { AnyReducerEffect } from "./coordinator.js";
|
|
4
|
+
|
|
5
|
+
const LOCK_RETRY_MS = 50;
|
|
6
|
+
const LOCK_MAX_RETRIES = 100;
|
|
7
|
+
|
|
8
|
+
function acquireLock(lockPath: string): void {
|
|
9
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
10
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
11
|
+
try {
|
|
12
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
13
|
+
return;
|
|
14
|
+
} catch (e: any) {
|
|
15
|
+
if (e.code === "EEXIST") {
|
|
16
|
+
try {
|
|
17
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
18
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
19
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
} catch { /* ignore read errors */ }
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function releaseLock(lockPath: string): void {
|
|
34
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isProcessRunning(pid: number): boolean {
|
|
38
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReducerResult<TState> {
|
|
42
|
+
state: TState;
|
|
43
|
+
effects: AnyReducerEffect[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-store glue the base needs to translate between its internal
|
|
48
|
+
* `{ nextId, entries }` representation, the pure reducer's state shape, and the
|
|
49
|
+
* on-disk JSON shape. Each is a small, allocation-only function.
|
|
50
|
+
*/
|
|
51
|
+
export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
|
|
52
|
+
/** Directory for `<listId>.json` when constructed with a bare list id. */
|
|
53
|
+
baseDir: string;
|
|
54
|
+
reduce: (state: TState, event: TEvent) => ReducerResult<TState>;
|
|
55
|
+
toReducerState: (nextId: number, entries: Map<string, TEntry>) => TState;
|
|
56
|
+
fromReducerState: (state: TState) => { nextId: number; entries: Map<string, TEntry> };
|
|
57
|
+
serialize: (nextId: number, entries: Map<string, TEntry>) => TData;
|
|
58
|
+
deserialize: (data: TData) => { nextId: number; entries: Map<string, TEntry> };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Shared persistence + reducer-dispatch machinery for the file-backed entity
|
|
63
|
+
* stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
|
|
64
|
+
* save, and reducer application; subclasses add only their entity-specific
|
|
65
|
+
* command methods.
|
|
66
|
+
*
|
|
67
|
+
* Durability boundary: every mutation runs inside {@link withLock}, which saves
|
|
68
|
+
* the whole file unconditionally after the callback. Reducer effects are
|
|
69
|
+
* therefore *not* the persistence mechanism — they are surfaced to
|
|
70
|
+
* {@link onEffects} (default: no-op) so cross-entity effects (e.g.
|
|
71
|
+
* `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
|
|
72
|
+
* without being silently dropped at the reducer call site.
|
|
73
|
+
*/
|
|
74
|
+
export abstract class ReducerBackedStore<TEntry extends { id: string }, TState, TEvent, TData> {
|
|
75
|
+
protected filePath: string | undefined;
|
|
76
|
+
protected lockPath: string | undefined;
|
|
77
|
+
private lastLoadedSignature: string | undefined;
|
|
78
|
+
|
|
79
|
+
protected nextId = 1;
|
|
80
|
+
protected entries = new Map<string, TEntry>();
|
|
81
|
+
|
|
82
|
+
private readonly config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>;
|
|
83
|
+
|
|
84
|
+
constructor(config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>, listIdOrPath?: string) {
|
|
85
|
+
this.config = config;
|
|
86
|
+
if (!listIdOrPath) return;
|
|
87
|
+
const filePath = isAbsolute(listIdOrPath) ? listIdOrPath : join(config.baseDir, `${listIdOrPath}.json`);
|
|
88
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
89
|
+
this.filePath = filePath;
|
|
90
|
+
this.lockPath = `${filePath}.lock`;
|
|
91
|
+
this.load();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private getFileSignature(): string | undefined {
|
|
95
|
+
if (!this.filePath || !existsSync(this.filePath)) return undefined;
|
|
96
|
+
const stat = statSync(this.filePath);
|
|
97
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private load(force = false): void {
|
|
101
|
+
if (!this.filePath) return;
|
|
102
|
+
const signature = this.getFileSignature();
|
|
103
|
+
if (!signature) return;
|
|
104
|
+
if (!force && signature === this.lastLoadedSignature) return;
|
|
105
|
+
try {
|
|
106
|
+
const data: TData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
107
|
+
const { nextId, entries } = this.config.deserialize(data);
|
|
108
|
+
this.nextId = nextId;
|
|
109
|
+
this.entries = entries;
|
|
110
|
+
this.lastLoadedSignature = signature;
|
|
111
|
+
} catch { /* corrupt file — start fresh */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private save(): void {
|
|
115
|
+
if (!this.filePath) return;
|
|
116
|
+
const data = this.config.serialize(this.nextId, this.entries);
|
|
117
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
118
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
119
|
+
renameSync(tmpPath, this.filePath);
|
|
120
|
+
this.lastLoadedSignature = this.getFileSignature();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected withLock<T>(fn: () => T): T {
|
|
124
|
+
if (!this.lockPath) return fn();
|
|
125
|
+
acquireLock(this.lockPath);
|
|
126
|
+
try {
|
|
127
|
+
this.load(true);
|
|
128
|
+
const result = fn();
|
|
129
|
+
this.save();
|
|
130
|
+
return result;
|
|
131
|
+
} finally {
|
|
132
|
+
releaseLock(this.lockPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected applyReducerEvent(event: TEvent): void {
|
|
137
|
+
const result = this.config.reduce(this.config.toReducerState(this.nextId, this.entries), event);
|
|
138
|
+
const { nextId, entries } = this.config.fromReducerState(result.state);
|
|
139
|
+
this.nextId = nextId;
|
|
140
|
+
this.entries = entries;
|
|
141
|
+
if (result.effects.length > 0) this.onEffects(result.effects);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hook for reducer effects. Default no-op: durability is owned by
|
|
146
|
+
* {@link withLock}. Override to forward non-persistence effects.
|
|
147
|
+
*/
|
|
148
|
+
protected onEffects(_effects: AnyReducerEffect[]): void { /* no-op by default */ }
|
|
149
|
+
|
|
150
|
+
/** Reload (signature-gated) and return the entry, or undefined. */
|
|
151
|
+
get(id: string): TEntry | undefined {
|
|
152
|
+
if (this.filePath) this.load();
|
|
153
|
+
return this.entries.get(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Reload (signature-gated) and return all entries sorted by numeric id. */
|
|
157
|
+
list(): TEntry[] {
|
|
158
|
+
if (this.filePath) this.load();
|
|
159
|
+
return Array.from(this.entries.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove the backing file when the store is empty. No-op for memory stores. */
|
|
163
|
+
deleteFileIfEmpty(): boolean {
|
|
164
|
+
if (!this.filePath || this.entries.size > 0) return false;
|
|
165
|
+
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|