@hank-warren/pi-loop 0.7.0 → 0.9.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,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
- }