@jl1990/pi-scheduler 0.3.0 → 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 (44) hide show
  1. package/README.md +4 -1
  2. package/extensions/scheduler/index.ts +193 -142
  3. package/extensions/scheduler/scheduler-coordination.cjs +55 -0
  4. package/extensions/scheduler/scheduler-core.cjs +31 -4
  5. package/extensions/scheduler/scheduler-lifecycle.cjs +16 -0
  6. package/extensions/scheduler/task-store.cjs +64 -0
  7. package/node_modules/graceful-fs/LICENSE +15 -0
  8. package/node_modules/graceful-fs/README.md +143 -0
  9. package/node_modules/graceful-fs/clone.js +23 -0
  10. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  11. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  12. package/node_modules/graceful-fs/package.json +53 -0
  13. package/node_modules/graceful-fs/polyfills.js +355 -0
  14. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  15. package/node_modules/proper-lockfile/LICENSE +21 -0
  16. package/node_modules/proper-lockfile/README.md +183 -0
  17. package/node_modules/proper-lockfile/index.js +40 -0
  18. package/node_modules/proper-lockfile/lib/adapter.js +85 -0
  19. package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
  20. package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
  21. package/node_modules/proper-lockfile/package.json +71 -0
  22. package/node_modules/retry/.npmignore +3 -0
  23. package/node_modules/retry/.travis.yml +15 -0
  24. package/node_modules/retry/License +21 -0
  25. package/node_modules/retry/Makefile +18 -0
  26. package/node_modules/retry/README.md +227 -0
  27. package/node_modules/retry/equation.gif +0 -0
  28. package/node_modules/retry/example/dns.js +31 -0
  29. package/node_modules/retry/example/stop.js +40 -0
  30. package/node_modules/retry/index.js +1 -0
  31. package/node_modules/retry/lib/retry.js +100 -0
  32. package/node_modules/retry/lib/retry_operation.js +158 -0
  33. package/node_modules/retry/package.json +32 -0
  34. package/node_modules/retry/test/common.js +10 -0
  35. package/node_modules/retry/test/integration/test-forever.js +24 -0
  36. package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
  37. package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
  38. package/node_modules/retry/test/integration/test-timeouts.js +69 -0
  39. package/node_modules/signal-exit/LICENSE.txt +16 -0
  40. package/node_modules/signal-exit/README.md +39 -0
  41. package/node_modules/signal-exit/index.js +202 -0
  42. package/node_modules/signal-exit/package.json +38 -0
  43. package/node_modules/signal-exit/signals.js +53 -0
  44. 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,13 +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");
12
+ const lifecycle = require("./scheduler-lifecycle.cjs");
13
+ const coordination = require("./scheduler-coordination.cjs");
14
+ const { createTaskStore } = require("./task-store.cjs");
13
15
 
14
16
  const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
15
17
  const TYPES = ["once", "interval", "cron"] as const;
@@ -17,8 +19,9 @@ const SCOPES = ["session", "cwd", "global"] as const;
17
19
  const WAKE_ON = ["always", "failure", "success", "never"] as const;
18
20
  const MANAGE_ACTIONS = ["enable", "disable", "remove", "update", "cleanup"] as const;
19
21
 
20
- 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");
21
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;
22
25
  const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
23
26
  const MAX_STORED_OUTPUT_CHARS = 12_000;
24
27
  const MAX_PROMPT_OUTPUT_CHARS = 18_000;
@@ -118,43 +121,35 @@ export default function schedulerExtension(pi: ExtensionAPI) {
118
121
  let handles = new Map<string, TimerHandle>();
119
122
  let activeCtx: ExtensionContext | undefined;
120
123
  let sessionGeneration = 0;
121
- let saveQueue: Promise<void> = Promise.resolve();
124
+ let stateRevision = -1;
122
125
  let widgetEnabled = true;
123
126
  const firing = new Set<string>();
127
+ const store = createTaskStore({ stateFile: STATE_FILE, sanitize: core.sanitizeTasks });
124
128
 
125
129
  function isSessionActive(ctx: ExtensionContext, generation = sessionGeneration): boolean {
126
- if (activeCtx !== ctx || sessionGeneration !== generation) return false;
127
- try {
128
- ctx.isIdle(); // Pi throws when a captured extension context has become stale.
129
- return true;
130
- } catch {
131
- return false;
132
- }
130
+ return lifecycle.isSessionContextActive(activeCtx, ctx, sessionGeneration, generation);
133
131
  }
134
132
 
135
- async function loadTasks(): Promise<void> {
136
- try {
137
- const raw = await readFile(STATE_FILE, "utf8");
138
- const parsed = JSON.parse(raw);
139
- tasks = core.sanitizeTasks(parsed.tasks ?? parsed);
140
- } catch (error: any) {
141
- if (error?.code === "ENOENT") {
142
- tasks = [];
143
- return;
144
- }
145
- throw error;
146
- }
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
+ );
147
144
  }
