@hank-warren/pi-loop 0.7.0 → 0.8.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.
@@ -1,351 +0,0 @@
1
- /**
2
- * The scheduler runtime: one timer, two delivery paths.
3
- *
4
- * A `prompt` task is delivered exactly like a loop wake — only at a settled
5
- * idle boundary, coalescing when the session is busy, because a scheduler
6
- * that interrupts a running turn is a worse tool than no scheduler. A `run`
7
- * task spawns a headless `pi -p` and never touches the conversation unless
8
- * its `wakeOn` policy says to report back.
9
- *
10
- * The model gets no scheduling tools at all. `/schedule` is user-typed, the
11
- * same doctrine `/loop` follows: a model that can schedule its own future
12
- * turns can schedule its way around every limit the loop imposes.
13
- */
14
-
15
- import { spawn } from "node:child_process";
16
- import { createWriteStream, mkdirSync } from "node:fs";
17
- import { randomUUID } from "node:crypto";
18
- import { dirname } from "node:path";
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
- import {
21
- DEFAULT_MAX_RUNS,
22
- isPersistentTask,
23
- nextFireAt,
24
- type ScheduledTask,
25
- TASK_LIFETIME_MS,
26
- type ScheduleSpec,
27
- type TaskSpec,
28
- } from "./model.js";
29
- import {
30
- acquireLease,
31
- LEASE_HEARTBEAT_MS,
32
- readTasks,
33
- releaseLease,
34
- runLogPath,
35
- schedulePaths,
36
- type SchedulePaths,
37
- writeTasks,
38
- } from "./store.js";
39
-
40
- /** Longest a single headless run may take before it is killed. */
41
- export const RUN_TIMEOUT_MS = 30 * 60_000;
42
- const MAX_TIMER_MS = 2_147_483_647;
43
-
44
- export interface SchedulerOptions {
45
- agentDir?: string;
46
- now?: () => number;
47
- /** Injected for tests; defaults to spawning a real headless Pi. */
48
- spawnRun?: (task: ScheduledTask, logPath: string) => Promise<{ ok: boolean; detail: string }>;
49
- }
50
-
51
- export interface CreateTaskInput {
52
- name: string;
53
- schedule: ScheduleSpec;
54
- task: TaskSpec;
55
- maxRuns?: number | null;
56
- }
57
-
58
- export class Scheduler {
59
- private readonly pi: ExtensionAPI;
60
- private readonly now: () => number;
61
- private readonly paths: SchedulePaths;
62
- private readonly owner = randomUUID();
63
- private readonly spawnRun: NonNullable<SchedulerOptions["spawnRun"]>;
64
- /** Session-scoped prompt tasks; they die with the session by design. */
65
- private sessionTasks: ScheduledTask[] = [];
66
- private timer: NodeJS.Timeout | undefined;
67
- private leaseTimer: NodeJS.Timeout | undefined;
68
- private ctx: ExtensionContext | undefined;
69
- private pendingPrompts: string[] = [];
70
- private started = false;
71
-
72
- constructor(pi: ExtensionAPI, options: SchedulerOptions = {}) {
73
- this.pi = pi;
74
- this.now = options.now ?? Date.now;
75
- this.paths = schedulePaths(options.agentDir);
76
- this.spawnRun = options.spawnRun ?? spawnHeadlessRun;
77
- }
78
-
79
- // --- lifecycle ---
80
-
81
- onSessionStart(ctx: ExtensionContext): void {
82
- this.ctx = ctx;
83
- this.sessionTasks = [];
84
- this.pendingPrompts = [];
85
- this.started = true;
86
- this.renewLease();
87
- this.reschedule();
88
- }
89
-
90
- onSessionShutdown(): void {
91
- this.started = false;
92
- this.clearTimers();
93
- releaseLease(this.paths, this.owner);
94
- this.ctx = undefined;
95
- // Prompt tasks are session scoped: a prompt with no session to arrive
96
- // in is not a task, it is a leak.
97
- this.sessionTasks = [];
98
- }
99
-
100
- /** Deliver anything that came due while the session was busy. */
101
- onAgentSettled(ctx: ExtensionContext): void {
102
- this.ctx = ctx;
103
- this.flushPrompts(ctx);
104
- this.reschedule();
105
- }
106
-
107
- // --- task management ---
108
-
109
- tasks(): ScheduledTask[] {
110
- return [...this.sessionTasks, ...readTasks(this.paths)].sort(
111
- (a, b) => (nextFireAt(a, this.now()) ?? Infinity) - (nextFireAt(b, this.now()) ?? Infinity),
112
- );
113
- }
114
-
115
- find(idPrefix: string): ScheduledTask | undefined {
116
- const matches = this.tasks().filter((task) => task.id.startsWith(idPrefix));
117
- return matches.length === 1 ? matches[0] : undefined;
118
- }
119
-
120
- create(input: CreateTaskInput): { task: ScheduledTask; warning?: string } {
121
- const now = this.now();
122
- const task: ScheduledTask = {
123
- id: randomUUID().slice(0, 8),
124
- name: input.name,
125
- schedule: input.schedule,
126
- task: input.task,
127
- status: "active",
128
- createdAt: now,
129
- expiresAt: now + TASK_LIFETIME_MS,
130
- maxRuns: input.maxRuns === undefined ? DEFAULT_MAX_RUNS : input.maxRuns,
131
- runs: 0,
132
- };
133
- const warning = this.save(task);
134
- this.reschedule();
135
- return { task, ...(warning ? { warning } : {}) };
136
- }
137
-
138
- update(task: ScheduledTask): string | undefined {
139
- const warning = this.save(task);
140
- this.reschedule();
141
- return warning;
142
- }
143
-
144
- remove(id: string): boolean {
145
- const before = this.sessionTasks.length;
146
- this.sessionTasks = this.sessionTasks.filter((task) => task.id !== id);
147
- if (this.sessionTasks.length !== before) {
148
- this.reschedule();
149
- return true;
150
- }
151
- const persisted = readTasks(this.paths);
152
- const kept = persisted.filter((task) => task.id !== id);
153
- if (kept.length === persisted.length) return false;
154
- writeTasks(this.paths, kept);
155
- this.reschedule();
156
- return true;
157
- }
158
-
159
- private save(task: ScheduledTask): string | undefined {
160
- if (!isPersistentTask(task)) {
161
- this.sessionTasks = [...this.sessionTasks.filter((other) => other.id !== task.id), task];
162
- return undefined;
163
- }
164
- const persisted = readTasks(this.paths).filter((other) => other.id !== task.id);
165
- return writeTasks(this.paths, [...persisted, task]);
166
- }
167
-
168
- // --- firing ---
169
-
170
- /** Fire everything due now. Exposed for the manager's "run now". */
171
- runDue(): void {
172
- if (!this.started) return;
173
- const now = this.now();
174
- const headlessAllowed = this.renewLease();
175
- for (const task of this.tasks()) {
176
- const fire = nextFireAt(task, now);
177
- if (fire === undefined || fire > now) continue;
178
- if (task.task.kind === "run" && !headlessAllowed) continue;
179
- this.fire(task, now);
180
- }
181
- this.reschedule();
182
- }
183
-
184
- fireNow(task: ScheduledTask): void {
185
- this.fire(task, this.now());
186
- this.reschedule();
187
- }
188
-
189
- private fire(task: ScheduledTask, now: number): void {
190
- // Count the run before doing it: a task that throws must not become a
191
- // hot loop, and a one-shot must not fire twice.
192
- const ran: ScheduledTask = { ...task, runs: task.runs + 1, lastRunAt: now };
193
- const finished =
194
- ran.schedule.kind === "once" || (ran.maxRuns !== null && ran.runs >= ran.maxRuns);
195
- this.save(finished ? { ...ran, status: "done" } : ran);
196
- if (task.task.kind === "prompt") {
197
- this.deliverPrompt(task);
198
- return;
199
- }
200
- void this.startRun(ran);
201
- }
202
-
203
- private deliverPrompt(task: ScheduledTask): void {
204
- if (task.task.kind !== "prompt") return;
205
- const message = `Scheduled task "${task.name}" fired.\n\n${task.task.prompt}`;
206
- this.pendingPrompts.push(message);
207
- const ctx = this.ctx;
208
- if (ctx) this.flushPrompts(ctx);
209
- }
210
-
211
- /**
212
- * Prompts deliver only at a fully idle boundary, and a queue that built up
213
- * while the session was busy delivers in order once it settles.
214
- */
215
- private flushPrompts(ctx: ExtensionContext): void {
216
- if (this.pendingPrompts.length === 0) return;
217
- if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
218
- const queued = this.pendingPrompts;
219
- this.pendingPrompts = [];
220
- for (const message of queued) {
221
- try {
222
- this.pi.sendUserMessage(message);
223
- } catch {
224
- // Re-queue the rest for the next settle rather than dropping them.
225
- this.pendingPrompts.push(message);
226
- }
227
- }
228
- }
229
-
230
- private async startRun(task: ScheduledTask): Promise<void> {
231
- if (task.task.kind !== "run") return;
232
- const at = this.now();
233
- const logPath = runLogPath(this.paths, task.id, at);
234
- let result: { ok: boolean; detail: string };
235
- try {
236
- result = await this.spawnRun(task, logPath);
237
- } catch (error) {
238
- result = { ok: false, detail: error instanceof Error ? error.message : String(error) };
239
- }
240
- const current = this.find(task.id) ?? task;
241
- this.save({
242
- ...current,
243
- lastResult: { at: this.now(), ok: result.ok, detail: `${result.detail} (log: ${logPath})` },
244
- });
245
- this.reportRun(task, result, logPath);
246
- }
247
-
248
- private reportRun(
249
- task: ScheduledTask,
250
- result: { ok: boolean; detail: string },
251
- logPath: string,
252
- ): void {
253
- if (task.task.kind !== "run") return;
254
- const wakeOn = task.task.wakeOn;
255
- const wanted =
256
- wakeOn === "always" ||
257
- (wakeOn === "failure" && !result.ok) ||
258
- (wakeOn === "success" && result.ok);
259
- if (!wanted) return;
260
- const ctx = this.ctx;
261
- if (!ctx) return;
262
- ctx.ui.notify(
263
- `Scheduled run "${task.name}" ${result.ok ? "succeeded" : "failed"}: ${result.detail}\nLog: ${logPath}`,
264
- result.ok ? "info" : "warning",
265
- );
266
- }
267
-
268
- // --- timers ---
269
-
270
- private reschedule(): void {
271
- this.clearFireTimer();
272
- if (!this.started) return;
273
- const now = this.now();
274
- let earliest: number | undefined;
275
- for (const task of this.tasks()) {
276
- const fire = nextFireAt(task, now);
277
- if (fire === undefined) continue;
278
- if (earliest === undefined || fire < earliest) earliest = fire;
279
- }
280
- if (earliest === undefined) return;
281
- const delay = Math.max(0, Math.min(MAX_TIMER_MS, earliest - now));
282
- this.timer = setTimeout(() => {
283
- this.timer = undefined;
284
- this.runDue();
285
- }, delay);
286
- this.timer.unref?.();
287
- }
288
-
289
- /**
290
- * Hold the headless-firing lease. Returns whether this process holds it;
291
- * prompt tasks never consult it, since they are session-scoped and cannot
292
- * be double-fired by another process.
293
- */
294
- private renewLease(): boolean {
295
- const held = acquireLease(this.paths, this.owner, this.now());
296
- if (!this.leaseTimer && this.started) {
297
- this.leaseTimer = setInterval(() => {
298
- acquireLease(this.paths, this.owner, this.now());
299
- }, LEASE_HEARTBEAT_MS);
300
- this.leaseTimer.unref?.();
301
- }
302
- return held;
303
- }
304
-
305
- private clearFireTimer(): void {
306
- if (this.timer) clearTimeout(this.timer);
307
- this.timer = undefined;
308
- }
309
-
310
- private clearTimers(): void {
311
- this.clearFireTimer();
312
- if (this.leaseTimer) clearInterval(this.leaseTimer);
313
- this.leaseTimer = undefined;
314
- }
315
- }
316
-
317
- /**
318
- * The default headless runner: `pi -p "<prompt>"` in the task's directory,
319
- * with stdout and stderr tee'd to the run log.
320
- */
321
- async function spawnHeadlessRun(
322
- task: ScheduledTask,
323
- logPath: string,
324
- ): Promise<{ ok: boolean; detail: string }> {
325
- if (task.task.kind !== "run") return { ok: false, detail: "not a run task" };
326
- const { prompt, cwd } = task.task;
327
- mkdirSync(dirname(logPath), { recursive: true });
328
- const log = createWriteStream(logPath, { flags: "a" });
329
- log.write(`# ${new Date().toISOString()} ${task.name}\n# cwd: ${cwd}\n# prompt: ${prompt}\n\n`);
330
- return await new Promise((resolve) => {
331
- const child = spawn("pi", ["-p", prompt], { cwd, stdio: ["ignore", "pipe", "pipe"] });
332
- const timeout = setTimeout(() => child.kill("SIGTERM"), RUN_TIMEOUT_MS);
333
- timeout.unref?.();
334
- child.stdout?.pipe(log, { end: false });
335
- child.stderr?.pipe(log, { end: false });
336
- child.on("error", (error) => {
337
- clearTimeout(timeout);
338
- log.end(`\n# spawn failed: ${error.message}\n`);
339
- resolve({ ok: false, detail: `could not start pi: ${error.message}` });
340
- });
341
- child.on("close", (code, signal) => {
342
- clearTimeout(timeout);
343
- log.end(`\n# exit ${code ?? "null"}${signal ? ` (${signal})` : ""}\n`);
344
- resolve(
345
- signal
346
- ? { ok: false, detail: `killed by ${signal}` }
347
- : { ok: code === 0, detail: `exit ${code ?? "unknown"}` },
348
- );
349
- });
350
- });
351
- }
@@ -1,183 +0,0 @@
1
- /**
2
- * Persistence for `run` tasks (`~/.pi/agent/loop/schedules.json`) and the
3
- * single-writer lease that decides which live Pi session is allowed to fire
4
- * them.
5
- *
6
- * The lease exists because headless tasks are shared state: every open
7
- * session reads the same file, and without arbitration a task scheduled for
8
- * 09:00 fires once per session that happens to be running. A lockfile holding
9
- * a pid and a heartbeat is enough — a holder that dies stops renewing, and
10
- * the next session takes over after the stale window. Deliberately not a real
11
- * distributed lock: the failure it must prevent is *duplicate work*, and the
12
- * worst case it can produce is one skipped tick.
13
- *
14
- * Everything here is best-effort. A read-only home directory means "no
15
- * headless scheduling", never a crash.
16
- */
17
-
18
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
- import { randomUUID } from "node:crypto";
20
- import { dirname, join } from "node:path";
21
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
- import { normalizeTask, type ScheduledTask } from "./model.js";
23
-
24
- export const SCHEDULES_FILE = "schedules.json";
25
- export const LEASE_FILE = "schedules.lease";
26
- export const RUNS_DIR = "runs";
27
-
28
- /** A lease older than this is assumed abandoned. */
29
- export const LEASE_STALE_MS = 90_000;
30
- /** How often the holder refreshes it. */
31
- export const LEASE_HEARTBEAT_MS = 30_000;
32
-
33
- export interface SchedulePaths {
34
- dir: string;
35
- schedules: string;
36
- lease: string;
37
- runs: string;
38
- }
39
-
40
- export function schedulePaths(agentDir = getAgentDir()): SchedulePaths {
41
- const dir = join(agentDir, "loop");
42
- return {
43
- dir,
44
- schedules: join(dir, SCHEDULES_FILE),
45
- lease: join(dir, LEASE_FILE),
46
- runs: join(dir, RUNS_DIR),
47
- };
48
- }
49
-
50
- /** Read persisted tasks, fail-open: an unreadable file means no tasks. */
51
- export function readTasks(paths: SchedulePaths): ScheduledTask[] {
52
- let contents: string;
53
- try {
54
- contents = readFileSync(paths.schedules, "utf8");
55
- } catch {
56
- return [];
57
- }
58
- try {
59
- const parsed: unknown = JSON.parse(contents);
60
- if (!Array.isArray(parsed)) return [];
61
- // One malformed entry drops itself, not the whole schedule.
62
- return parsed
63
- .map((value) => normalizeTask(value))
64
- .filter((task): task is ScheduledTask => task !== undefined);
65
- } catch {
66
- return [];
67
- }
68
- }
69
-
70
- /** Atomically replace the persisted tasks. Returns a failure reason, if any. */
71
- export function writeTasks(paths: SchedulePaths, tasks: ScheduledTask[]): string | undefined {
72
- const document = `${JSON.stringify(tasks, null, 2)}\n`;
73
- const temporary = join(paths.dir, `.${SCHEDULES_FILE}.${randomUUID()}.tmp`);
74
- try {
75
- mkdirSync(paths.dir, { recursive: true });
76
- writeFileSync(temporary, document, { encoding: "utf8", flag: "wx" });
77
- renameSync(temporary, paths.schedules);
78
- return undefined;
79
- } catch (error) {
80
- return error instanceof Error ? error.message : String(error);
81
- } finally {
82
- try {
83
- rmSync(temporary, { force: true });
84
- } catch {
85
- // Best-effort cleanup must not replace the write result.
86
- }
87
- }
88
- }
89
-
90
- export interface LeaseRecord {
91
- pid: number;
92
- heartbeat: number;
93
- owner: string;
94
- }
95
-
96
- /**
97
- * Take or renew the lease. Returns true when this process may fire headless
98
- * tasks. `owner` identifies this runtime so a restarted process with a reused
99
- * pid cannot mistake someone else's lease for its own.
100
- */
101
- export function acquireLease(
102
- paths: SchedulePaths,
103
- owner: string,
104
- now: number,
105
- pid: number = process.pid,
106
- ): boolean {
107
- const current = readLease(paths);
108
- if (current && current.owner !== owner) {
109
- const fresh = now - current.heartbeat < LEASE_STALE_MS;
110
- if (fresh && isProcessAlive(current.pid)) return false;
111
- }
112
- return writeLease(paths, { pid, heartbeat: now, owner });
113
- }
114
-
115
- /** Release the lease if this owner holds it. */
116
- export function releaseLease(paths: SchedulePaths, owner: string): void {
117
- const current = readLease(paths);
118
- if (!current || current.owner !== owner) return;
119
- try {
120
- rmSync(paths.lease, { force: true });
121
- } catch {
122
- // A lease we cannot delete simply goes stale.
123
- }
124
- }
125
-
126
- export function readLease(paths: SchedulePaths): LeaseRecord | undefined {
127
- try {
128
- const parsed: unknown = JSON.parse(readFileSync(paths.lease, "utf8"));
129
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
130
- const record = parsed as Record<string, unknown>;
131
- if (
132
- typeof record.pid !== "number" ||
133
- typeof record.heartbeat !== "number" ||
134
- typeof record.owner !== "string" ||
135
- !record.owner
136
- ) {
137
- return undefined;
138
- }
139
- return { pid: record.pid, heartbeat: record.heartbeat, owner: record.owner };
140
- } catch {
141
- return undefined;
142
- }
143
- }
144
-
145
- function writeLease(paths: SchedulePaths, lease: LeaseRecord): boolean {
146
- const temporary = join(dirname(paths.lease), `.${LEASE_FILE}.${randomUUID()}.tmp`);
147
- try {
148
- mkdirSync(paths.dir, { recursive: true });
149
- writeFileSync(temporary, `${JSON.stringify(lease)}\n`, { encoding: "utf8", flag: "wx" });
150
- renameSync(temporary, paths.lease);
151
- return true;
152
- } catch {
153
- return false;
154
- } finally {
155
- try {
156
- rmSync(temporary, { force: true });
157
- } catch {
158
- // Best-effort.
159
- }
160
- }
161
- }
162
-
163
- function isProcessAlive(pid: number): boolean {
164
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
165
- try {
166
- // Signal 0 tests for existence without delivering anything.
167
- process.kill(pid, 0);
168
- return true;
169
- } catch (error) {
170
- // EPERM means it exists and belongs to someone else.
171
- return isNodeError(error) && error.code === "EPERM";
172
- }
173
- }
174
-
175
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
176
- return error instanceof Error && "code" in error;
177
- }
178
-
179
- /** Where a headless run's output is logged. */
180
- export function runLogPath(paths: SchedulePaths, taskId: string, at: number): string {
181
- const stamp = new Date(at).toISOString().replace(/[:.]/g, "-");
182
- return join(paths.runs, taskId, `${stamp}.log`);
183
- }