@trevonistrevon/pi-loop 0.4.10 → 0.5.0

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.
Files changed (45) hide show
  1. package/README.md +11 -1
  2. package/dist/coordinator.d.ts +35 -0
  3. package/dist/coordinator.js +56 -0
  4. package/dist/goal-types.d.ts +78 -0
  5. package/dist/goal-types.js +1 -0
  6. package/dist/goal-verifier.d.ts +20 -0
  7. package/dist/goal-verifier.js +198 -0
  8. package/dist/index.js +182 -40
  9. package/dist/loop-reducer.d.ts +63 -0
  10. package/dist/loop-reducer.js +67 -0
  11. package/dist/monitor-completion-coordinator.d.ts +10 -0
  12. package/dist/monitor-completion-coordinator.js +13 -0
  13. package/dist/monitor-manager.d.ts +2 -0
  14. package/dist/monitor-manager.js +107 -29
  15. package/dist/monitor-reducer.d.ts +82 -0
  16. package/dist/monitor-reducer.js +69 -0
  17. package/dist/notification-reducer.d.ts +81 -0
  18. package/dist/notification-reducer.js +65 -0
  19. package/dist/store.d.ts +2 -0
  20. package/dist/store.js +118 -44
  21. package/dist/task-backlog-coordinator.d.ts +12 -0
  22. package/dist/task-backlog-coordinator.js +22 -0
  23. package/dist/task-reducer.d.ts +66 -0
  24. package/dist/task-reducer.js +76 -0
  25. package/dist/task-store.d.ts +2 -0
  26. package/dist/task-store.js +82 -30
  27. package/docs/architecture/goal-state-schema.md +505 -0
  28. package/docs/architecture/state-machine-migration.md +546 -0
  29. package/docs/architecture/state-machine-reducer-event-model.md +823 -0
  30. package/docs/architecture/state-machine-test-matrix.md +249 -0
  31. package/docs/architecture/state-machine-transition-map.md +436 -0
  32. package/package.json +1 -1
  33. package/src/coordinator.ts +115 -0
  34. package/src/goal-types.ts +99 -0
  35. package/src/goal-verifier.ts +241 -0
  36. package/src/index.ts +209 -39
  37. package/src/loop-reducer.ts +148 -0
  38. package/src/monitor-completion-coordinator.ts +24 -0
  39. package/src/monitor-manager.ts +115 -27
  40. package/src/monitor-reducer.ts +166 -0
  41. package/src/notification-reducer.ts +155 -0
  42. package/src/store.ts +119 -43
  43. package/src/task-backlog-coordinator.ts +32 -0
  44. package/src/task-reducer.ts +152 -0
  45. package/src/task-store.ts +84 -27
@@ -1,6 +1,11 @@
1
1
 
2
2
  import { spawn } from "node:child_process";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ type MonitorReducerEvent,
6
+ type MonitorReducerState,
7
+ reduceMonitorState,
8
+ } from "./monitor-reducer.js";
4
9
  import type { MonitorEntry, MonitorProcess } from "./types.js";
5
10
 
