@jl1990/pi-scheduler 0.3.1 → 0.4.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 (43) hide show
  1. package/README.md +4 -1
  2. package/extensions/scheduler/index.ts +191 -135
  3. package/extensions/scheduler/scheduler-coordination.cjs +55 -0
  4. package/extensions/scheduler/scheduler-core.cjs +31 -4
  5. package/extensions/scheduler/task-store.cjs +64 -0
  6. package/node_modules/graceful-fs/LICENSE +15 -0
  7. package/node_modules/graceful-fs/README.md +143 -0
  8. package/node_modules/graceful-fs/clone.js +23 -0
  9. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  10. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  11. package/node_modules/graceful-fs/package.json +53 -0
  12. package/node_modules/graceful-fs/polyfills.js +355 -0
  13. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  14. package/node_modules/proper-lockfile/LICENSE +21 -0
  15. package/node_modules/proper-lockfile/README.md +183 -0
  16. package/node_modules/proper-lockfile/index.js +40 -0
  17. package/node_modules/proper-lockfile/lib/adapter.js +85 -0
  18. package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
  19. package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
  20. package/node_modules/proper-lockfile/package.json +71 -0
  21. package/node_modules/retry/.npmignore +3 -0
  22. package/node_modules/retry/.travis.yml +15 -0
  23. package/node_modules/retry/License +21 -0
  24. package/node_modules/retry/Makefile +18 -0
  25. package/node_modules/retry/README.md +227 -0
  26. package/node_modules/retry/equation.gif +0 -0
  27. package/node_modules/retry/example/dns.js +31 -0
  28. package/node_modules/retry/example/stop.js +40 -0
  29. package/node_modules/retry/index.js +1 -0
  30. package/node_modules/retry/lib/retry.js +100 -0
  31. package/node_modules/retry/lib/retry_operation.js +158 -0
  32. package/node_modules/retry/package.json +32 -0
  33. package/node_modules/retry/test/common.js +10 -0
  34. package/node_modules/retry/test/integration/test-forever.js +24 -0
  35. package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
  36. package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
  37. package/node_modules/retry/test/integration/test-timeouts.js +69 -0
  38. package/node_modules/signal-exit/LICENSE.txt +16 -0
  39. package/node_modules/signal-exit/README.md +39 -0
  40. package/node_modules/signal-exit/index.js +202 -0
  41. package/node_modules/signal-exit/package.json +38 -0
  42. package/node_modules/signal-exit/signals.js +53 -0
  43. package/package.json +5 -3
package/README.md CHANGED
@@ -28,6 +28,7 @@ Without scheduling, the agent has to stop and hope you come back. With Pi Schedu
28
28
  - **Scopes** — bind tasks to a session, cwd/project, or all sessions.
29
29
  - **Compact widget** — shows the next few scheduled actions below the editor.
30
30
  - **Persistent state** — scheduled tasks are stored in `~/.pi/agent/state/scheduler/tasks.json`.
31
+ - **Multi-process coordination** — atomic state transactions and task claims allow multiple Pi agents to share a cwd safely.
31
32
 
32
33
  ## Install
33
34
 
@@ -235,7 +236,9 @@ Cron catch-up can be configured with environment variables:
235
236
 
236
237
  Invalid, negative, or non-finite values fall back to the defaults; `PI_SCHEDULER_CATCHUP_MAX` must also be a whole number. If Pi stopped while a task was already running, the interrupted attempt is recorded as failed rather than retried blindly: one-shot tasks remain failed, while recurring tasks are rescheduled from startup time.
237
238
 
238
- Task state is shared but not cross-process locked. Running multiple Pi processes against the same `cwd` or `global` tasks can race, so use those scopes from one active Pi process at a time.
239
+ Task state is coordinated across Pi processes with an atomic filesystem lock. Multiple agents may share the same cwd: all relevant processes can observe `cwd` tasks, while an atomic claim ensures only one process executes each due occurrence. The process that wins the claim receives prompt/message follow-ups. Use `session` scope when a follow-up must return to one specific Pi session.
240
+
241
+ Running processes refresh shared scheduler state every five seconds so they can discover tasks created by another agent. `global` tasks use the same single-claim behavior across all running Pi processes.
239
242
 