148
145
 
149
- async function saveTasks(): Promise<void> {
150
- const payload = JSON.stringify({ version: 2, updatedAt: new Date().toISOString(), tasks }, null, 2) + "\n";
151
- saveQueue = saveQueue.then(async () => {
152
- await mkdir(dirname(STATE_FILE), { recursive: true });
153
- const tmp = `${STATE_FILE}.${process.pid}.tmp`;
154
- await writeFile(tmp, payload, "utf8");
155
- await rename(tmp, STATE_FILE);
156
- });
157
- 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;
158
153
  }
159
154
 
160
155
  function clearHandle(id: string): void {
@@ -218,11 +213,15 @@ export default function schedulerExtension(pi: ExtensionAPI) {
218
213
  });
219
214
  handles.set(task.id, { kind: "cron", handle: cron });
220
215
  } catch (error: any) {
221
- task.enabled = false;
222
- task.status = "failed";
223
- task.lastStatus = "error";
224
- task.lastError = error?.message ?? String(error);
225
- 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
+ });
226
225
  }
227
226
  return;
228
227
  }
@@ -244,13 +243,45 @@ export default function schedulerExtension(pi: ExtensionAPI) {
244
243
  handles.set(task.id, { kind: "timeout", handle: timer });
245
244
  }
246
245
 
247
- function rescheduleAll(ctx = activeCtx, generation = sessionGeneration): void {
246
+ function rescheduleAll(generation = sessionGeneration): void {
247
+ const ctx = activeCtx;
248
248
  if (!ctx || !isSessionActive(ctx, generation)) return;
249
249
  clearTimers();
250
250
  for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
251
251
  updateStatus(ctx);
252
252
  }
253
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
+
254
285
  async function catchUpOverdueCronTasks(
255
286
  ctx: ExtensionContext,
256
287
  generation: number,
@@ -268,11 +299,10 @@ export default function schedulerExtension(pi: ExtensionAPI) {
268
299
 
269
300
  for (const { task, missedAt } of overdue) {
270
301
  if (!isSessionActive(ctx, generation)) return;
271
- // Record the most recent missed occurrence so prompts and persisted state
272
- // describe the run being caught up, not the first occurrence missed.
273
- task.nextRun = missedAt.toISOString();
274
- task.dueAt = task.nextRun;
275
- await fireTask(task.id, ctx, generation);
302
+ await fireTask(task.id, ctx, generation, {
303
+ expectedNextRun: task.nextRun ?? task.dueAt,
304
+ occurrence: missedAt.toISOString(),
305
+ });
276
306
  }
277
307
  }
278
308
 
@@ -348,51 +378,75 @@ export default function schedulerExtension(pi: ExtensionAPI) {
348
378
  throw new Error(`Unsupported scheduled action: ${task.action}`);
349
379
  }
350
380
 
351
- async function fireTask(taskId: string, ctx: ExtensionContext, generation = sessionGeneration): Promise<void> {
352
- if (!isSessionActive(ctx, generation)) return;
353
- const task = tasks.find((candidate) => candidate.id === taskId);
354
- if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
355
- 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;
356
388
 
357
- firing.add(task.id);
389
+ firing.add(taskId);
358
390
  const attemptId = randomUUID();
391
+ let task: ScheduledTask | undefined;
359
392
  try {
360
- core.markScheduledTaskRunning(tasks, task.id, new Date(), {
361
- 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 } };
362
411
  });
363
- await saveTasks();
412
+ if (!task) return;
413
+
364
414
  if (!isSessionActive(ctx, generation)) {
365
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
366
- if (currentTask?.runOwner?.attemptId === attemptId) {
367
- currentTask.status = "pending";
368
- currentTask.lastStatus = "error";
369
- currentTask.lastError = "Scheduled task start was cancelled because the Pi session changed";
370
- delete currentTask.runOwner;
371
- delete currentTask.startedAt;
372
- await saveTasks();
373
- }
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
+ });
374
424
  return;
375
425
  }
426
+
376
427
  updateStatus(ctx);
377
428
  const result = await executeTask(task, ctx, () => isSessionActive(ctx, generation));
