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