pi-cc-loop 0.1.1 → 0.3.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.
package/durable.ts ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Durable jobs: `.pi/scheduled_tasks.json`, shared by every Pi session in a
3
+ * project, and the lock that decides which session runs them.
4
+ *
5
+ * Claude Code keeps durable tasks in `.claude/scheduled_tasks.json` and uses
6
+ * `.claude/scheduled_tasks.lock` so that only one session fires them. Its docs
7
+ * add: "When the `.claude` directory or that file is a symlink, Claude Code
8
+ * returns an error instead of scheduling the task. A saved task runs only in
9
+ * the project folder where you created it. If you copy the file into another
10
+ * folder, such as a new worktree, sessions there list the copied tasks but
11
+ * don't run them."
12
+ *
13
+ * Rules here:
14
+ * - Every write is a read-modify-write of one job, so sessions never
15
+ * overwrite each other's jobs, and the file is never deleted wholesale.
16
+ * - The session holding the lock runs the durable jobs and picks up jobs
17
+ * other sessions add or remove. The lock carries a heartbeat; a lock whose
18
+ * heartbeat is older than a minute (or whose process is gone) is stale and
19
+ * can be taken over.
20
+ * - The runtime files are added to `.git/info/exclude`.
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { nextRunTime, storedJob, type LoopJob, type LoopScheduler, type TimingConfig } from "./schedule.ts";
27
+
28
+ export const DURABLE_FILE = path.join(".pi", "scheduled_tasks.json");
29
+ export const LOCK_FILE = path.join(".pi", "scheduled_tasks.lock");
30
+ /** A lock whose heartbeat is older than this is stale. */
31
+ export const LOCK_STALE_MS = 60_000;
32
+ const HEARTBEAT_MS = 15_000;
33
+
34
+ interface DurableFileData {
35
+ version: 1;
36
+ jobs: LoopJob[];
37
+ }
38
+
39
+ function writeAtomic(file: string, text: string): void {
40
+ const temp = `${file}.${process.pid}.tmp`;
41
+ fs.writeFileSync(temp, text, "utf8");
42
+ fs.renameSync(temp, file);
43
+ }
44
+
45
+ function isSymlink(file: string): boolean {
46
+ try {
47
+ return fs.lstatSync(file).isSymbolicLink();
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /** `.pi/scheduled_tasks.json`, edited one job at a time. */
54
+ export class DurableStore {
55
+ readonly file: string;
56
+ readonly dir: string;
57
+
58
+ constructor(readonly cwd: string) {
59
+ this.dir = path.join(cwd, ".pi");
60
+ this.file = path.join(cwd, DURABLE_FILE);
61
+ }
62
+
63
+ exists(): boolean {
64
+ return fs.existsSync(this.file);
65
+ }
66
+
67
+ read(): LoopJob[] {
68
+ try {
69
+ const data = JSON.parse(fs.readFileSync(this.file, "utf8")) as Partial<DurableFileData>;
70
+ return Array.isArray(data.jobs) ? data.jobs.filter((job) => job && typeof job.id === "string") : [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ /** Modification time, to notice changes made by other sessions. */
77
+ mtime(): number | undefined {
78
+ try {
79
+ return fs.statSync(this.file).mtimeMs;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /** Like Claude Code, refuse to write through a symlinked `.pi` or task file. */
86
+ assertWritable(): void {
87
+ if (isSymlink(this.dir) || isSymlink(this.file)) {
88
+ throw new Error(
89
+ `Refusing to write durable jobs: ${isSymlink(this.dir) ? this.dir : this.file} is a symlink.`,
90
+ );
91
+ }
92
+ }
93
+
94
+ /** Insert or replace one job, keeping every other job in the file. */
95
+ upsert(job: LoopJob): void {
96
+ this.assertWritable();
97
+ const jobs = this.read().filter((other) => other.id !== job.id);
98
+ jobs.push(storedJob(job));
99
+ this.write(jobs);
100
+ }
101
+
102
+ remove(id: string): boolean {
103
+ const jobs = this.read();
104
+ const kept = jobs.filter((job) => job.id !== id);
105
+ if (kept.length === jobs.length) return false;
106
+ this.write(kept);
107
+ return true;
108
+ }
109
+
110
+ private write(jobs: LoopJob[]): void {
111
+ fs.mkdirSync(this.dir, { recursive: true });
112
+ const data: DurableFileData = { version: 1, jobs };
113
+ writeAtomic(this.file, `${JSON.stringify(data, null, 2)}\n`);
114
+ }
115
+ }
116
+
117
+ interface LockInfo {
118
+ pid: number;
119
+ hostname: string;
120
+ acquiredAt: number;
121
+ heartbeatAt: number;
122
+ }
123
+
124
+ function pidAlive(pid: number): boolean {
125
+ try {
126
+ process.kill(pid, 0);
127
+ return true;
128
+ } catch (error) {
129
+ return (error as NodeJS.ErrnoException).code === "EPERM";
130
+ }
131
+ }
132
+
133
+ /** Who a lock belongs to. Injectable so tests can play several processes. */
134
+ export interface LockIdentity {
135
+ pid?: number;
136
+ hostname?: string;
137
+ isAlive?: (pid: number) => boolean;
138
+ staleMs?: number;
139
+ }
140
+
141
+ /** `.pi/scheduled_tasks.lock`: which session runs the project's durable jobs. */
142
+ export class SchedulerLock {
143
+ readonly file: string;
144
+ owned = false;
145
+ private readonly pid: number;
146
+ private readonly hostname: string;
147
+ private readonly isAlive: (pid: number) => boolean;
148
+ private readonly staleMs: number;
149
+
150
+ constructor(cwd: string, identity: LockIdentity = {}) {
151
+ this.file = path.join(cwd, LOCK_FILE);
152
+ this.pid = identity.pid ?? process.pid;
153
+ this.hostname = identity.hostname ?? os.hostname();
154
+ this.isAlive = identity.isAlive ?? pidAlive;
155
+ this.staleMs = identity.staleMs ?? LOCK_STALE_MS;
156
+ }
157
+
158
+ holder(): LockInfo | null {
159
+ try {
160
+ const info = JSON.parse(fs.readFileSync(this.file, "utf8")) as LockInfo;
161
+ return typeof info.pid === "number" ? info : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ isStale(info: LockInfo, now: number): boolean {
168
+ if (now - info.heartbeatAt > this.staleMs) return true;
169
+ return info.hostname === this.hostname && !this.isAlive(info.pid);
170
+ }
171
+
172
+ tryAcquire(now = Date.now()): boolean {
173
+ if (this.owned) return true;
174
+ for (let attempt = 0; attempt < 2; attempt++) {
175
+ try {
176
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
177
+ const fd = fs.openSync(this.file, "wx");
178
+ const info: LockInfo = { pid: this.pid, hostname: this.hostname, acquiredAt: now, heartbeatAt: now };
179
+ fs.writeSync(fd, `${JSON.stringify(info)}\n`);
180
+ fs.closeSync(fd);
181
+ this.owned = true;
182
+ return true;
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return false;
185
+ const info = this.holder();
186
+ // The same process after /reload, a dead process, or a missed heartbeat.
187
+ const takeOver = !info || info.pid === this.pid || this.isStale(info, now);
188
+ if (!takeOver) return false;
189
+ try {
190
+ fs.rmSync(this.file, { force: true });
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+
199
+ heartbeat(now = Date.now()): void {
200
+ if (!this.owned) return;
201
+ const info = this.holder();
202
+ if (!info || info.pid !== this.pid) {
203
+ // Someone took the lock over (we were considered stale).
204
+ this.owned = false;
205
+ return;
206
+ }
207
+ try {
208
+ writeAtomic(this.file, `${JSON.stringify({ ...info, heartbeatAt: now })}\n`);
209
+ } catch {
210
+ // a missed heartbeat only matters after a minute
211
+ }
212
+ }
213
+
214
+ release(): void {
215
+ if (!this.owned) return;
216
+ this.owned = false;
217
+ if (this.holder()?.pid === this.pid) {
218
+ try {
219
+ fs.rmSync(this.file, { force: true });
220
+ } catch {
221
+ // a stale lock is taken over after a minute anyway
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ /** The git directory that owns `cwd`, following worktree `.git` files. */
228
+ function gitCommonDir(cwd: string): string | undefined {
229
+ let dir = path.resolve(cwd);
230
+ for (;;) {
231
+ const dotGit = path.join(dir, ".git");
232
+ try {
233
+ const stat = fs.statSync(dotGit);
234
+ if (stat.isDirectory()) return dotGit;
235
+ const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, "utf8"));
236
+ if (!match) return undefined;
237
+ const gitDir = path.resolve(dir, match[1].trim());
238
+ try {
239
+ return path.resolve(gitDir, fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim());
240
+ } catch {
241
+ return gitDir;
242
+ }
243
+ } catch {
244
+ // no .git here; keep walking up
245
+ }
246
+ const parent = path.dirname(dir);
247
+ if (parent === dir) return undefined;
248
+ dir = parent;
249
+ }
250
+ }
251
+
252
+ /** Add our runtime files to `.git/info/exclude`, like Claude Code does for its own. */
253
+ export function ensureGitExclude(cwd: string): void {
254
+ try {
255
+ const common = gitCommonDir(cwd);
256
+ if (!common) return;
257
+ const file = path.join(common, "info", "exclude");
258
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
259
+ const lines = current.split("\n").map((line) => line.trim());
260
+ const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock"];
261
+ const missing = wanted.filter((entry) => !lines.includes(entry));
262
+ if (!missing.length) return;
263
+ fs.mkdirSync(path.dirname(file), { recursive: true });
264
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
265
+ fs.appendFileSync(file, `${prefix}# pi-cc-loop runtime files\n${missing.join("\n")}\n`);
266
+ } catch {
267
+ // best effort
268
+ }
269
+ }
270
+
271
+ export interface DurableRuntimeOptions {
272
+ cwd: string;
273
+ scheduler: LoopScheduler;
274
+ timing: () => TimingConfig;
275
+ maxAgeMs: () => number;
276
+ /** How often housekeeping runs (heartbeat, sync, lock attempts). */
277
+ syncMs: () => number;
278
+ /** One-shot jobs that came due while no session ran them. */
279
+ onMissed: (jobs: LoopJob[]) => void;
280
+ /** Lock identity; tests use it to play several Pi processes. */
281
+ lock?: LockIdentity;
282
+ }
283
+
284
+ /** Where a durable job runs, for the tool result. */
285
+ export interface Placement {
286
+ runsHere: boolean;
287
+ holderPid?: number;
288
+ }
289
+
290
+ /** The durable-jobs side of one session. */
291
+ export class DurableRuntime {
292
+ readonly store: DurableStore;
293
+ private readonly lock: SchedulerLock;
294
+ private lastMtime: number | undefined;
295
+ private lastMaintenance = 0;
296
+ private lastHeartbeat = 0;
297
+
298
+ constructor(private readonly options: DurableRuntimeOptions) {
299
+ this.store = new DurableStore(options.cwd);
300
+ this.lock = new SchedulerLock(options.cwd, options.lock);
301
+ }
302
+
303
+ get owner(): boolean {
304
+ return this.lock.owned;
305
+ }
306
+
307
+ holderPid(): number | undefined {
308
+ return this.lock.holder()?.pid;
309
+ }
310
+
311
+ /** Take the lock if the project has durable jobs, then load them. */
312
+ start(now = Date.now()): void {
313
+ if (this.store.exists()) this.acquireAndLoad(now);
314
+ }
315
+
316
+ stop(): void {
317
+ this.lock.release();
318
+ }
319
+
320
+ /** Called on every scheduler scan; throttled. */
321
+ maintain(now = Date.now()): void {
322
+ if (now - this.lastMaintenance < this.options.syncMs()) return;
323
+ this.lastMaintenance = now;
324
+ if (!this.owner) {
325
+ if (this.store.exists()) this.acquireAndLoad(now);
326
+ return;
327
+ }
328
+ if (now - this.lastHeartbeat >= HEARTBEAT_MS) {
329
+ this.lastHeartbeat = now;
330
+ this.lock.heartbeat(now);
331
+ if (!this.owner) {
332
+ this.dropLoadedJobs();
333
+ return;
334
+ }
335
+ }
336
+ const mtime = this.store.mtime();
337
+ if (mtime !== this.lastMtime) this.sync(now);
338
+ }
339
+
340
+ /**
341
+ * Save a new durable job. It runs here when this session holds (or can
342
+ * take) the lock; otherwise the session that holds it picks it up.
343
+ */
344
+ add(job: LoopJob, now = Date.now()): Placement {
345
+ this.store.assertWritable();
346
+ job.createdInProject = path.resolve(this.options.cwd);
347
+ this.store.upsert(job);
348
+ ensureGitExclude(this.options.cwd);
349
+ if (this.owner) {
350
+ this.options.scheduler.add(job);
351
+ this.save(job);
352
+ } else {
353
+ this.acquireAndLoad(now);
354
+ }
355
+ return this.owner ? { runsHere: true } : { runsHere: false, holderPid: this.holderPid() };
356
+ }
357
+
358
+ /** Remove a durable job from the file. */
359
+ remove(id: string): boolean {
360
+ const removed = this.store.remove(id);
361
+ this.lastMtime = this.store.mtime();
362
+ return removed;
363
+ }
364
+
365
+ /** Record a fire: update the job in the file, or remove it after its last run. */
366
+ afterFire(job: LoopJob, finished: boolean): void {
367
+ try {
368
+ if (finished) this.store.remove(job.id);
369
+ else this.store.upsert(job);
370
+ this.lastMtime = this.store.mtime();
371
+ } catch {
372
+ // best effort
373
+ }
374
+ }
375
+
376
+ /** Durable jobs this session does not run (listed, not fired). */
377
+ foreignJobs(): Array<LoopJob & { foreignReason: "other-session" | "other-folder" }> {
378
+ return this.store
379
+ .read()
380
+ .filter((job) => !this.options.scheduler.get(job.id))
381
+ .map((job) => ({
382
+ ...job,
383
+ nextFireAt: nextRunTime(job, job.recurring ? Date.now() : job.createdAt, this.options.timing()) ?? undefined,
384
+ foreignReason: this.ownsProject(job) ? ("other-session" as const) : ("other-folder" as const),
385
+ }));
386
+ }
387
+
388
+ private ownsProject(job: LoopJob): boolean {
389
+ return !job.createdInProject || path.resolve(job.createdInProject) === path.resolve(this.options.cwd);
390
+ }
391
+
392
+ private save(job: LoopJob): void {
393
+ try {
394
+ this.store.upsert(job);
395
+ this.lastMtime = this.store.mtime();
396
+ } catch {
397
+ // best effort
398
+ }
399
+ }
400
+
401
+ private acquireAndLoad(now: number): void {
402
+ if (!this.lock.tryAcquire(now)) return;
403
+ this.lastHeartbeat = now;
404
+ this.sync(now);
405
+ }
406
+
407
+ /** Bring the scheduler in line with the file: adopt new jobs, drop removed ones. */
408
+ private sync(now: number): void {
409
+ const { scheduler } = this.options;
410
+ const jobs = this.store.read();
411
+ const ids = new Set(jobs.map((job) => job.id));
412
+ for (const job of scheduler.list()) {
413
+ if (job.durable && !ids.has(job.id)) scheduler.remove(job.id);
414
+ }
415
+
416
+ const missed: LoopJob[] = [];
417
+ const maxAge = this.options.maxAgeMs();
418
+ let adopted = false;
419
+ for (const stored of jobs) {
420
+ if (scheduler.get(stored.id) || !this.ownsProject(stored)) continue;
421
+ const job = { ...storedJob(stored), durable: true };
422
+ if (!job.recurring) {
423
+ const due = nextRunTime(job, job.createdAt, this.options.timing());
424
+ if (due === null || due <= now) {
425
+ missed.push(job);
426
+ this.store.remove(job.id);
427
+ continue;
428
+ }
429
+ } else if (maxAge > 0 && now - job.createdAt >= maxAge) {
430
+ // Expired while nobody ran it: dropped, like a restored expired task.
431
+ this.store.remove(job.id);
432
+ continue;
433
+ }
434
+ scheduler.restore(job);
435
+ scheduler.arm(job, now);
436
+ adopted = true;
437
+ }
438
+ if (adopted) scheduler.refresh();
439
+ this.lastMtime = this.store.mtime();
440
+ if (missed.length) this.options.onMissed(missed);
441
+ }
442
+
443
+ private dropLoadedJobs(): void {
444
+ const { scheduler } = this.options;
445
+ for (const job of scheduler.list()) if (job.durable) scheduler.remove(job.id);
446
+ }
447
+ }