378
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
379
- if (currentTask?.runOwner?.attemptId !== attemptId) return;
380
- core.markScheduledTaskCompleted(tasks, currentTask.id, new Date(), result, { ok: result.ok !== false });
381
- 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
+ });
382
434
  } catch (error: any) {
383
- const currentTask = tasks.find((candidate) => candidate.id === task.id);
384
- if (currentTask?.runOwner?.attemptId !== attemptId) return;
385
- core.markScheduledTaskFailed(tasks, currentTask.id, new Date(), error);
386
- await saveTasks();
387
- if (isSessionActive(ctx, generation)) {
388
- 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)}`;
389
444
  if (ctx.hasUI) ctx.ui.notify(message, "error");
390
- recordMessage(`⚠️ ${message}`, { task: currentTask, error: error?.message ?? String(error) }, false);
445
+ recordMessage(`⚠️ ${message}`, { task: failedTask, error: error?.message ?? String(error) }, false);
391
446
  }
392
447
  } finally {
393
- firing.delete(task.id);
394
- if (isSessionActive(ctx, generation)) rescheduleAll(ctx, generation);
395
- else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
448
+ firing.delete(taskId);
449
+ if (activeCtx) rescheduleAll(sessionGeneration);
396
450
  }
397
451
  }
398
452
 
@@ -402,19 +456,21 @@ export default function schedulerExtension(pi: ExtensionAPI) {
402
456
  if (scope === "session" && !sessionFile) {
403
457
  throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
404
458
  }
405
- const task = core.createScheduledTask(
406
- {
407
- ...input,
408
- schedule: input.schedule ?? input.when ?? input.whenText,
409
- cwd: input.cwd ?? ctx.cwd,
410
- scope,
411
- sessionFile,
412
- },
413
- new Date(),
414
- );
415
- tasks.push(task);
416
- await saveTasks();
417
- 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();
418
474
  return task;
419
475
  }
420
476
 
@@ -431,12 +487,12 @@ export default function schedulerExtension(pi: ExtensionAPI) {
431
487
  return { ...base, message: parsed.payload };
432
488
  }
433
489
 
434
- function cleanupVisibleTasks(ctx: ExtensionContext): ScheduledTask[] {
435
- const removable = visibleTasks(ctx).filter(
436
- (task) => task.enabled === false || ["fired", "cancelled", "failed"].includes(task.status),
437
- );
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));
438
494
  const removableIds = new Set(removable.map((task) => task.id));
439
- tasks = tasks.filter((task) => !removableIds.has(task.id));
495
+ current.splice(0, current.length, ...current.filter((task) => !removableIds.has(task.id)));
440
496
  for (const task of removable) clearHandle(task.id);
441
497
  return removable;
442
498
  }
@@ -446,11 +502,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
446
502
  id: string,
447
503
  mutator: (visible: ScheduledTask[]) => ScheduledTask,
448
504
  ): Promise<ScheduledTask> {
449
- await loadTasks();
450
- const visible = visibleTasks(ctx);
451
- const task = mutator(visible);
452
- await saveTasks();
453
- rescheduleAll(ctx);
505
+ const task = await transactTasks((current) => {
506
+ const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
507
+ return mutator(visible);
508
+ });
509
+ rescheduleAll();
454
510
  return task;
455
511
  }
456
512
 
@@ -465,22 +521,20 @@ export default function schedulerExtension(pi: ExtensionAPI) {
465
521
  pi.on("session_start", async (_event, ctx) => {
466
522
  activeCtx = ctx;
467
523
  const generation = ++sessionGeneration;
468
- await loadTasks();
524
+ const interrupted = await transactTasks((current) =>
525
+ core.recoverInterruptedTasks(current, new Date(), { isOwnerActive: isRunOwnerActive }),
526
+ );
469
527
  if (!isSessionActive(ctx, generation)) return;
470
528
 
471
- const interrupted = core.recoverInterruptedTasks(tasks, new Date(), { isOwnerActive: isRunOwnerActive });
472
- if (interrupted.length > 0) {
473
- await saveTasks();
474
- if (!isSessionActive(ctx, generation)) return;
475
- if (ctx.hasUI) {
476
- ctx.ui.notify(
477
- `Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
478
- "warning",
479
- );
480
- }
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
+ );
481
534
  }
482
535
 
483
- rescheduleAll(ctx, generation);
536
+ rescheduleAll(generation);
537
+ startStateRefresh(generation);
484
538
  const catchUpOptions = core.parseCatchUpOptions(process.env);
485
539
  void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