6
11
  export class MonitorManager {
@@ -9,18 +14,45 @@ export class MonitorManager {
9
14
 
10
15
  constructor(private pi: ExtensionAPI) {}
11
16
 
12
- create(command: string, description?: string, timeout = 300000): MonitorEntry {
13
- const id = String(this.nextId++);
14
- const entry: MonitorEntry = {
15
- id,
16
- command,
17
- description,
18
- timeout,
19
- status: "running",
20
- startedAt: Date.now(),
21
- outputLines: 0,
22
- outputBuffer: [],
17
+ private toReducerState(): MonitorReducerState {
18
+ return {
19
+ nextId: this.nextId,
20
+ monitorsById: Object.fromEntries(
21
+ Array.from(this.processes.entries()).map(([id, process]) => [id, process.entry]),
22
+ ),
23
23
  };
24
+ }
25
+
26
+ private applyReducerEvent(event: MonitorReducerEvent) {
27
+ const result = reduceMonitorState(this.toReducerState(), event);
28
+ this.nextId = result.state.nextId;
29
+ for (const [id, process] of [...this.processes.entries()]) {
30
+ const updated = result.state.monitorsById[id];
31
+ if (!updated) {
32
+ this.processes.delete(id);
33
+ continue;
34
+ }
35
+ process.entry = updated;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ create(command: string, description?: string, timeout = 300000): MonitorEntry {
41
+ const now = Date.now();
42
+ const result = reduceMonitorState(this.toReducerState(), {
43
+ type: "MONITOR_CREATED",
44
+ at: now,
45
+ source: "tool",
46
+ entityType: "monitor",
47
+ payload: {
48
+ command,
49
+ description,
50
+ timeout,
51
+ },
52
+ });
53
+ this.nextId = result.state.nextId;
54
+ const id = String(this.nextId - 1);
55
+ const entry = result.state.monitorsById[id]!;
24
56
 
25
57
  const abortController = new AbortController();
26
58
  const child = spawn("sh", ["-c", command], {
@@ -42,8 +74,14 @@ export class MonitorManager {
42
74
  const lines = data.toString().split("\n");
43
75
  for (const line of lines) {
44
76
  if (line.length === 0) continue;
45
- entry.outputLines++;
46
- if (entry.outputBuffer.length < 200) entry.outputBuffer.push(line);
77
+ this.applyReducerEvent({
78
+ type: "MONITOR_OUTPUT",
79
+ at: Date.now(),
80
+ source: "monitor",
81
+ entityType: "monitor",
82
+ entityId: id,
83
+ payload: { id, line },
84
+ });
47
85
  this.pi.events.emit("monitor:output", {
48
86
  monitorId: id,
49
87
  line,
@@ -56,8 +94,14 @@ export class MonitorManager {
56
94
  const lines = data.toString().split("\n");
57
95
  for (const line of lines) {
58
96
  if (line.length === 0) continue;
59
- entry.outputLines++;
60
- if (entry.outputBuffer.length < 200) entry.outputBuffer.push(line);
97
+ this.applyReducerEvent({
98
+ type: "MONITOR_OUTPUT",
99
+ at: Date.now(),
100
+ source: "monitor",
101
+ entityType: "monitor",
102
+ entityId: id,
103
+ payload: { id, line },
104
+ });
61
105
  this.pi.events.emit("monitor:output", {
62
106
  monitorId: id,
63
107
  line,
@@ -67,13 +111,22 @@ export class MonitorManager {
67
111
  });
68
112
 
69
113
  const finish = (code: number | null, status: "completed" | "error") => {
70
- entry.status = status;
71
- entry.exitCode = code ?? undefined;
72
- entry.completedAt = Date.now();
114
+ this.applyReducerEvent({
115
+ type: status === "completed" ? "MONITOR_COMPLETED" : "MONITOR_ERRORED",
116
+ at: Date.now(),
117
+ source: "monitor",
118
+ entityType: "monitor",
119
+ entityId: id,
120
+ payload: {
121
+ id,
122
+ exitCode: code ?? undefined,
123
+ },
124
+ });
125
+ const current = this.get(id)!;
73
126
  this.pi.events.emit(status === "completed" ? "monitor:done" : "monitor:error", {
74
127
  monitorId: id,
75
128
  exitCode: code,
76
- outputLines: entry.outputLines,
129
+ outputLines: current.outputLines,
77
130
  });
78
131
  if (status === "completed") {
79
132
  for (const callback of bp.completionCallbacks) callback();
@@ -83,19 +136,37 @@ export class MonitorManager {
83
136
  bp.waiters = [];
84
137
  // Remove completed/errored monitors after a brief delay so tool
85
138
  // consumers have time to read the final state via MonitorList.
86
- setTimeout(() => { this.processes.delete(id); }, 30000);
139
+ setTimeout(() => {
140
+ this.applyReducerEvent({
141
+ type: "MONITOR_PRUNED",
142
+ at: Date.now(),
143
+ source: "system",
144
+ entityType: "monitor",
145
+ entityId: id,
146
+ payload: { id },
147
+ });
148
+ }, 30000);
87
149
  };
88
150
 
89
151
  child.on("close", (code) => {
90
- if (entry.status === "running") {
152
+ if (bp.entry.status === "running") {
91
153
  finish(code, code === 0 ? "completed" : "error");
92
154
  }
93
155
  });
94
156
 
95
157
  child.on("error", (err) => {
96
- if (entry.status === "running") {
97
- entry.status = "error";
98
- entry.completedAt = Date.now();
158
+ if (bp.entry.status === "running") {
159
+ this.applyReducerEvent({
160
+ type: "MONITOR_ERRORED",
161
+ at: Date.now(),
162
+ source: "monitor",
163
+ entityType: "monitor",
164
+ entityId: id,
165
+ payload: {
166
+ id,
167
+ error: err.message,
168
+ },
169
+ });
99
170
  this.pi.events.emit("monitor:error", {
100
171
  monitorId: id,
101
172
  error: err.message,
@@ -108,7 +179,7 @@ export class MonitorManager {
108
179
 
109
180
  if (timeout > 0) {
110
181
  setTimeout(() => {
111
- if (entry.status === "running") {
182
+ if (bp.entry.status === "running") {
112
183
  this.stop(id);
113
184
  }
114
185
  }, timeout);
@@ -133,7 +204,17 @@ export class MonitorManager {
133
204
  const bp = this.processes.get(id);
134
205
  if (!bp || bp.entry.status !== "running") return false;
135
206
 
136
- bp.entry.status = "stopped";
207
+ this.applyReducerEvent({
208
+ type: "MONITOR_STOPPED",
209
+ at: Date.now(),
210
+ source: "tool",
211
+ entityType: "monitor",
212
+ entityId: id,
213
+ payload: {
214
+ id,
215
+ reason: "manual",
216
+ },
217
+ });
137
218
  bp.proc.kill("SIGTERM");
138
219
 
139
220
  await new Promise<void>((resolve) => {
@@ -148,7 +229,6 @@ export class MonitorManager {
148
229
  });
149
230
  });
150
231
 
151
- bp.entry.completedAt = Date.now();
152
232
  bp.completionCallbacks = [];
153
233
  for (const resolve of bp.waiters) resolve();
154
234
  bp.waiters = [];
@@ -163,6 +243,14 @@ export class MonitorManager {
163
243
  return true;
164
244
  }
165
245
  if (bp.entry.status !== "running") return false;
246
+ this.applyReducerEvent({
247
+ type: "MONITOR_ONDONE_REGISTERED",
248
+ at: Date.now(),
249
+ source: "tool",
250
+ entityType: "monitor",
251
+ entityId: id,
252
+ payload: { id },
253
+ });
166
254
  bp.completionCallbacks.push(callback);
167
255
  return true;
168
256
  }
@@ -0,0 +1,166 @@
1
+ import type { MonitorEntry } from "./types.js";
2
+
3
+ type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
4
+
5
+ export interface MonitorReducerEntry extends MonitorEntry {
6
+ onDoneRegistered?: boolean;
7
+ }
8
+
9
+ export interface MonitorReducerState {
10
+ nextId: number;
11
+ monitorsById: Record<string, MonitorReducerEntry>;
12
+ }
13
+
14
+ export type MonitorReducerEvent =
15
+ | {
16
+ type: "MONITOR_CREATED";
17
+ at: number;
18
+ source: ReducerSource;
19
+ entityType?: "monitor";
20
+ entityId?: string;
21
+ payload: {
22
+ command: string;
23
+ description?: string;
24
+ timeout: number;
25
+ };
26
+ }
27
+ | {
28
+ type: "MONITOR_OUTPUT";
29
+ at: number;
30
+ source: ReducerSource;
31
+ entityType?: "monitor";
32
+ entityId?: string;
33
+ payload: {
34
+ id: string;
35
+ line: string;
36
+ };
37
+ }
38
+ | {
39
+ type: "MONITOR_COMPLETED" | "MONITOR_ERRORED";
40
+ at: number;
41
+ source: ReducerSource;
42
+ entityType?: "monitor";
43
+ entityId?: string;
44
+ payload: {
45
+ id: string;
46
+ exitCode?: number;
47
+ error?: string;
48
+ };
49
+ }
50
+ | {
51
+ type: "MONITOR_STOPPED";
52
+ at: number;
53
+ source: ReducerSource;
54
+ entityType?: "monitor";
55
+ entityId?: string;
56
+ payload: {
57
+ id: string;
58
+ reason: "manual" | "timeout";
59
+ };
60
+ }
61
+ | {
62
+ type: "MONITOR_PRUNED" | "MONITOR_ONDONE_REGISTERED";
63
+ at: number;
64
+ source: ReducerSource;
65
+ entityType?: "monitor";
66
+ entityId?: string;
67
+ payload: {
68
+ id: string;
69
+ };
70
+ };
71
+
72
+ export type MonitorReducerEffect =
73
+ | {
74
+ type: "PERSIST_MONITOR";
75
+ entityType: "monitor";
76
+ entityId: string;
77
+ payload: { monitor: MonitorReducerEntry };
78
+ }
79
+ | {
80
+ type: "DELETE_MONITOR";
81
+ entityType: "monitor";
82
+ entityId: string;
83
+ payload: { id: string };
84
+ };
85
+
86
+ export interface MonitorReduceResult {
87
+ state: MonitorReducerState;
88
+ effects: MonitorReducerEffect[];
89
+ }
90
+
91
+ function cloneState(state: MonitorReducerState): MonitorReducerState {
92
+ return {
93
+ nextId: state.nextId,
94
+ monitorsById: { ...state.monitorsById },
95
+ };
96
+ }
97
+
98
+ export function reduceMonitorState(state: MonitorReducerState, event: MonitorReducerEvent): MonitorReduceResult {
99
+ if (event.type === "MONITOR_CREATED") {
100
+ const next = cloneState(state);
101
+ const id = String(next.nextId++);
102
+ const monitor: MonitorReducerEntry = {
103
+ id,
104
+ command: event.payload.command,
105
+ description: event.payload.description,
106
+ timeout: event.payload.timeout,
107
+ status: "running",
108
+ startedAt: event.at,
109
+ outputLines: 0,
110
+ outputBuffer: [],
111
+ };
112
+ next.monitorsById[id] = monitor;
113
+ return {
114
+ state: next,
115
+ effects: [{ type: "PERSIST_MONITOR", entityType: "monitor", entityId: id, payload: { monitor } }],
116
+ };
117
+ }
118
+
119
+ const id = event.payload.id;
120
+ const current = state.monitorsById[id];
121
+ if (!current) return { state, effects: [] };
122
+
123
+ if (event.type === "MONITOR_PRUNED") {
124
+ const next = cloneState(state);
125
+ delete next.monitorsById[id];
126
+ return {
127
+ state: next,
128
+ effects: [{ type: "DELETE_MONITOR", entityType: "monitor", entityId: id, payload: { id } }],
129
+ };
130
+ }
131
+
132
+ const next = cloneState(state);
133
+ const monitor: MonitorReducerEntry = { ...current };
134
+
135
+ if (event.type === "MONITOR_OUTPUT") {
136
+ monitor.outputLines++;
137
+ if (monitor.outputBuffer.length < 200) monitor.outputBuffer = [...monitor.outputBuffer, event.payload.line];
138
+ }
139
+
140
+ if (event.type === "MONITOR_COMPLETED") {
141
+ monitor.status = "completed";
142
+ monitor.exitCode = event.payload.exitCode;
143
+ monitor.completedAt = event.at;
144
+ }
145
+
146
+ if (event.type === "MONITOR_ERRORED") {
147
+ monitor.status = "error";
148
+ if (event.payload.exitCode !== undefined) monitor.exitCode = event.payload.exitCode;
149
+ monitor.completedAt = event.at;
150
+ }
151
+
152
+ if (event.type === "MONITOR_STOPPED") {
153
+ monitor.status = "stopped";
154
+ monitor.completedAt = event.at;
155
+ }
156
+
157
+ if (event.type === "MONITOR_ONDONE_REGISTERED") {
158
+ monitor.onDoneRegistered = true;
159
+ }
160
+
161
+ next.monitorsById[id] = monitor;
162
+ return {
163
+ state: next,
164
+ effects: [{ type: "PERSIST_MONITOR", entityType: "monitor", entityId: id, payload: { monitor } }],
165
+ };
166
+ }
@@ -0,0 +1,155 @@
1
+ type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
2
+
3
+ export interface ReducerNotification {
4
+ key: string;
5
+ loopId: string;
6
+ message: string;
7
+ timestamp: number;
8
+ trigger: unknown;
9
+ recurring?: boolean;
10
+ autoTask?: boolean;
11
+ readOnly?: boolean;
12
+ }
13
+
14
+ export interface NotificationReducerState {
15
+ notificationsByKey: Record<string, ReducerNotification>;
16
+ agentRunning: boolean;
17
+ hasPendingMessages: boolean;
18
+ }
19
+
20
+ export type NotificationReducerEvent =
21
+ | {
22
+ type: "NOTIFICATION_QUEUED";
23
+ at: number;
24
+ source: ReducerSource;
25
+ entityType?: "notification";
26
+ entityId?: string;
27
+ payload: { notification: ReducerNotification };
28
+ }
29
+ | {
30
+ type: "NOTIFICATION_DROPPED";
31
+ at: number;
32
+ source: ReducerSource;
33
+ entityType?: "notification";
34
+ entityId?: string;
35
+ payload: {
36
+ key: string;
37
+ reason: "zero_pending_tasks" | "session_switch" | "session_shutdown" | "superseded";
38
+ };
39
+ }
40
+ | {
41
+ type: "NOTIFICATION_CLEARED";
42
+ at: number;
43
+ source: ReducerSource;
44
+ entityType?: "notification";
45
+ entityId?: string;
46
+ payload: { reason: "session_switch" | "session_shutdown" };
47
+ }
48
+ | {
49
+ type: "NOTIFICATION_FLUSH_REQUESTED";
50
+ at: number;
51
+ source: ReducerSource;
52
+ entityType?: "notification";
53
+ entityId?: string;
54
+ payload: { ignorePendingMessages?: boolean };
55
+ }
56
+ | {
57
+ type: "NOTIFICATION_RUNTIME_UPDATED";
58
+ at: number;
59
+ source: ReducerSource;
60
+ entityType?: "notification";
61
+ entityId?: string;
62
+ payload: { agentRunning: boolean; hasPendingMessages: boolean };
63
+ };
64
+
65
+ export type NotificationReducerEffect =
66
+ | {
67
+ type: "REQUEST_NOTIFICATION_FLUSH";
68
+ payload: Record<string, never>;
69
+ }
70
+ | {
71
+ type: "DELIVER_NOTIFICATION";
72
+ entityType: "notification";
73
+ entityId: string;
74
+ payload: { notification: ReducerNotification };
75
+ };
76
+
77
+ export interface NotificationReduceResult {
78
+ state: NotificationReducerState;
79
+ effects: NotificationReducerEffect[];
80
+ }
81
+
82
+ function cloneState(state: NotificationReducerState): NotificationReducerState {
83
+ return {
84
+ notificationsByKey: { ...state.notificationsByKey },
85
+ agentRunning: state.agentRunning,
86
+ hasPendingMessages: state.hasPendingMessages,
87
+ };
88
+ }
89
+
90
+ export function reduceNotificationState(
91
+ state: NotificationReducerState,
92
+ event: NotificationReducerEvent,
93
+ ): NotificationReduceResult {
94
+ if (event.type === "NOTIFICATION_QUEUED") {
95
+ const next = cloneState(state);
96
+ next.notificationsByKey[event.payload.notification.key] = event.payload.notification;
97
+ return {
98
+ state: next,
99
+ effects: [{ type: "REQUEST_NOTIFICATION_FLUSH", payload: {} }],
100
+ };
101
+ }
102
+
103
+ if (event.type === "NOTIFICATION_DROPPED") {
104
+ const next = cloneState(state);
105
+ delete next.notificationsByKey[event.payload.key];
106
+ return { state: next, effects: [] };
107
+ }
108
+
109
+ if (event.type === "NOTIFICATION_CLEARED") {
110
+ return {
111
+ state: {
112
+ ...state,
113
+ notificationsByKey: {},
114
+ },
115
+ effects: [],
116
+ };
117
+ }
118
+
119
+ if (event.type === "NOTIFICATION_RUNTIME_UPDATED") {
120
+ return {
121
+ state: {
122
+ ...state,
123
+ agentRunning: event.payload.agentRunning,
124
+ hasPendingMessages: event.payload.hasPendingMessages,
125
+ },
126
+ effects: [],
127
+ };
128
+ }
129
+
130
+ if (event.type === "NOTIFICATION_FLUSH_REQUESTED") {
131
+ if (state.agentRunning) return { state, effects: [] };
132
+ if (!event.payload.ignorePendingMessages && state.hasPendingMessages) {
133
+ return { state, effects: [] };
134
+ }
135
+
136
+ const queued = Object.values(state.notificationsByKey)
137
+ .sort((left, right) => left.timestamp - right.timestamp);
138
+ const nextNotification = queued[0];
139
+ if (!nextNotification) return { state, effects: [] };
140
+
141
+ const next = cloneState(state);
142
+ delete next.notificationsByKey[nextNotification.key];
143
+ return {
144
+ state: next,
145
+ effects: [{
146
+ type: "DELIVER_NOTIFICATION",
147
+ entityType: "notification",
148
+ entityId: nextNotification.key,
149
+ payload: { notification: nextNotification },
150
+ }],
151
+ };
152
+ }
153
+
154
+ return { state, effects: [] };
155
+ }