@trevonistrevon/pi-loop 0.6.0 → 0.6.2
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/CHANGELOG.md +22 -0
- package/README.md +28 -143
- package/dist/commands/loop-command.d.ts +10 -2
- package/dist/commands/loop-command.js +84 -51
- package/dist/index.d.ts +2 -1
- package/dist/index.js +44 -16
- package/dist/loop-format.d.ts +3 -0
- package/dist/loop-format.js +20 -0
- package/dist/loop-parse.d.ts +1 -0
- package/dist/loop-parse.js +45 -1
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.d.ts +1 -1
- package/dist/monitor-manager.js +13 -4
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +19 -7
- package/dist/runtime/task-backlog-runtime.d.ts +2 -1
- package/dist/runtime/task-backlog-runtime.js +13 -9
- package/dist/scheduler.js +25 -7
- package/dist/store.d.ts +9 -1
- package/dist/store.js +57 -0
- package/dist/tools/loop-tools.d.ts +8 -0
- package/dist/tools/loop-tools.js +104 -12
- package/dist/tools/monitor-tools.js +1 -1
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/docs/USAGE_GUIDE.md +185 -0
- package/package.json +13 -4
- package/src/commands/loop-command.ts +102 -54
- package/src/index.ts +46 -16
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +44 -1
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +12 -4
- package/src/notification-reducer.ts +3 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +15 -11
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +128 -12
- package/src/tools/monitor-tools.ts +1 -1
- package/src/trigger-system.ts +1 -1
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -123
- package/CONTRIBUTING.md +0 -79
- package/Dockerfile +0 -17
- package/benchmarks/experiment-sim.js +0 -30
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -36
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -191
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/api.ts.html +0 -193
- package/coverage/src/commands/index.html +0 -131
- package/coverage/src/commands/loop-command.ts.html +0 -634
- package/coverage/src/commands/tasks-command.ts.html +0 -490
- package/coverage/src/coordinator.ts.html +0 -430
- package/coverage/src/index.html +0 -341
- package/coverage/src/index.ts.html +0 -1249
- package/coverage/src/loop-parse.ts.html +0 -607
- package/coverage/src/loop-reducer.ts.html +0 -559
- package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
- package/coverage/src/monitor-manager.ts.html +0 -964
- package/coverage/src/monitor-reducer.ts.html +0 -583
- package/coverage/src/notification-reducer.ts.html +0 -550
- package/coverage/src/reducer-backed-store.ts.html +0 -589
- package/coverage/src/rpc/channels.ts.html +0 -376
- package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
- package/coverage/src/rpc/index.html +0 -131
- package/coverage/src/runtime/index.html +0 -251
- package/coverage/src/runtime/loop-events.ts.html +0 -265
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
- package/coverage/src/runtime/notification-runtime.ts.html +0 -721
- package/coverage/src/runtime/scope.ts.html +0 -196
- package/coverage/src/runtime/session-runtime.ts.html +0 -679
- package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-mutations.ts.html +0 -394
- package/coverage/src/runtime/task-rpc.ts.html +0 -523
- package/coverage/src/scheduler.ts.html +0 -400
- package/coverage/src/store.ts.html +0 -667
- package/coverage/src/task-backlog-coordinator.ts.html +0 -181
- package/coverage/src/task-reducer.ts.html +0 -550
- package/coverage/src/task-store.ts.html +0 -526
- package/coverage/src/tools/index.html +0 -161
- package/coverage/src/tools/loop-tools.ts.html +0 -991
- package/coverage/src/tools/monitor-tools.ts.html +0 -538
- package/coverage/src/tools/native-task-tools.ts.html +0 -493
- package/coverage/src/tools/tool-result.ts.html +0 -97
- package/coverage/src/trigger-system.ts.html +0 -547
- package/coverage/src/ui/index.html +0 -116
- package/coverage/src/ui/widget.ts.html +0 -292
- package/docker-compose.yml +0 -12
- package/docs/architecture/state-machine-migration.md +0 -502
- package/docs/architecture/state-machine-reducer-event-model.md +0 -822
- package/docs/architecture/state-machine-test-matrix.md +0 -249
- package/docs/architecture/state-machine-transition-map.md +0 -436
- package/release-please-config.json +0 -9
- package/vitest.config.ts +0 -29
package/dist/loop-parse.js
CHANGED
|
@@ -50,9 +50,53 @@ function isFullCron(expr) {
|
|
|
50
50
|
const parts = expr.trim().split(/\s+/);
|
|
51
51
|
return parts.length === 5;
|
|
52
52
|
}
|
|
53
|
+
function parseCronNumber(input, min, max) {
|
|
54
|
+
if (!/^\d+$/.test(input))
|
|
55
|
+
return undefined;
|
|
56
|
+
const value = Number.parseInt(input, 10);
|
|
57
|
+
return value >= min && value <= max ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
function isValidCronField(field, min, max) {
|
|
60
|
+
return field.split(",").every((part) => {
|
|
61
|
+
const [base, step, extra] = part.split("/");
|
|
62
|
+
if (extra !== undefined || base === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (step !== undefined && (parseCronNumber(step, 1, max - min + 1) === undefined))
|
|
65
|
+
return false;
|
|
66
|
+
if (base === "*")
|
|
67
|
+
return true;
|
|
68
|
+
const range = base.split("-");
|
|
69
|
+
if (range.length === 1)
|
|
70
|
+
return step === undefined && parseCronNumber(base, min, max) !== undefined;
|
|
71
|
+
if (range.length !== 2)
|
|
72
|
+
return false;
|
|
73
|
+
const start = parseCronNumber(range[0] ?? "", min, max);
|
|
74
|
+
const end = parseCronNumber(range[1] ?? "", min, max);
|
|
75
|
+
return start !== undefined && end !== undefined && start <= end;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function isValidCronExpression(expr) {
|
|
79
|
+
const fields = expr.trim().split(/\s+/);
|
|
80
|
+
if (fields.length !== 5)
|
|
81
|
+
return false;
|
|
82
|
+
const ranges = [
|
|
83
|
+
[0, 59],
|
|
84
|
+
[0, 23],
|
|
85
|
+
[1, 31],
|
|
86
|
+
[1, 12],
|
|
87
|
+
[0, 6],
|
|
88
|
+
];
|
|
89
|
+
return fields.every((field, index) => {
|
|
90
|
+
const range = ranges[index];
|
|
91
|
+
return range !== undefined && isValidCronField(field, range[0], range[1]);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
53
94
|
export function parseInterval(input) {
|
|
54
95
|
const trimmed = input.trim();
|
|
55
96
|
if (isFullCron(trimmed)) {
|
|
97
|
+
if (!isValidCronExpression(trimmed)) {
|
|
98
|
+
throw new Error(`Invalid cron expression: ${trimmed}`);
|
|
99
|
+
}
|
|
56
100
|
return { cron: trimmed, description: `cron: ${trimmed}` };
|
|
57
101
|
}
|
|
58
102
|
const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
@@ -69,7 +113,7 @@ export function parseInterval(input) {
|
|
|
69
113
|
}
|
|
70
114
|
export function cronToNextFire(cronExpr, fromDate = new Date()) {
|
|
71
115
|
const parts = cronExpr.trim().split(/\s+/);
|
|
72
|
-
if (
|
|
116
|
+
if (!isValidCronExpression(cronExpr))
|
|
73
117
|
throw new Error(`Invalid cron expression: ${cronExpr}`);
|
|
74
118
|
const [minF, hourF, dayF, monthF, dowF] = parts;
|
|
75
119
|
if (minF === undefined ||
|
package/dist/loop-reducer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LoopEntry, Trigger } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
|
|
2
2
|
export declare const MAX_LOOP_EXPIRY_MS: number;
|
|
3
3
|
/**
|
|
4
4
|
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
@@ -26,6 +26,7 @@ export type LoopReducerEvent = {
|
|
|
26
26
|
taskBacklog?: boolean;
|
|
27
27
|
readOnly?: boolean;
|
|
28
28
|
maxFires?: number;
|
|
29
|
+
dynamic?: Partial<DynamicLoopState>;
|
|
29
30
|
};
|
|
30
31
|
} | {
|
|
31
32
|
type: "LOOP_PAUSED" | "LOOP_RESUMED" | "LOOP_FIRED" | "LOOP_DELETED" | "LOOP_MAX_FIRES_REACHED" | "LOOP_BACKLOG_EMPTY";
|
|
@@ -46,6 +47,17 @@ export type LoopReducerEvent = {
|
|
|
46
47
|
id: string;
|
|
47
48
|
reason: "expires_at" | "resume_event_stale" | "already_completed_monitor";
|
|
48
49
|
};
|
|
50
|
+
} | {
|
|
51
|
+
type: "LOOP_DYNAMIC_UPDATED";
|
|
52
|
+
at: number;
|
|
53
|
+
source: ReducerSource;
|
|
54
|
+
entityType?: "loop";
|
|
55
|
+
entityId?: string;
|
|
56
|
+
payload: {
|
|
57
|
+
id: string;
|
|
58
|
+
prompt?: string;
|
|
59
|
+
dynamic: Partial<DynamicLoopState>;
|
|
60
|
+
};
|
|
49
61
|
};
|
|
50
62
|
export type LoopReducerEffect = {
|
|
51
63
|
type: "PERSIST_LOOP";
|
package/dist/loop-reducer.js
CHANGED
|
@@ -32,6 +32,18 @@ export function reduceLoopState(state, event) {
|
|
|
32
32
|
readOnly: event.payload.readOnly,
|
|
33
33
|
maxFires: event.payload.maxFires,
|
|
34
34
|
fireCount: 0,
|
|
35
|
+
dynamic: event.payload.trigger.type === "dynamic" || event.payload.dynamic
|
|
36
|
+
? {
|
|
37
|
+
goal: event.payload.dynamic?.goal ?? event.payload.prompt,
|
|
38
|
+
state: event.payload.dynamic?.state,
|
|
39
|
+
metrics: event.payload.dynamic?.metrics,
|
|
40
|
+
doneCriteria: event.payload.dynamic?.doneCriteria,
|
|
41
|
+
iteration: event.payload.dynamic?.iteration ?? 0,
|
|
42
|
+
nextWakeAt: event.payload.dynamic?.nextWakeAt,
|
|
43
|
+
awaitingUpdate: event.payload.dynamic?.awaitingUpdate ?? false,
|
|
44
|
+
lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
|
|
45
|
+
}
|
|
46
|
+
: undefined,
|
|
35
47
|
};
|
|
36
48
|
next.loopsById[id] = loop;
|
|
37
49
|
return {
|
|
@@ -68,6 +80,20 @@ export function reduceLoopState(state, event) {
|
|
|
68
80
|
loop.fireCount = (loop.fireCount ?? 0) + 1;
|
|
69
81
|
loop.updatedAt = event.at;
|
|
70
82
|
}
|
|
83
|
+
if (event.type === "LOOP_DYNAMIC_UPDATED") {
|
|
84
|
+
loop.prompt = event.payload.prompt ?? loop.prompt;
|
|
85
|
+
loop.dynamic = {
|
|
86
|
+
goal: event.payload.dynamic.goal ?? loop.dynamic?.goal ?? loop.prompt,
|
|
87
|
+
state: event.payload.dynamic.state ?? loop.dynamic?.state,
|
|
88
|
+
metrics: event.payload.dynamic.metrics ?? loop.dynamic?.metrics,
|
|
89
|
+
doneCriteria: event.payload.dynamic.doneCriteria ?? loop.dynamic?.doneCriteria,
|
|
90
|
+
iteration: event.payload.dynamic.iteration ?? loop.dynamic?.iteration ?? 0,
|
|
91
|
+
nextWakeAt: "nextWakeAt" in event.payload.dynamic ? event.payload.dynamic.nextWakeAt : loop.dynamic?.nextWakeAt,
|
|
92
|
+
awaitingUpdate: event.payload.dynamic.awaitingUpdate ?? loop.dynamic?.awaitingUpdate ?? false,
|
|
93
|
+
lastUpdatedAt: event.payload.dynamic.lastUpdatedAt ?? event.at,
|
|
94
|
+
};
|
|
95
|
+
loop.updatedAt = event.at;
|
|
96
|
+
}
|
|
71
97
|
next.loopsById[id] = loop;
|
|
72
98
|
return {
|
|
73
99
|
state: next,
|
|
@@ -22,7 +22,7 @@ export declare class MonitorManager {
|
|
|
22
22
|
create(command: string, description?: string, timeout?: number): MonitorEntry;
|
|
23
23
|
get(id: string): MonitorEntry | undefined;
|
|
24
24
|
list(): MonitorEntry[];
|
|
25
|
-
stop(id: string): Promise<boolean>;
|
|
25
|
+
stop(id: string, reason?: "manual" | "timeout"): Promise<boolean>;
|
|
26
26
|
onComplete(id: string, callback: () => void): boolean;
|
|
27
27
|
getProcess(id: string): MonitorProcess | undefined;
|
|
28
28
|
}
|
package/dist/monitor-manager.js
CHANGED
|
@@ -190,7 +190,7 @@ export class MonitorManager {
|
|
|
190
190
|
if (timeout > 0) {
|
|
191
191
|
setTimeout(() => {
|
|
192
192
|
if (bp.entry.status === "running") {
|
|
193
|
-
this.stop(id);
|
|
193
|
+
void this.stop(id, "timeout");
|
|
194
194
|
}
|
|
195
195
|
}, timeout);
|
|
196
196
|
}
|
|
@@ -206,7 +206,7 @@ export class MonitorManager {
|
|
|
206
206
|
.map(bp => bp.entry)
|
|
207
207
|
.sort((a, b) => Number(a.id) - Number(b.id));
|
|
208
208
|
}
|
|
209
|
-
async stop(id) {
|
|
209
|
+
async stop(id, reason = "manual") {
|
|
210
210
|
const bp = this.processes.get(id);
|
|
211
211
|
if (!bp || bp.entry.status !== "running")
|
|
212
212
|
return false;
|
|
@@ -218,7 +218,7 @@ export class MonitorManager {
|
|
|
218
218
|
entityId: id,
|
|
219
219
|
payload: {
|
|
220
220
|
id,
|
|
221
|
-
reason
|
|
221
|
+
reason,
|
|
222
222
|
},
|
|
223
223
|
});
|
|
224
224
|
this.schedulePrune(id);
|
|
@@ -236,6 +236,15 @@ export class MonitorManager {
|
|
|
236
236
|
resolve();
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
|
+
if (reason === "timeout") {
|
|
240
|
+
this.pi.events.emit("monitor:error", {
|
|
241
|
+
monitorId: id,
|
|
242
|
+
error: `Timed out after ${bp.entry.timeout}ms`,
|
|
243
|
+
outputLines: bp.entry.outputLines,
|
|
244
|
+
});
|
|
245
|
+
for (const callback of bp.completionCallbacks)
|
|
246
|
+
callback();
|
|
247
|
+
}
|
|
239
248
|
bp.completionCallbacks = [];
|
|
240
249
|
for (const resolve of bp.waiters)
|
|
241
250
|
resolve();
|
|
@@ -246,7 +255,7 @@ export class MonitorManager {
|
|
|
246
255
|
const bp = this.processes.get(id);
|
|
247
256
|
if (!bp)
|
|
248
257
|
return false;
|
|
249
|
-
if (bp.entry.status === "completed") {
|
|
258
|
+
if (bp.entry.status === "completed" || bp.entry.status === "error") {
|
|
250
259
|
callback();
|
|
251
260
|
return true;
|
|
252
261
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DynamicLoopState } from "./types.js";
|
|
1
2
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
2
3
|
export interface ReducerNotification {
|
|
3
4
|
key: string;
|
|
@@ -8,6 +9,7 @@ export interface ReducerNotification {
|
|
|
8
9
|
recurring?: boolean;
|
|
9
10
|
autoTask?: boolean;
|
|
10
11
|
readOnly?: boolean;
|
|
12
|
+
dynamic?: DynamicLoopState;
|
|
11
13
|
}
|
|
12
14
|
export interface NotificationReducerState {
|
|
13
15
|
notificationsByKey: Record<string, ReducerNotification>;
|
|
@@ -37,7 +37,7 @@ export function createMonitorOnDoneRuntime(options) {
|
|
|
37
37
|
return;
|
|
38
38
|
const monitor = monitorManager.get(monitorId);
|
|
39
39
|
if (monitor && monitor.status !== "running") {
|
|
40
|
-
if (monitor.status === "completed") {
|
|
40
|
+
if (monitor.status === "completed" || monitor.status === "error") {
|
|
41
41
|
deliver();
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Trigger } from "../types.js";
|
|
2
|
+
import type { DynamicLoopState, Trigger } from "../types.js";
|
|
3
3
|
export interface LoopFireEvent {
|
|
4
4
|
loopId: string;
|
|
5
5
|
prompt: string;
|
|
@@ -8,6 +8,7 @@ export interface LoopFireEvent {
|
|
|
8
8
|
readOnly?: boolean;
|
|
9
9
|
recurring?: boolean;
|
|
10
10
|
autoTask?: boolean;
|
|
11
|
+
dynamic?: DynamicLoopState;
|
|
11
12
|
}
|
|
12
13
|
export interface PendingNotification extends LoopFireEvent {
|
|
13
14
|
key: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCoordinator, } from "../coordinator.js";
|
|
2
|
+
import { formatTrigger } from "../loop-format.js";
|
|
2
3
|
import { reduceNotificationState, } from "../notification-reducer.js";
|
|
3
4
|
export function createNotificationRuntime(options) {
|
|
4
5
|
const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
|
|
@@ -43,18 +44,28 @@ export function createNotificationRuntime(options) {
|
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
function buildLoopFireMessage(data) {
|
|
46
|
-
const triggerInfo =
|
|
47
|
-
? data.trigger
|
|
48
|
-
: data.trigger?.type === "cron"
|
|
49
|
-
? `schedule: ${data.trigger.schedule}`
|
|
50
|
-
: data.trigger?.type === "event"
|
|
51
|
-
? `event: ${data.trigger.source}`
|
|
52
|
-
: "hybrid";
|
|
47
|
+
const triggerInfo = formatTrigger(data.trigger, "notification");
|
|
53
48
|
const loopId = data.loopId || "?";
|
|
54
49
|
const prompt = data.prompt || "loop fired";
|
|
55
50
|
const constraint = data.readOnly
|
|
56
51
|
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
57
52
|
: "";
|
|
53
|
+
if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
|
|
54
|
+
const dynamic = data.dynamic;
|
|
55
|
+
const lines = [
|
|
56
|
+
`[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
|
|
57
|
+
`Goal: ${dynamic?.goal ?? prompt}`,
|
|
58
|
+
`Iteration: ${dynamic?.iteration ?? 0}`,
|
|
59
|
+
];
|
|
60
|
+
if (dynamic?.state)
|
|
61
|
+
lines.push(`State: ${dynamic.state}`);
|
|
62
|
+
if (dynamic?.metrics)
|
|
63
|
+
lines.push(`Metrics: ${dynamic.metrics}`);
|
|
64
|
+
if (dynamic?.doneCriteria)
|
|
65
|
+
lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
66
|
+
lines.push("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
58
69
|
return [
|
|
59
70
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
60
71
|
prompt,
|
|
@@ -88,6 +99,7 @@ export function createNotificationRuntime(options) {
|
|
|
88
99
|
recurring: notification.recurring,
|
|
89
100
|
readOnly: notification.readOnly,
|
|
90
101
|
autoTask: notification.autoTask,
|
|
102
|
+
dynamic: notification.dynamic,
|
|
91
103
|
timestamp: notification.timestamp,
|
|
92
104
|
},
|
|
93
105
|
}, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskStore } from "../task-store.js";
|
|
2
|
-
import type { LoopEntry, Trigger } from "../types.js";
|
|
2
|
+
import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
|
|
3
3
|
import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
|
|
4
4
|
export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
5
|
export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
@@ -11,6 +11,7 @@ export interface TaskBacklogRuntimeOptions {
|
|
|
11
11
|
maxFires?: number;
|
|
12
12
|
}) => LoopEntry;
|
|
13
13
|
deleteLoop: (id: string) => void;
|
|
14
|
+
recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
|
|
14
15
|
addTrigger: (entry: LoopEntry) => void;
|
|
15
16
|
removeTrigger: (id: string) => void;
|
|
16
17
|
updateWidget: () => void;
|
|
@@ -4,7 +4,7 @@ import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./lo
|
|
|
4
4
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
5
|
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
6
6
|
export function createTaskBacklogRuntime(options) {
|
|
7
|
-
const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
7
|
+
const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
8
8
|
function isAutoTaskWorkerLoop(entry) {
|
|
9
9
|
return entry.status === "active"
|
|
10
10
|
&& entry.prompt === AUTO_TASK_WORKER_PROMPT
|
|
@@ -18,6 +18,13 @@ export function createTaskBacklogRuntime(options) {
|
|
|
18
18
|
function findAutoTaskWorkerLoop() {
|
|
19
19
|
return getLoops().find(isAutoTaskWorkerLoop);
|
|
20
20
|
}
|
|
21
|
+
function deleteTaskBacklogLoop(entry, pendingCount) {
|
|
22
|
+
debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
23
|
+
removeTrigger(entry.id);
|
|
24
|
+
recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
|
|
25
|
+
deleteLoop(entry.id);
|
|
26
|
+
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
|
|
27
|
+
}
|
|
21
28
|
async function cleanupTaskBacklogLoops() {
|
|
22
29
|
const backlogLoops = getLoops().filter(isTaskBacklogLoop);
|
|
23
30
|
if (backlogLoops.length === 0)
|
|
@@ -25,13 +32,10 @@ export function createTaskBacklogRuntime(options) {
|
|
|
25
32
|
const pending = await hasPendingTasks();
|
|
26
33
|
if (pending < 0 || pending > 0)
|
|
27
34
|
return 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
deleteLoop(entry.id);
|
|
33
|
-
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
|
|
34
|
-
}
|
|
35
|
+
const deletedLoopIds = backlogLoops.map((entry) => entry.id);
|
|
36
|
+
emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
|
|
37
|
+
for (const entry of backlogLoops)
|
|
38
|
+
deleteTaskBacklogLoop(entry, pending);
|
|
35
39
|
updateWidget();
|
|
36
40
|
return backlogLoops.length;
|
|
37
41
|
}
|
|
@@ -43,7 +47,7 @@ export function createTaskBacklogRuntime(options) {
|
|
|
43
47
|
return { entry: existing, created: false };
|
|
44
48
|
const trigger = {
|
|
45
49
|
type: "hybrid",
|
|
46
|
-
cron: "*/
|
|
50
|
+
cron: "*/3 * * * *",
|
|
47
51
|
event: { source: "tasks:created" },
|
|
48
52
|
debounceMs: 30000,
|
|
49
53
|
};
|
package/dist/scheduler.js
CHANGED
|
@@ -4,6 +4,9 @@ function computeNextFire(entry) {
|
|
|
4
4
|
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
5
5
|
return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
|
|
6
6
|
}
|
|
7
|
+
if (entry.trigger.type === "dynamic") {
|
|
8
|
+
return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
|
|
9
|
+
}
|
|
7
10
|
return new Date(Date.now() + 60000);
|
|
8
11
|
}
|
|
9
12
|
export class CronScheduler {
|
|
@@ -15,10 +18,20 @@ export class CronScheduler {
|
|
|
15
18
|
this.onFire = onFire;
|
|
16
19
|
}
|
|
17
20
|
start() {
|
|
18
|
-
for (const
|
|
21
|
+
for (const storedEntry of this.store.list()) {
|
|
22
|
+
let entry = storedEntry;
|
|
19
23
|
if (entry.status !== "active")
|
|
20
24
|
continue;
|
|
21
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
25
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
26
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
|
|
27
|
+
entry = this.store.updateDynamic(entry.id, {
|
|
28
|
+
dynamic: {
|
|
29
|
+
awaitingUpdate: false,
|
|
30
|
+
nextWakeAt: undefined,
|
|
31
|
+
lastUpdatedAt: Date.now(),
|
|
32
|
+
},
|
|
33
|
+
}) ?? entry;
|
|
34
|
+
}
|
|
22
35
|
this.armTimer(entry);
|
|
23
36
|
}
|
|
24
37
|
}
|
|
@@ -27,7 +40,7 @@ export class CronScheduler {
|
|
|
27
40
|
this.fireTimes.clear();
|
|
28
41
|
}
|
|
29
42
|
add(entry) {
|
|
30
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
43
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
31
44
|
this.armTimer(entry);
|
|
32
45
|
}
|
|
33
46
|
}
|
|
@@ -38,11 +51,14 @@ export class CronScheduler {
|
|
|
38
51
|
return this.fireTimes.get(id);
|
|
39
52
|
}
|
|
40
53
|
armTimer(entry) {
|
|
41
|
-
const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
42
54
|
const nextFire = computeNextFire(entry);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
let jitter = 0;
|
|
56
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
57
|
+
const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
58
|
+
const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
|
|
59
|
+
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
60
|
+
jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
61
|
+
}
|
|
46
62
|
const fireTime = nextFire.getTime() + jitter;
|
|
47
63
|
if (fireTime > entry.expiresAt) {
|
|
48
64
|
this.store.delete(entry.id);
|
|
@@ -59,6 +75,8 @@ export class CronScheduler {
|
|
|
59
75
|
this.fireTimes.delete(id);
|
|
60
76
|
continue;
|
|
61
77
|
}
|
|
78
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate)
|
|
79
|
+
continue;
|
|
62
80
|
if (filter && !filter(entry))
|
|
63
81
|
continue;
|
|
64
82
|
if (now >= entry.expiresAt) {
|
package/dist/store.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
|
|
2
2
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
3
|
-
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
3
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
4
4
|
export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
5
|
+
private tombstones;
|
|
5
6
|
constructor(listIdOrPath?: string);
|
|
6
7
|
create(trigger: Trigger, prompt: string, opts: {
|
|
7
8
|
recurring: boolean;
|
|
@@ -9,6 +10,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
9
10
|
taskBacklog?: boolean;
|
|
10
11
|
readOnly?: boolean;
|
|
11
12
|
maxFires?: number;
|
|
13
|
+
dynamic?: Partial<DynamicLoopState>;
|
|
12
14
|
}): LoopEntry;
|
|
13
15
|
pause(id: string): LoopEntry | undefined;
|
|
14
16
|
resume(id: string): LoopEntry | undefined;
|
|
@@ -20,6 +22,12 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
20
22
|
entry: LoopEntry | undefined;
|
|
21
23
|
changedFields: string[];
|
|
22
24
|
};
|
|
25
|
+
updateDynamic(id: string, fields: {
|
|
26
|
+
prompt?: string;
|
|
27
|
+
dynamic: Partial<DynamicLoopState>;
|
|
28
|
+
}): LoopEntry | undefined;
|
|
29
|
+
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
|
|
30
|
+
recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
|
|
23
31
|
delete(id: string): boolean;
|
|
24
32
|
clearExpired(): number;
|
|
25
33
|
expireEventLoops(sessionStartedAt: number): number;
|
package/dist/store.js
CHANGED
|
@@ -4,7 +4,9 @@ import { reduceLoopState } from "./loop-reducer.js";
|
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
5
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
6
6
|
const MAX_LOOPS = 25;
|
|
7
|
+
const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
7
8
|
export class LoopStore extends ReducerBackedStore {
|
|
9
|
+
tombstones = new Map();
|
|
8
10
|
constructor(listIdOrPath) {
|
|
9
11
|
super({
|
|
10
12
|
baseDir: LOOPS_DIR,
|
|
@@ -34,6 +36,7 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
34
36
|
taskBacklog: opts.taskBacklog,
|
|
35
37
|
readOnly: opts.readOnly,
|
|
36
38
|
maxFires: opts.maxFires,
|
|
39
|
+
dynamic: opts.dynamic,
|
|
37
40
|
},
|
|
38
41
|
});
|
|
39
42
|
return this.entries.get(String(this.nextId - 1));
|
|
@@ -68,6 +71,22 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
68
71
|
entityId: id,
|
|
69
72
|
payload: { id },
|
|
70
73
|
});
|
|
74
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
|
|
75
|
+
this.applyReducerEvent({
|
|
76
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
77
|
+
at: Date.now(),
|
|
78
|
+
source: "tool",
|
|
79
|
+
entityType: "loop",
|
|
80
|
+
entityId: id,
|
|
81
|
+
payload: {
|
|
82
|
+
id,
|
|
83
|
+
dynamic: {
|
|
84
|
+
awaitingUpdate: false,
|
|
85
|
+
lastUpdatedAt: Date.now(),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
71
90
|
return this.entries.get(id);
|
|
72
91
|
});
|
|
73
92
|
}
|
|
@@ -108,6 +127,44 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
108
127
|
return { entry: this.entries.get(id), changedFields };
|
|
109
128
|
});
|
|
110
129
|
}
|
|
130
|
+
updateDynamic(id, fields) {
|
|
131
|
+
return this.withLock(() => {
|
|
132
|
+
if (!this.entries.has(id))
|
|
133
|
+
return undefined;
|
|
134
|
+
this.applyReducerEvent({
|
|
135
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
136
|
+
at: Date.now(),
|
|
137
|
+
source: "tool",
|
|
138
|
+
entityType: "loop",
|
|
139
|
+
entityId: id,
|
|
140
|
+
payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
|
|
141
|
+
});
|
|
142
|
+
return this.entries.get(id);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
getDeletionTombstone(id) {
|
|
146
|
+
const tombstone = this.tombstones.get(id);
|
|
147
|
+
if (!tombstone)
|
|
148
|
+
return undefined;
|
|
149
|
+
if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS)
|
|
150
|
+
return tombstone;
|
|
151
|
+
this.tombstones.delete(id);
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
recordDeletionTombstone(id, input) {
|
|
155
|
+
const entry = this.entries.get(id);
|
|
156
|
+
if (!entry)
|
|
157
|
+
return undefined;
|
|
158
|
+
const tombstone = {
|
|
159
|
+
id,
|
|
160
|
+
reason: input.reason,
|
|
161
|
+
pendingCount: input.pendingCount,
|
|
162
|
+
deletedAt: Date.now(),
|
|
163
|
+
prompt: entry.prompt,
|
|
164
|
+
};
|
|
165
|
+
this.tombstones.set(id, tombstone);
|
|
166
|
+
return tombstone;
|
|
167
|
+
}
|
|
111
168
|
delete(id) {
|
|
112
169
|
return this.withLock(() => {
|
|
113
170
|
if (!this.entries.has(id))
|
|
@@ -11,6 +11,14 @@ interface LoopStoreLike {
|
|
|
11
11
|
maxFires?: number;
|
|
12
12
|
}): LoopEntry;
|
|
13
13
|
pause(id: string): LoopEntry | undefined;
|
|
14
|
+
updateDynamic(id: string, fields: {
|
|
15
|
+
prompt?: string;
|
|
16
|
+
dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
|
|
17
|
+
}): LoopEntry | undefined;
|
|
18
|
+
getDeletionTombstone(id: string): {
|
|
19
|
+
reason: string;
|
|
20
|
+
pendingCount?: number;
|
|
21
|
+
} | undefined;
|
|
14
22
|
delete(id: string): boolean;
|
|
15
23
|
}
|
|
16
24
|
interface TriggerSystemLike {
|