486
540
  if (isSessionActive(ctx, generation) && ctx.hasUI) {
@@ -490,9 +544,9 @@ export default function schedulerExtension(pi: ExtensionAPI) {
490
544
  });
491
545
 
492
546
  pi.on("session_shutdown", async (_event, ctx) => {
493
- if (activeCtx !== ctx) return;
494
547
  ++sessionGeneration;
495
548
  clearTimers();
549
+ refreshLoop.stop();
496
550
  if (ctx.hasUI) {
497
551
  ctx.ui.setStatus("scheduler", undefined);
498
552
  ctx.ui.setWidget("scheduler", undefined);
@@ -596,12 +650,13 @@ export default function schedulerExtension(pi: ExtensionAPI) {
596
650
  handler: async (args, ctx) => {
597
651
  const id = args.trim();
598
652
  try {
599
- await loadTasks();
600
- const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), id);
601
- 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
+ });
602
658
  clearHandle(removed.id);
603
- await saveTasks();
604
- rescheduleAll(ctx);
659
+ rescheduleAll();
605
660
  ctx.ui.notify(`Removed scheduled task ${removed.id}`, "info");
606
661
  } catch (error: any) {
607
662
  ctx.ui.notify(error?.message ?? String(error), "error");
@@ -612,10 +667,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
612
667
  pi.registerCommand("schedule-cleanup", {
613
668
  description: "Remove disabled/completed/cancelled/failed scheduled tasks visible to this session",
614
669
  handler: async (_args, ctx) => {
615
- await loadTasks();
616
- const removed = cleanupVisibleTasks(ctx);
617
- await saveTasks();
618
- rescheduleAll(ctx);
670
+ const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
671
+ rescheduleAll();
619
672
  ctx.ui.notify(`Cleaned up ${removed.length} scheduled task(s)`, "info");
620
673
  },
621
674
  });
@@ -772,41 +825,39 @@ export default function schedulerExtension(pi: ExtensionAPI) {
772
825
  failurePrompt: Type.Optional(Type.String()),
773
826
  }),
774
827
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
775
- await loadTasks();
776
828
  if (params.action === "cleanup") {
777
- const removed = cleanupVisibleTasks(ctx);
778
- await saveTasks();
779
- rescheduleAll(ctx);
829
+ const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
830
+ rescheduleAll();
780
831
  return { content: [{ type: "text", text: `Cleaned up ${removed.length} scheduled task(s).` }], details: { removed } };
781
832
  }
782
833
 
783
834
  if (!params.id) throw new Error("id is required for this management action");
784
- let task: ScheduledTask;
785
- if (params.action === "enable") {
786
- task = core.enableScheduledTask(visibleTasks(ctx), params.id, new Date());
787
- } else if (params.action === "disable") {
788
- task = core.disableScheduledTask(visibleTasks(ctx), params.id, new Date());
789
- } else if (params.action === "remove") {
790
- const visibleRemoved = core.removeScheduledTask(visibleTasks(ctx), params.id);
791
- task = core.removeScheduledTask(tasks, visibleRemoved.id);
792
- clearHandle(task.id);
793
- } 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
+
794
848
  const updates: Record<string, any> = { ...params };
795
849
  delete updates.action;
796
850
  delete updates.id;
797
851
  if (updates.scope === "session") {
798
852
  updates.sessionFile = currentSessionFile(ctx);
799
- if (!updates.sessionFile) {
800
- throw new Error("Session-scoped tasks require a persisted Pi session");
801
- }
853
+ if (!updates.sessionFile) throw new Error("Session-scoped tasks require a persisted Pi session");
802
854
  } else if (updates.scope !== undefined) {
803
855
  updates.sessionFile = null;
804
856
  }
805
- task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
806
- }
807
-
808
- await saveTasks();
809
- rescheduleAll(ctx);
857
+ return core.updateScheduledTask(visible, params.id, updates, new Date());
858
+ });
859
+ if (params.action === "remove") clearHandle(task.id);
860
+ rescheduleAll();
810
861
  return {
811
862
  content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
812
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
  }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ function isSessionContextActive(activeCtx, candidateCtx, currentGeneration, expectedGeneration) {
4
+ if (!activeCtx || currentGeneration !== expectedGeneration) return false;
5
+ try {
6
+ // Pi creates distinct context wrappers for events, commands, and tool calls.
7
+ // Probe both wrappers for staleness instead of comparing object identity.
8
+ activeCtx.isIdle();
9
+ candidateCtx.isIdle();
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ module.exports = { isSessionContextActive };