240
243
  This is enough for live agent workflows like CI polling while a Pi session is open. A future version could add OS-level `cron`, `at`, launchd, systemd, or a small daemon for exact wakeups while Pi is not running.
241
244
 
@@ -3,14 +3,15 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Cron } from "croner";
5
5
  import { randomUUID } from "node:crypto";
6
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
7
6
  import { homedir } from "node:os";
8
- import { dirname, join } from "node:path";
7
+ import { join } from "node:path";
9
8
  import { Type } from "typebox";
10
9
 
11
10
  // Keep the scheduler logic testable from plain node --test.
12
11
  const core = require("./scheduler-core.cjs");
13
12
  const lifecycle = require("./scheduler-lifecycle.cjs");
13
+ const coordination = require("./scheduler-coordination.cjs");
14
+ const { createTaskStore } = require("./task-store.cjs");
14
15
 
15
16
  const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
16
17
  const TYPES = ["once", "interval", "cron"] as const;
@@ -18,8 +19,9 @@ const SCOPES = ["session", "cwd", "global"] as const;
18
19
  const WAKE_ON = ["always", "failure", "success", "never"] as const;
19
20
  const MANAGE_ACTIONS = ["enable", "disable", "remove", "update", "cleanup"] as const;
20
21
 
21
- const STATE_FILE = join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
22
+ const STATE_FILE = process.env.PI_SCHEDULER_STATE_FILE || join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
22
23
  const MAX_TIMER_DELAY_MS = 2_147_483_647; // setTimeout's practical max (~24.8 days)
24
+ const STATE_REFRESH_INTERVAL_MS = 5_000;
23
25
  const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
24
26
  const MAX_STORED_OUTPUT_CHARS = 12_000;
25
27
  const MAX_PROMPT_OUTPUT_CHARS = 18_000;
