@trevonistrevon/pi-loop 0.5.3 → 0.5.5
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 +1054 -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 +865 -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/commands/loop-command.js +4 -5
- package/dist/commands/tasks-command.js +3 -3
- 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 -154
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -3
- package/dist/loop-reducer.d.ts +7 -0
- package/dist/loop-reducer.js +9 -0
- package/dist/monitor-manager.js +4 -4
- 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 -156
- package/dist/task-reducer.js +3 -0
- package/dist/task-store.d.ts +4 -15
- package/dist/task-store.js +25 -147
- package/dist/tools/loop-tools.js +7 -5
- package/dist/tools/monitor-tools.js +12 -3
- package/dist/tools/native-task-tools.js +2 -2
- package/dist/trigger-system.js +2 -1
- package/dist/ui/widget.js +8 -1
- package/package.json +4 -1
- package/src/commands/loop-command.ts +4 -5
- package/src/commands/tasks-command.ts +3 -3
- package/src/goal-reducer.ts +20 -1
- package/src/goal-store.ts +35 -142
- package/src/index.ts +4 -3
- package/src/loop-reducer.ts +10 -0
- package/src/monitor-manager.ts +2 -3
- package/src/reducer-backed-store.ts +168 -0
- package/src/store.ts +28 -141
- package/src/task-reducer.ts +3 -0
- package/src/task-store.ts +28 -135
- package/src/tools/loop-tools.ts +6 -5
- package/src/tools/monitor-tools.ts +12 -3
- package/src/tools/native-task-tools.ts +2 -2
- package/src/trigger-system.ts +2 -1
- package/src/ui/widget.ts +8 -1
- package/vitest.config.ts +25 -0
- package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/src/task-store.ts
CHANGED
|
@@ -1,127 +1,30 @@
|
|
|
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";
|
|
3
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
4
4
|
import { reduceTaskState, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
|
|
5
5
|
import type { TaskEntry, TaskStoreData } from "./task-types.js";
|
|
6
6
|
|
|
7
7
|
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
8
|
-
const LOCK_RETRY_MS = 50;
|
|
9
|
-
const LOCK_MAX_RETRIES = 100;
|
|
10
8
|
const MAX_TASKS = 200;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
14
|
-
try {
|
|
15
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
16
|
-
return;
|
|
17
|
-
} catch (e: any) {
|
|
18
|
-
if (e.code === "EEXIST") {
|
|
19
|
-
try {
|
|
20
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
21
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
22
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
} catch { /* ignore read errors */ }
|
|
26
|
-
const start = Date.now();
|
|
27
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
throw e;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function releaseLock(lockPath: string): void {
|
|
37
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isProcessRunning(pid: number): boolean {
|
|
41
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export class TaskStore {
|
|
45
|
-
private filePath: string | undefined;
|
|
46
|
-
private lockPath: string | undefined;
|
|
47
|
-
private lastLoadedSignature: string | undefined;
|
|
48
|
-
|
|
49
|
-
private nextId = 1;
|
|
50
|
-
private tasks = new Map<string, TaskEntry>();
|
|
51
|
-
|
|
10
|
+
export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
|
|
52
11
|
constructor(listIdOrPath?: string) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const stat = statSync(this.filePath);
|
|
65
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private load(force = false): void {
|
|
69
|
-
if (!this.filePath) return;
|
|
70
|
-
const signature = this.getFileSignature();
|
|
71
|
-
if (!signature) return;
|
|
72
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
73
|
-
try {
|
|
74
|
-
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
75
|
-
this.nextId = data.nextId;
|
|
76
|
-
this.tasks.clear();
|
|
77
|
-
for (const task of data.tasks) {
|
|
78
|
-
this.tasks.set(task.id, task);
|
|
79
|
-
}
|
|
80
|
-
this.lastLoadedSignature = signature;
|
|
81
|
-
} catch { /* corrupt file — start fresh */ }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private save(): void {
|
|
85
|
-
if (!this.filePath) return;
|
|
86
|
-
const data: TaskStoreData = {
|
|
87
|
-
nextId: this.nextId,
|
|
88
|
-
tasks: Array.from(this.tasks.values()),
|
|
89
|
-
};
|
|
90
|
-
const tmpPath = this.filePath + ".tmp";
|
|
91
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
92
|
-
renameSync(tmpPath, this.filePath);
|
|
93
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private withLock<T>(fn: () => T): T {
|
|
97
|
-
if (!this.lockPath) return fn();
|
|
98
|
-
acquireLock(this.lockPath);
|
|
99
|
-
try {
|
|
100
|
-
this.load(true);
|
|
101
|
-
const result = fn();
|
|
102
|
-
this.save();
|
|
103
|
-
return result;
|
|
104
|
-
} finally {
|
|
105
|
-
releaseLock(this.lockPath);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private toReducerState(): TaskReducerState {
|
|
110
|
-
return {
|
|
111
|
-
nextId: this.nextId,
|
|
112
|
-
tasksById: Object.fromEntries(this.tasks.entries()),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private applyReducerEvent(event: TaskReducerEvent): void {
|
|
117
|
-
const result = reduceTaskState(this.toReducerState(), event);
|
|
118
|
-
this.nextId = result.state.nextId;
|
|
119
|
-
this.tasks = new Map(Object.entries(result.state.tasksById));
|
|
12
|
+
super(
|
|
13
|
+
{
|
|
14
|
+
baseDir: TASKS_DIR,
|
|
15
|
+
reduce: (state, event) => reduceTaskState(state, event),
|
|
16
|
+
toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
|
|
17
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
|
|
18
|
+
serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
|
|
19
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
|
|
20
|
+
},
|
|
21
|
+
listIdOrPath,
|
|
22
|
+
);
|
|
120
23
|
}
|
|
121
24
|
|
|
122
25
|
create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
|
|
123
26
|
return this.withLock(() => {
|
|
124
|
-
if (this.
|
|
27
|
+
if (this.entries.size >= MAX_TASKS) {
|
|
125
28
|
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
126
29
|
}
|
|
127
30
|
const now = Date.now();
|
|
@@ -132,23 +35,13 @@ export class TaskStore {
|
|
|
132
35
|
entityType: "task",
|
|
133
36
|
payload: { subject, description, metadata },
|
|
134
37
|
});
|
|
135
|
-
return this.
|
|
38
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
136
39
|
});
|
|
137
40
|
}
|
|
138
41
|
|
|
139
|
-
get(id: string): TaskEntry | undefined {
|
|
140
|
-
if (this.filePath) this.load();
|
|
141
|
-
return this.tasks.get(id);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
list(): TaskEntry[] {
|
|
145
|
-
if (this.filePath) this.load();
|
|
146
|
-
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
42
|
start(id: string): TaskEntry | undefined {
|
|
150
43
|
return this.withLock(() => {
|
|
151
|
-
const entry = this.
|
|
44
|
+
const entry = this.entries.get(id);
|
|
152
45
|
if (!entry) return undefined;
|
|
153
46
|
this.applyReducerEvent({
|
|
154
47
|
type: "TASK_STARTED",
|
|
@@ -158,13 +51,13 @@ export class TaskStore {
|
|
|
158
51
|
entityId: id,
|
|
159
52
|
payload: { id },
|
|
160
53
|
});
|
|
161
|
-
return this.
|
|
54
|
+
return this.entries.get(id);
|
|
162
55
|
});
|
|
163
56
|
}
|
|
164
57
|
|
|
165
58
|
complete(id: string): TaskEntry | undefined {
|
|
166
59
|
return this.withLock(() => {
|
|
167
|
-
const entry = this.
|
|
60
|
+
const entry = this.entries.get(id);
|
|
168
61
|
if (!entry) return undefined;
|
|
169
62
|
this.applyReducerEvent({
|
|
170
63
|
type: "TASK_COMPLETED",
|
|
@@ -174,13 +67,13 @@ export class TaskStore {
|
|
|
174
67
|
entityId: id,
|
|
175
68
|
payload: { id },
|
|
176
69
|
});
|
|
177
|
-
return this.
|
|
70
|
+
return this.entries.get(id);
|
|
178
71
|
});
|
|
179
72
|
}
|
|
180
73
|
|
|
181
74
|
reopen(id: string): TaskEntry | undefined {
|
|
182
75
|
return this.withLock(() => {
|
|
183
|
-
const entry = this.
|
|
76
|
+
const entry = this.entries.get(id);
|
|
184
77
|
if (!entry) return undefined;
|
|
185
78
|
this.applyReducerEvent({
|
|
186
79
|
type: "TASK_REOPENED",
|
|
@@ -190,13 +83,13 @@ export class TaskStore {
|
|
|
190
83
|
entityId: id,
|
|
191
84
|
payload: { id },
|
|
192
85
|
});
|
|
193
|
-
return this.
|
|
86
|
+
return this.entries.get(id);
|
|
194
87
|
});
|
|
195
88
|
}
|
|
196
89
|
|
|
197
90
|
updateDetails(id: string, fields: { subject?: string; description?: string }): TaskEntry | undefined {
|
|
198
91
|
return this.withLock(() => {
|
|
199
|
-
const entry = this.
|
|
92
|
+
const entry = this.entries.get(id);
|
|
200
93
|
if (!entry) return undefined;
|
|
201
94
|
if (fields.subject === undefined && fields.description === undefined) return entry;
|
|
202
95
|
this.applyReducerEvent({
|
|
@@ -211,13 +104,13 @@ export class TaskStore {
|
|
|
211
104
|
description: fields.description,
|
|
212
105
|
},
|
|
213
106
|
});
|
|
214
|
-
return this.
|
|
107
|
+
return this.entries.get(id);
|
|
215
108
|
});
|
|
216
109
|
}
|
|
217
110
|
|
|
218
111
|
delete(id: string): boolean {
|
|
219
112
|
return this.withLock(() => {
|
|
220
|
-
if (!this.
|
|
113
|
+
if (!this.entries.has(id)) return false;
|
|
221
114
|
this.applyReducerEvent({
|
|
222
115
|
type: "TASK_DELETED",
|
|
223
116
|
at: Date.now(),
|
|
@@ -232,7 +125,7 @@ export class TaskStore {
|
|
|
232
125
|
|
|
233
126
|
pendingCount(): number {
|
|
234
127
|
let count = 0;
|
|
235
|
-
for (const t of this.
|
|
128
|
+
for (const t of this.entries.values()) {
|
|
236
129
|
if (t.status === "pending" || t.status === "in_progress") count++;
|
|
237
130
|
}
|
|
238
131
|
return count;
|
|
@@ -240,7 +133,7 @@ export class TaskStore {
|
|
|
240
133
|
|
|
241
134
|
pruneCompleted(): number {
|
|
242
135
|
return this.withLock(() => {
|
|
243
|
-
const before = this.
|
|
136
|
+
const before = this.entries.size;
|
|
244
137
|
this.applyReducerEvent({
|
|
245
138
|
type: "TASKS_PRUNED",
|
|
246
139
|
at: Date.now(),
|
|
@@ -248,7 +141,7 @@ export class TaskStore {
|
|
|
248
141
|
entityType: "task",
|
|
249
142
|
payload: { reason: "manual" },
|
|
250
143
|
});
|
|
251
|
-
return before - this.
|
|
144
|
+
return before - this.entries.size;
|
|
252
145
|
});
|
|
253
146
|
}
|
|
254
147
|
}
|
package/src/tools/loop-tools.ts
CHANGED
|
@@ -149,7 +149,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
149
149
|
"## Task-driven workflows",
|
|
150
150
|
"Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
|
|
151
151
|
"Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
|
|
152
|
-
"Set taskBacklog: true for
|
|
152
|
+
"Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
|
|
153
153
|
"When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
|
|
154
154
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
155
155
|
],
|
|
@@ -229,10 +229,10 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
229
229
|
`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
230
230
|
`Trigger: ${triggerDesc}\n` +
|
|
231
231
|
`Recurring: ${entry.recurring}\n` +
|
|
232
|
-
(entry.autoTask ? "Auto-task: enabled\n" : "") +
|
|
233
|
-
(entry.taskBacklog ? "
|
|
234
|
-
(bootstrapped ? "
|
|
235
|
-
(isTaskSystemReady() ? "" : "
|
|
232
|
+
(entry.autoTask ? "Auto-create task: enabled\n" : "") +
|
|
233
|
+
(entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
|
|
234
|
+
(bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
|
|
235
|
+
(isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
|
|
236
236
|
`ID: ${entry.id} (use LoopDelete to cancel)`
|
|
237
237
|
));
|
|
238
238
|
},
|
|
@@ -266,6 +266,7 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
266
266
|
line += ` next: ${formatRemaining(remaining)}`;
|
|
267
267
|
}
|
|
268
268
|
if (entry.autoTask) line += " [auto-task]";
|
|
269
|
+
if (entry.taskBacklog) line += " [backlog-worker]";
|
|
269
270
|
lines.push(line);
|
|
270
271
|
}
|
|
271
272
|
|
|
@@ -82,10 +82,19 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
82
82
|
|
|
83
83
|
let onDoneMsg = "";
|
|
84
84
|
if (params.onDone) {
|
|
85
|
+
// onDone delivery is callback-only: the loop below is delivered solely
|
|
86
|
+
// via MonitorManager.onComplete (see handleMonitorDoneLoop →
|
|
87
|
+
// monitor-ondone-runtime), so it fires exactly once by construction.
|
|
88
|
+
// The event-typed trigger is metadata, NOT a live subscription — this
|
|
89
|
+
// loop is deliberately NOT passed to triggerSystem.add(). The "event"
|
|
90
|
+
// type lets expireEventLoops() prune it if orphaned across a session
|
|
91
|
+
// and lets the widget render it as a monitor-completion wake. Do not
|
|
92
|
+
// triggerSystem.add() this loop, or the monitor:done event would fire it
|
|
93
|
+
// a second time.
|
|
85
94
|
const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
|
|
86
95
|
const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
|
|
87
96
|
handleMonitorDoneLoop(doneLoop, entry.id);
|
|
88
|
-
onDoneMsg = `\
|
|
97
|
+
onDoneMsg = `\nCompletion wake loop #${doneLoop.id}: fires when the monitor completes — no polling needed`;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
return Promise.resolve(textResult(
|
|
@@ -103,11 +112,11 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
103
112
|
parameters: Type.Object({}),
|
|
104
113
|
execute() {
|
|
105
114
|
const monitors = getMonitorManager().list();
|
|
106
|
-
if (monitors.length === 0) return Promise.resolve(textResult("No monitors
|
|
115
|
+
if (monitors.length === 0) return Promise.resolve(textResult("No monitors."));
|
|
107
116
|
|
|
108
117
|
const lines: string[] = [];
|
|
109
118
|
for (const m of monitors) {
|
|
110
|
-
const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "
|
|
119
|
+
const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "x";
|
|
111
120
|
const age = Date.now() - m.startedAt;
|
|
112
121
|
const ageStr = formatRemaining(age);
|
|
113
122
|
let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
|
|
@@ -57,7 +57,7 @@ Fields:
|
|
|
57
57
|
updateWidget();
|
|
58
58
|
|
|
59
59
|
const autoLoopMsg = backlog.created && backlog.entry
|
|
60
|
-
? `\
|
|
60
|
+
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
61
61
|
: "";
|
|
62
62
|
return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
|
|
63
63
|
},
|
|
@@ -124,7 +124,7 @@ Parameters: id (required), status, subject, description`,
|
|
|
124
124
|
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
125
125
|
const statusMsg = status ? ` → ${status}` : "";
|
|
126
126
|
const autoLoopMsg = backlog.created && backlog.entry
|
|
127
|
-
? `\
|
|
127
|
+
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
128
128
|
: "";
|
|
129
129
|
return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
|
|
130
130
|
},
|
package/src/trigger-system.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
2
3
|
import type { CronScheduler } from "./scheduler.js";
|
|
3
4
|
import type { LoopStore } from "./store.js";
|
|
4
5
|
import type { LoopEntry } from "./types.js";
|
|
@@ -110,7 +111,7 @@ export class TriggerSystem {
|
|
|
110
111
|
return;
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
if (fresh.recurring &&
|
|
114
|
+
if (fresh.recurring && atMaxFires(fresh)) {
|
|
114
115
|
this.remove(fresh.id);
|
|
115
116
|
this.store.delete(fresh.id);
|
|
116
117
|
return;
|
package/src/ui/widget.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { MonitorManager } from "../monitor-manager.js";
|
|
3
3
|
import type { LoopStore } from "../store.js";
|
|
4
|
+
import type { LoopEntry } from "../types.js";
|
|
4
5
|
|
|
5
6
|
interface TaskSummary {
|
|
6
7
|
count: number;
|
|
@@ -34,7 +35,7 @@ export class LoopWidget {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
private computeStatus(): string | undefined {
|
|
37
|
-
const loops = this.store.list().filter(
|
|
38
|
+
const loops = this.store.list().filter(isStatusVisibleLoop);
|
|
38
39
|
const monitors = this.monitorManager.list();
|
|
39
40
|
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
40
41
|
|
|
@@ -60,3 +61,9 @@ export class LoopWidget {
|
|
|
60
61
|
function formatCount(count: number, noun: string): string {
|
|
61
62
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
function isStatusVisibleLoop(loop: LoopEntry): boolean {
|
|
66
|
+
if (loop.status !== "active") return false;
|
|
67
|
+
if (loop.recurring) return true;
|
|
68
|
+
return !(loop.trigger.type === "event" && loop.trigger.source === "monitor:done");
|
|
69
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: "node",
|
|
6
|
+
include: ["test/**/*.test.ts"],
|
|
7
|
+
exclude: ["node_modules", "dist"],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: "v8",
|
|
10
|
+
reporter: ["text-summary", "html", "json"],
|
|
11
|
+
include: ["src/**/*.ts"],
|
|
12
|
+
// Type-only modules and test helpers carry no executable logic worth gating.
|
|
13
|
+
exclude: ["src/**/*-types.ts", "src/types.ts"],
|
|
14
|
+
// Floors set just below current actuals (stmts 80%, branches 69.5%,
|
|
15
|
+
// funcs 92.5%, lines 82.9%) to catch regressions. Ratchet up as the
|
|
16
|
+
// runtime/ and tools/ suites land in Phase 4.
|
|
17
|
+
thresholds: {
|
|
18
|
+
statements: 78,
|
|
19
|
+
branches: 67,
|
|
20
|
+
functions: 90,
|
|
21
|
+
lines: 80,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# Differential Review Report
|
|
2
|
-
|
|
3
|
-
## Update — 2026-06-02
|
|
4
|
-
|
|
5
|
-
### Resolved audit finding
|
|
6
|
-
- **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
|
|
7
|
-
- **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
|
|
8
|
-
- **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
|
|
9
|
-
|
|
10
|
-
### Regression coverage added
|
|
11
|
-
- `test/trigger-system.test.ts`
|
|
12
|
-
- recurring `event` loop is deleted immediately at final `maxFires`
|
|
13
|
-
- recurring `hybrid` loop is deleted immediately at final `maxFires`
|
|
14
|
-
- hybrid cleanup also clears scheduled cron state
|
|
15
|
-
- `test/index.test.ts`
|
|
16
|
-
- extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
|
|
17
|
-
|
|
18
|
-
### Validation status
|
|
19
|
-
- `npm run lint` ✅
|
|
20
|
-
- `npm run typecheck` ✅
|
|
21
|
-
- `npm run test` ✅
|
|
22
|
-
- `npm run build` ✅
|
|
23
|
-
- Current suite: **112 passing tests**
|
|
24
|
-
|
|
25
|
-
## Scope
|
|
26
|
-
Reviewed recent uncommitted changes in:
|
|
27
|
-
- `src/index.ts`
|
|
28
|
-
- `src/ui/widget.ts`
|
|
29
|
-
- `test/index.test.ts`
|
|
30
|
-
- `test/widget.test.ts`
|
|
31
|
-
- `README.md`
|
|
32
|
-
|
|
33
|
-
## Risk Summary
|
|
34
|
-
- **Overall risk:** Medium
|
|
35
|
-
- **Primary areas affected:** runtime task fallback routing, UI/widget behavior, interactive command registration
|
|
36
|
-
- **Security impact:** Low direct security impact; main risks are state-management regressions and missing coverage around command behavior
|
|
37
|
-
|
|
38
|
-
## Findings
|
|
39
|
-
|
|
40
|
-
### 🟡 Warning
|
|
41
|
-
`src/index.ts` - Native task fallback is covered for registration and file persistence, but not for interactive `/tasks` flows or loop-driven task lifecycle integration.
|
|
42
|
-
|
|
43
|
-
**Why it matters:**
|
|
44
|
-
The new `/tasks` command and interactive actions (`Start`, `Complete`, `Reopen`, `Delete`) are stateful and user-facing. Regressions here would not be caught by current tests. Similarly, native fallback behavior for `autoTask`, `hasPendingTasks()`, and `cleanDoneTasks()` is only indirectly covered.
|
|
45
|
-
|
|
46
|
-
**Recommended follow-up:**
|
|
47
|
-
Add tests for:
|
|
48
|
-
- `/tasks` command registration and quick-create path
|
|
49
|
-
- native `autoTask` creation path
|
|
50
|
-
- `hasPendingTasks()` using native fallback
|
|
51
|
-
- completed-task sweep behavior for native tasks
|
|
52
|
-
|
|
53
|
-
### 🟢 Suggestion
|
|
54
|
-
`src/ui/widget.ts` - Compact widget behavior is appropriately simplified and focus-oriented.
|
|
55
|
-
|
|
56
|
-
**Good pattern:**
|
|
57
|
-
The single-line status approach reduces noise and matches the intended UX. Showing only active/next task focus text is a good constraint.
|
|
58
|
-
|
|
59
|
-
## Test Coverage Review
|
|
60
|
-
|
|
61
|
-
### Covered well
|
|
62
|
-
- Native tool registration when `pi-tasks` is absent/present
|
|
63
|
-
- Native task persistence path (`.pi/tasks/tasks.json`)
|
|
64
|
-
- Compact widget states:
|
|
65
|
-
- `none`
|
|
66
|
-
- monitor-only count
|
|
67
|
-
- loop + monitor count
|
|
68
|
-
- active task focus text
|
|
69
|
-
- next task focus text
|
|
70
|
-
- retained widget rendering after content clears
|
|
71
|
-
|
|
72
|
-
### Coverage gaps
|
|
73
|
-
- No tests for `/tasks` command behavior beyond registration
|
|
74
|
-
- No direct tests for native `TaskUpdate` → widget focus transitions
|
|
75
|
-
- No direct tests for native `cleanDoneTasks()` sweep behavior
|
|
76
|
-
- No end-to-end tests for `LoopCreate(autoTask: true)` using native fallback
|
|
77
|
-
|
|
78
|
-
## Review Verdict
|
|
79
|
-
- **No merge-blocking issues found**
|
|
80
|
-
- Safe to release after version bump and validation
|
|
81
|
-
- Recommended follow-up: add native task lifecycle command/integration tests in a later pass
|