@@ -119,37 +121,35 @@ export default function schedulerExtension(pi: ExtensionAPI) {
119
121
  let handles = new Map<string, TimerHandle>();
120
122
  let activeCtx: ExtensionContext | undefined;
121
123
  let sessionGeneration = 0;
122
- let saveQueue: Promise<void> = Promise.resolve();
124
+ let stateRevision = -1;
123
125
  let widgetEnabled = true;
124
126
  const firing = new Set<string>();
127
+ const store = createTaskStore({ stateFile: STATE_FILE, sanitize: core.sanitizeTasks });
125
128
 
126
129
  function isSessionActive(ctx: ExtensionContext, generation = sessionGeneration): boolean {
127
130
  return lifecycle.isSessionContextActive(activeCtx, ctx, sessionGeneration, generation);
128
131
  }
129
132
 
130
- async function loadTasks(): Promise<void> {
131
- try {
132
- const raw = await readFile(STATE_FILE, "utf8");
133
- const parsed = JSON.parse(raw);
134
- tasks = core.sanitizeTasks(parsed.tasks ?? parsed);
135
- } catch (error: any) {
136
- if (error?.code === "ENOENT") {
137
- tasks = [];
138
- return;
139
- }
140
- throw error;
141
- }
133
+ async function loadTasks(): Promise<boolean> {
134
+ const snapshot = await store.read();
135
+ return coordination.reconcileSnapshot(
136
+ snapshot,
137
+ stateRevision,
138
+ (nextTasks: ScheduledTask[], revision: number) => {
139
+ tasks = nextTasks;
140
+ stateRevision = revision;
141
+ },
142
+ () => rescheduleAll(),
143
+ );
142
144
  }
143
145
 
144
- async function saveTasks(): Promise<void> {
145
- const payload = JSON.stringify({ version: 2, updatedAt: new Date().toISOString(), tasks }, null, 2) + "\n";
146
- saveQueue = saveQueue.then(async () => {
147
- await mkdir(dirname(STATE_FILE), { recursive: true });
148
- const tmp = `${STATE_FILE}.${process.pid}.tmp`;
149
- await writeFile(tmp, payload, "utf8");
150
- await rename(tmp, STATE_FILE);
151
- });
152
- return saveQueue;
146
+ async function transactTasks<T>(mutator: (current: ScheduledTask[]) => T | Promise<T>): Promise<T> {
147
+ const transaction = await store.transact(mutator);
148
+ if (transaction.revision >= stateRevision) {
149
+ tasks = transaction.tasks;
150
+ stateRevision = transaction.revision;
151
+ }
152
+ return transaction.result;
153
153
  }
154
154
 
155
155
  function clearHandle(id: string): void {
@@ -213,11 +213,15 @@ export default function schedulerExtension(pi: ExtensionAPI) {
213
213
  });
214
214
  handles.set(task.id, { kind: "cron", handle: cron });
215
215
  } catch (error: any) {
216
- task.enabled = false;
217
- task.status = "failed";
218
- task.lastStatus = "error";
219
- task.lastError = error?.message ?? String(error);
220
- void saveTasks();
216
+ const message = error?.message ?? String(error);
217
+ void transactTasks((current) => {
218
+ const persisted = current.find((candidate) => candidate.id === task.id);
219
+ if (!persisted) return;
220
+ persisted.enabled = false;
221
+ persisted.status = "failed";
222
+ persisted.lastStatus = "error";
223
+ persisted.lastError = message;
224
+ });
221
225
  }
222
226
  return;
223
227
  }
@@ -239,13 +243,45 @@ export default function schedulerExtension(pi: ExtensionAPI) {
239
243
  handles.set(task.id, { kind: "timeout", handle: timer });
240
244
  }
241
245
 
242
- function rescheduleAll(ctx = activeCtx, generation = sessionGeneration): void {
246
+ function rescheduleAll(generation = sessionGeneration): void {
247
+ const ctx = activeCtx;
243
248
  if (!ctx || !isSessionActive(ctx, generation)) return;
244
249
  clearTimers();
245
250
  for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
246
251
  updateStatus(ctx);
247
252
  }
248
253
 
254
+ async function refreshFromStore(generation = sessionGeneration): Promise<void> {
255
+ const ctx = activeCtx;
256
+ if (!ctx || !isSessionActive(ctx, generation)) return;
257
+ await coordination.refreshSchedulerState({
258
+ store,
259
+ currentRevision: () => stateRevision,
260
+ isOwnerActive: isRunOwnerActive,
261
+ recoverInterrupted: core.recoverInterruptedTasks,
262
+ now: () => new Date(),
263
+ install: (nextTasks: ScheduledTask[], revision: number) => {
264
+ tasks = nextTasks;
265
+ stateRevision = revision;
266
+ },
267
+ reconcile: () => rescheduleAll(generation),
268
+ });
269
+ }
270
+
271
+ const refreshLoop = coordination.createRefreshLoop({
272
+ intervalMs: STATE_REFRESH_INTERVAL_MS,
273
+ run: refreshFromStore,
274
+ onError: (error: any) => {
275
+ if (activeCtx?.hasUI) activeCtx.ui.notify(`Scheduler state refresh failed: ${error?.message ?? String(error)}`, "error");
276
+ },
277
+ setInterval,
278
+ clearInterval,
279
+ });
280
+
281
+ function startStateRefresh(generation: number): void {
282
+ refreshLoop.start(generation);
283
+ }
284
+
249
285
  async function catchUpOverdueCronTasks(
250
286
  ctx: ExtensionContext,
251
287
  generation: number,
@@ -263,11 +299,10 @@ export default function schedulerExtension(pi: ExtensionAPI) {
263
299
 
264
300
  for (const { task, missedAt } of overdue) {
265
301
  if (!isSessionActive(ctx, generation)) return;
266
- // Record the most recent missed occurrence so prompts and persisted state
267
- // describe the run being caught up, not the first occurrence missed.
268
- task.nextRun = missedAt.toISOString();
269
- task.dueAt = task.nextRun;
270
- await fireTask(task.id, ctx, generation);
302
+ await fireTask(task.id, ctx, generation, {
303
+ expectedNextRun: task.nextRun ?? task.dueAt,
304
+ occurrence: missedAt.toISOString(),
305
+ });
271
306
  }
272
307
  }
273
308
 
@@ -343,51 +378,75 @@ export default function schedulerExtension(pi: ExtensionAPI) {
343
378
  throw new Error(`Unsupported scheduled action: ${task.action}`);
344
379
  }
345
380
 
346
- async function fireTask(taskId: string, ctx: ExtensionContext, generation = sessionGeneration): Promise<void> {
347
- if (!isSessionActive(ctx, generation)) return;
348
- const task = tasks.find((candidate) => candidate.id === taskId);
349
- if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
350
- if (!taskBelongsToSession(task, ctx)) return;
381
+ async function fireTask(
382
+ taskId: string,
383
+ ctx: ExtensionContext,
384
+ generation = sessionGeneration,
385
+ claimOptions?: { expectedNextRun?: string; occurrence?: string },
386
+ ): Promise<void> {
387
+ if (!isSessionActive(ctx, generation) || firing.has(taskId)) return;
351
388
 
352
- firing.add(task.id);
389
+ firing.add(taskId);
353
390
  const attemptId = randomUUID();
391
+ let task: ScheduledTask | undefined;
354
392
  try {
355
- core.markScheduledTaskRunning(tasks, task.id, new Date(), {
356
- runOwner: { pid: process.pid, attemptId, sessionFile: currentSessionFile(ctx) },
393
+ // Claim under the shared state lock. Other Pi processes may have armed the
394
+ // same cwd/global task, but only one can transition it to running.
395
+ task = await transactTasks((current) => {
396
+ const candidate = current.find((item) => item.id === taskId);
397
+ if (!candidate || candidate.enabled === false || candidate.status !== "pending") return undefined;
398
+ if (!taskBelongsToSession(candidate, ctx)) return undefined;
399
+ const persistedNextRun = candidate.nextRun ?? candidate.dueAt;
400
+ if (claimOptions?.expectedNextRun && persistedNextRun !== claimOptions.expectedNextRun) return undefined;
401
+ const dueAt = Date.parse(claimOptions?.occurrence ?? persistedNextRun ?? "");
402
+ if (!Number.isFinite(dueAt) || dueAt > Date.now()) return undefined;
403
+ if (claimOptions?.occurrence) {
404
+ candidate.nextRun = claimOptions.occurrence;
405
+ candidate.dueAt = claimOptions.occurrence;
406
+ }
407
+ core.markScheduledTaskRunning(current, candidate.id, new Date(), {
408
+ runOwner: { pid: process.pid, attemptId, sessionFile: currentSessionFile(ctx) },
409
+ });
410
+ return { ...candidate, runOwner: { ...candidate.runOwner } };
357
411
  });
358
- await saveTasks();
412
+ if (!task) return;
413
+
359
414
  if (!isSessionActive(ctx, generation)) {
360
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
361
- if (currentTask?.runOwner?.attemptId === attemptId) {
362
- currentTask.status = "pending";
363
- currentTask.lastStatus = "error";
364
- currentTask.lastError = "Scheduled task start was cancelled because the Pi session changed";
365
- delete currentTask.runOwner;
366
- delete currentTask.startedAt;
367
- await saveTasks();
368
- }
415
+ await transactTasks((current) => {
416
+ const persisted = current.find((candidate) => candidate.id === taskId);
417
+ if (persisted?.runOwner?.attemptId !== attemptId) return;
418
+ persisted.status = "pending";
419
+ persisted.lastStatus = "error";
420
+ persisted.lastError = "Scheduled task start was cancelled because the Pi session changed";
421
+ delete persisted.runOwner;
422
+ delete persisted.startedAt;
423
+ });
369
424
  return;
370
425
  }
426
+
371
427
  updateStatus(ctx);
372
428
  const result = await executeTask(task, ctx, () => isSessionActive(ctx, generation));
373
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
374
- if (currentTask?.runOwner?.attemptId !== attemptId) return;
375
- core.markScheduledTaskCompleted(tasks, currentTask.id, new Date(), result, { ok: result.ok !== false });
376
- await saveTasks();
429
+ await transactTasks((current) => {
430
+ const persisted = current.find((candidate) => candidate.id === taskId);
431
+ if (persisted?.runOwner?.attemptId !== attemptId) return;
432
+ core.markScheduledTaskCompleted(current, persisted.id, new Date(), result, { ok: result.ok !== false });
433
+ });
377
434
  } catch (error: any) {
378
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
379
- if (currentTask?.runOwner?.attemptId !== attemptId) return;
380
- core.markScheduledTaskFailed(tasks, currentTask.id, new Date(), error);
381
- await saveTasks();
382
- if (isSessionActive(ctx, generation)) {
383
- const message = `Scheduled task ${currentTask.id} failed: ${error?.message ?? String(error)}`;
435
+ let failedTask: ScheduledTask | undefined;
436
+ await transactTasks((current) => {
437
+ const persisted = current.find((candidate) => candidate.id === taskId);
438
+ if (persisted?.runOwner?.attemptId !== attemptId) return;
439
+ core.markScheduledTaskFailed(current, persisted.id, new Date(), error);
440
+ failedTask = { ...persisted };
441
+ });
442
+ if (failedTask && isSessionActive(ctx, generation)) {
443
+ const message = `Scheduled task ${taskId} failed: ${error?.message ?? String(error)}`;
384
444
  if (ctx.hasUI) ctx.ui.notify(message, "error");
385
- recordMessage(`⚠️ ${message}`, { task: currentTask, error: error?.message ?? String(error) }, false);
445
+ recordMessage(`⚠️ ${message}`, { task: failedTask, error: error?.message ?? String(error) }, false);
386
446
  }
387
447
  } finally {
388
- firing.delete(task.id);
389
- if (isSessionActive(ctx, generation)) rescheduleAll(ctx, generation);
390
- else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
448
+ firing.delete(taskId);
449
+ if (activeCtx) rescheduleAll(sessionGeneration);
391
450
  }
392
451
  }
393
452
 
@@ -397,19 +456,21 @@ export default function schedulerExtension(pi: ExtensionAPI) {
397
456
  if (scope === "session" && !sessionFile) {
398
457
  throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
399
458
  }
400
- const task = core.createScheduledTask(
401
- {
402
- ...input,
403
- schedule: input.schedule ?? input.when ?? input.whenText,
404
- cwd: input.cwd ?? ctx.cwd,
405
- scope,
406
- sessionFile,
407
- },
408
- new Date(),
409
- );
410
- tasks.push(task);
411
- await saveTasks();
412
- rescheduleAll(ctx);
459
+ const task = await transactTasks((current) => {
460
+ const created = core.createScheduledTask(
461
+ {
462
+ ...input,
463
+ schedule: input.schedule ?? input.when ?? input.whenText,
464
+ cwd: input.cwd ?? ctx.cwd,
465
+ scope,
466
+ sessionFile,
467
+ },
468
+ new Date(),
469
+ );
470
+ current.push(created);
471
+ return created;
472
+ });
473
+ rescheduleAll();
413
474
  return task;
414
475
  }
415
476
 
@@ -426,12 +487,12 @@ export default function schedulerExtension(pi: ExtensionAPI) {
426
487
  return { ...base, message: parsed.payload };
427
488
  }
428
489
 
429
- function cleanupVisibleTasks(ctx: ExtensionContext): ScheduledTask[] {
430
- const removable = visibleTasks(ctx).filter(
431
- (task) => task.enabled === false || ["fired", "cancelled", "failed"].includes(task.status),
432
- );
490
+ function cleanupVisibleTasks(current: ScheduledTask[], ctx: ExtensionContext): ScheduledTask[] {
491
+ const removable = current
492
+ .filter((task) => taskBelongsToSession(task, ctx))
493
+ .filter((task) => task.enabled === false || ["fired", "cancelled", "failed"].includes(task.status));
433
494
  const removableIds = new Set(removable.map((task) => task.id));
434
- tasks = tasks.filter((task) => !removableIds.has(task.id));
495
+ current.splice(0, current.length, ...current.filter((task) => !removableIds.has(task.id)));
435
496
  for (const task of removable) clearHandle(task.id);
436
497
  return removable;
437
498
  }
@@ -441,11 +502,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
441
502
  id: string,
442
503
  mutator: (visible: ScheduledTask[]) => ScheduledTask,
443
504
  ): Promise<ScheduledTask> {
444
- await loadTasks();
445
- const visible = visibleTasks(ctx);
446
- const task = mutator(visible);
447
- await saveTasks();
448
- rescheduleAll(ctx);
505
+ const task = await transactTasks((current) => {
506
+ const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
507
+ return mutator(visible);
508
+ });
509
+ rescheduleAll();
449
510
  return task;
450
511
  }
451
512
 
@@ -460,22 +521,20 @@ export default function schedulerExtension(pi: ExtensionAPI) {
460
521
  pi.on("session_start", async (_event, ctx) => {
461
522
  activeCtx = ctx;
462
523
  const generation = ++sessionGeneration;
463
- await loadTasks();
524
+ const interrupted = await transactTasks((current) =>
525
+ core.recoverInterruptedTasks(current, new Date(), { isOwnerActive: isRunOwnerActive }),
526
+ );
464
527
  if (!isSessionActive(ctx, generation)) return;
465
528
 
466
- const interrupted = core.recoverInterruptedTasks(tasks, new Date(), { isOwnerActive: isRunOwnerActive });
467
- if (interrupted.length > 0) {
468
- await saveTasks();
469
- if (!isSessionActive(ctx, generation)) return;
470
- if (ctx.hasUI) {
471
- ctx.ui.notify(
472
- `Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
473
- "warning",
474
- );
475
- }
529
+ if (interrupted.length > 0 && ctx.hasUI) {
530
+ ctx.ui.notify(
531
+ `Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
532
+ "warning",
533
+ );
476
534
  }
477
535
 
478
- rescheduleAll(ctx, generation);
536
+ rescheduleAll(generation);
537
+ startStateRefresh(generation);
479
538
  const catchUpOptions = core.parseCatchUpOptions(process.env);
480
539
  void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
481
540
  if (isSessionActive(ctx, generation) && ctx.hasUI) {
@@ -485,9 +544,9 @@ export default function schedulerExtension(pi: ExtensionAPI) {
485
544
  });
486
545
 
487
546
  pi.on("session_shutdown", async (_event, ctx) => {
488
- if (!isSessionActive(ctx)) return;
489
547
  ++sessionGeneration;
490
548
  clearTimers();
549
+ refreshLoop.stop();
491
550
  if (ctx.hasUI) {
492
551
  ctx.ui.setStatus("scheduler", undefined);
493
552
  ctx.ui.setWidget("scheduler", undefined);
@@ -591,12 +650,13 @@ export default function schedulerExtension(pi: ExtensionAPI) {
591
650
  handler: async (args, ctx) => {
592
651
  const id = args.trim();
593
652
  try {
594
- await loadTasks();
595
- const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), id);
596
- const removed = core.removeScheduledTask(tasks, visibleRemoved.id);
653
+ const removed = await transactTasks((current) => {
654
+ const visible = current.filter((task) => taskBelongsToSession(task, ctx));
655
+ const visibleRemoved = core.removeScheduledTask(visible, id);
656
+ return core.removeScheduledTask(current, visibleRemoved.id);
657
+ });
597
658
  clearHandle(removed.id);
598
- await saveTasks();
599
- rescheduleAll(ctx);
659
+ rescheduleAll();
600
660
  ctx.ui.notify(`Removed scheduled task ${removed.id}`, "info");
601
661
  } catch (error: any) {
602
662
  ctx.ui.notify(error?.message ?? String(error), "error");
@@ -607,10 +667,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
607
667
  pi.registerCommand("schedule-cleanup", {
608
668
  description: "Remove disabled/completed/cancelled/failed scheduled tasks visible to this session",
609
669
  handler: async (_args, ctx) => {
610
- await loadTasks();
611
- const removed = cleanupVisibleTasks(ctx);
612
- await saveTasks();
613
- rescheduleAll(ctx);
670
+ const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
671
+ rescheduleAll();
614
672
  ctx.ui.notify(`Cleaned up ${removed.length} scheduled task(s)`, "info");
615
673
  },
616
674
  });
@@ -767,41 +825,39 @@ export default function schedulerExtension(pi: ExtensionAPI) {
767
825
  failurePrompt: Type.Optional(Type.String()),
768
826
  }),
769
827
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
770
- await loadTasks();
771
828
  if (params.action === "cleanup") {
772
- const removed = cleanupVisibleTasks(ctx);
773
- await saveTasks();
774
- rescheduleAll(ctx);
829
+ const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
830
+ rescheduleAll();
775
831
  return { content: [{ type: "text", text: `Cleaned up ${removed.length} scheduled task(s).` }], details: { removed } };
776
832
  }
777
833
 
778
834
  if (!params.id) throw new Error("id is required for this management action");
779
- let task: ScheduledTask;
780
- if (params.action === "enable") {
781
- task = core.enableScheduledTask(visibleTasks(ctx), params.id, new Date());
782
- } else if (params.action === "disable") {
783
- task = core.disableScheduledTask(visibleTasks(ctx), params.id, new Date());
784
- } else if (params.action === "remove") {
785
- const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), params.id);
786
- task = core.removeScheduledTask(tasks, visibleRemoved.id);
787
- clearHandle(task.id);
788
- } else {
835
+ const task = await transactTasks((current) => {
836
+ const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
837
+ if (params.action === "enable") {
838
+ return core.enableScheduledTask(visible, params.id, new Date());
839
+ }
840
+ if (params.action === "disable") {
841
+ return core.disableScheduledTask(visible, params.id, new Date());
842
+ }
843
+ if (params.action === "remove") {
844
+ const visibleRemoved = core.removeScheduledTask(visible, params.id);
845
+ return core.removeScheduledTask(current, visibleRemoved.id);
846
+ }
847
+
789
848
  const updates: Record<string, any> = { ...params };
790
849
  delete updates.action;
791
850
  delete updates.id;
792
851
  if (updates.scope === "session") {
793
852
  updates.sessionFile = currentSessionFile(ctx);
794
- if (!updates.sessionFile) {
795
- throw new Error("Session-scoped tasks require a persisted Pi session");
796
- }
853
+ if (!updates.sessionFile) throw new Error("Session-scoped tasks require a persisted Pi session");
797
854
  } else if (updates.scope !== undefined) {
798
855
  updates.sessionFile = null;
799
856
  }
800
- task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
801
- }
802
-
803
- await saveTasks();
804
- rescheduleAll(ctx);
857
+ return core.updateScheduledTask(visible, params.id, updates, new Date());
858
+ });
859
+ if (params.action === "remove") clearHandle(task.id);
860
+ rescheduleAll();
805
861
  return {
806
862
  content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
807
863
  details: { task, pending: core.pendingTasks(tasks) },
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ function reconcileSnapshot(snapshot, currentRevision, install, reconcile) {
4
+ if (snapshot.revision <= currentRevision) return false;
5
+ install(snapshot.tasks, snapshot.revision);
6
+ reconcile();
7
+ return true;
8
+ }
9
+
10
+ function needsInterruptedRunRecovery(tasks, isOwnerActive) {
11
+ return tasks.some((task) => task.status === "running" && !isOwnerActive(task.runOwner));
12
+ }
13
+
14
+ async function refreshSchedulerState(options) {
15
+ let snapshot = await options.store.read();
16
+ if (needsInterruptedRunRecovery(snapshot.tasks, options.isOwnerActive)) {
17
+ const transaction = await options.store.transact((tasks) =>
18
+ options.recoverInterrupted(tasks, options.now(), { isOwnerActive: options.isOwnerActive }),
19
+ );
20
+ snapshot = transaction;
21
+ }
22
+ return reconcileSnapshot(snapshot, options.currentRevision(), options.install, options.reconcile);
23
+ }
24
+
25
+ function createRefreshLoop(options) {
26
+ let handle;
27
+ let inFlight = false;
28
+
29
+ async function tick(generation) {
30
+ if (inFlight) return;
31
+ inFlight = true;
32
+ try {
33
+ await options.run(generation);
34
+ } catch (error) {
35
+ options.onError?.(error);
36
+ } finally {
37
+ inFlight = false;
38
+ }
39
+ }
40
+
41
+ function stop() {
42
+ if (handle !== undefined) options.clearInterval(handle);
43
+ handle = undefined;
44
+ }
45
+
46
+ function start(generation) {
47
+ stop();
48
+ handle = options.setInterval(() => tick(generation), options.intervalMs);
49
+ handle?.unref?.();
50
+ }
51
+
52
+ return { start, stop, tick };
53
+ }
54
+
55
+ module.exports = { createRefreshLoop, reconcileSnapshot, refreshSchedulerState };
@@ -544,7 +544,6 @@ function disableScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
544
544
  task.enabled = false;
545
545
  task.disabledAt = now.toISOString();
546
546
  task.nextRun = undefined;
547
- if (task.status === "running") task.status = "pending";
548
547
  return task;
549
548
  }
550
549
 
@@ -628,6 +627,7 @@ function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date(), opti
628
627
  }
629
628
 
630
629
  function finishTaskAfterRun(task, now, ok, result) {
630
+ const remainDisabled = task.enabled === false;
631
631
  delete task.runOwner;
632
632
  task.runCount = (Number.isInteger(task.runCount) ? task.runCount : 0) + 1;
633
633
  task.lastRun = now.toISOString();
@@ -645,6 +645,13 @@ function finishTaskAfterRun(task, now, ok, result) {
645
645
  return task;
646
646
  }
647
647
 
648
+ if (remainDisabled) {
649
+ task.enabled = false;
650
+ task.status = "pending";
651
+ task.nextRun = undefined;
652
+ return task;
653
+ }
654
+
648
655
  task.enabled = true;
649
656
  task.status = "pending";
650
657
  try {
@@ -664,7 +671,11 @@ function markScheduledTaskCompleted(tasks, idOrPrefix, nowValue = new Date(), re
664
671
  const now = asDate(nowValue);
665
672
  const task = findTask(tasks, idOrPrefix);
666
673
  if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
667
- if (task.status === "cancelled") return task;
674
+ if (task.status === "cancelled") {
675
+ delete task.runOwner;
676
+ delete task.startedAt;
677
+ return task;
678
+ }
668
679
  return finishTaskAfterRun(task, now, options.ok !== false, result);
669
680
  }
670
681
 
@@ -675,6 +686,11 @@ function markScheduledTaskFired(tasks, idOrPrefix, nowValue = new Date(), result
675
686
  function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error) {
676
687
  const task = findTask(tasks, idOrPrefix);
677
688
  if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
689
+ if (task.status === "cancelled") {
690
+ delete task.runOwner;
691
+ delete task.startedAt;
692
+ return task;
693
+ }
678
694
  task.lastError = error instanceof Error ? error.message : String(error);
679
695
  return finishTaskAfterRun(task, asDate(nowValue), false, undefined);
680
696
  }
@@ -682,10 +698,21 @@ function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error
682
698
  function recoverInterruptedTasks(tasks, nowValue = new Date(), options = {}) {
683
699
  const now = asDate(nowValue);
684
700
  const interrupted = tasks.filter(
685
- (task) => task.enabled !== false && task.status === "running" && !options.isOwnerActive?.(task.runOwner),
701
+ (task) => task.status === "running" && !options.isOwnerActive?.(task.runOwner),
686
702
  );
687
703
  for (const task of interrupted) {
688
- markScheduledTaskFailed(tasks, task.id, now, new Error("Scheduled task was interrupted before completion"));
704
+ const error = new Error("Scheduled task was interrupted before completion");
705
+ if (task.enabled === false) {
706
+ // Preserve an external disable decision while clearing the abandoned run.
707
+ task.status = "pending";
708
+ task.lastStatus = "error";
709
+ task.lastError = error.message;
710
+ task.nextRun = undefined;
711
+ delete task.runOwner;
712
+ delete task.startedAt;
713
+ continue;
714
+ }
715
+ markScheduledTaskFailed(tasks, task.id, now, error);
689
716
  }
690
717
  return interrupted;
691
718
  }