@jl1990/pi-scheduler 0.2.4 → 0.3.1

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/README.md CHANGED
@@ -223,7 +223,19 @@ Pi Scheduler currently uses **in-process timers**:
223
223
 
224
224
  - If Pi is running, tasks fire at the scheduled time.
225
225
  - If Pi is closed, tasks do not fire while Pi is closed.
226
- - Pending/missed tasks are loaded again when the relevant Pi session starts, and due tasks fire then.
226
+ - Overdue one-shot and interval tasks fire when the relevant Pi session starts again.
227
+ - For each overdue cron task, Pi Scheduler catches up its most recent missed occurrence when that occurrence is inside the configured catch-up window. Catch-up is newest-first and bounded per startup.
228
+
229
+ Cron catch-up can be configured with environment variables:
230
+
231
+ | Variable | Default | Description |
232
+ | --- | ---: | --- |
233
+ | `PI_SCHEDULER_CATCHUP_WINDOW_H` | `24` | Maximum age, in hours, of the most recent missed cron occurrence. |
234
+ | `PI_SCHEDULER_CATCHUP_MAX` | `5` | Maximum number of missed cron tasks fired per session start. Set to `0` to disable cron catch-up. |
235
+
236
+ 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
+ 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.
227
239
 
228
240
  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.
229
241
 
@@ -2,6 +2,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { Cron } from "croner";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
7
  import { homedir } from "node:os";
7
8
  import { dirname, join } from "node:path";
@@ -9,6 +10,7 @@ import { Type } from "typebox";
9
10
 
10
11
  // Keep the scheduler logic testable from plain node --test.
11
12
  const core = require("./scheduler-core.cjs");
13
+ const lifecycle = require("./scheduler-lifecycle.cjs");
12
14
 
13
15
  const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
14
16
  const TYPES = ["once", "interval", "cron"] as const;
@@ -40,12 +42,19 @@ function currentSessionFile(ctx: ExtensionContext): string | undefined {
40
42
  }
41
43
 
42
44
  function taskBelongsToSession(task: ScheduledTask, ctx: ExtensionContext): boolean {
43
- const scope = task.scope ?? "session";
44
- if (scope === "global") return true;
45
- if (scope === "cwd") return !task.cwd || task.cwd === ctx.cwd;
45
+ return core.taskMatchesScope(task, { cwd: ctx.cwd, sessionFile: currentSessionFile(ctx) });
46
+ }
46
47
 
47
- const sessionFile = currentSessionFile(ctx);
48
- return !task.sessionFile || !sessionFile || task.sessionFile === sessionFile;
48
+ function isRunOwnerActive(owner: Record<string, any> | undefined): boolean {
49
+ const pid = Number(owner?.pid);
50
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
51
+ if (pid === process.pid) return true;
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ } catch (error: any) {
56
+ return error?.code === "EPERM";
57
+ }
49
58
  }
50
59
 
51
60
  function sendAgentPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
@@ -109,10 +118,15 @@ export default function schedulerExtension(pi: ExtensionAPI) {
109
118
  let tasks: ScheduledTask[] = [];
110
119
  let handles = new Map<string, TimerHandle>();
111
120
  let activeCtx: ExtensionContext | undefined;
121
+ let sessionGeneration = 0;
112
122
  let saveQueue: Promise<void> = Promise.resolve();
113
123
  let widgetEnabled = true;
114
124
  const firing = new Set<string>();
115
125
 
126
+ function isSessionActive(ctx: ExtensionContext, generation = sessionGeneration): boolean {
127
+ return lifecycle.isSessionContextActive(activeCtx, ctx, sessionGeneration, generation);
128
+ }
129
+
116
130
  async function loadTasks(): Promise<void> {
117
131
  try {
118
132
  const raw = await readFile(STATE_FILE, "utf8");
@@ -186,7 +200,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
186
200
  updateWidget(ctx);
187
201
  }
188
202
 
189
- function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext): void {
203
+ function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext, generation = sessionGeneration): void {
204
+ if (!isSessionActive(ctx, generation)) return;
190
205
  if (task.enabled === false || task.status !== "pending") return;
191
206
  if (!taskBelongsToSession(task, ctx)) return;
192
207
  clearHandle(task.id);
@@ -194,7 +209,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
194
209
  if (task.type === "cron") {
195
210
  try {
196
211
  const cron = new Cron(task.schedule, () => {
197
- void fireTask(task.id, ctx);
212
+ void fireTask(task.id, ctx, generation);
198
213
  });
199
214
  handles.set(task.id, { kind: "cron", handle: cron });
200
215
  } catch (error: any) {
@@ -214,22 +229,48 @@ export default function schedulerExtension(pi: ExtensionAPI) {
214
229
 
215
230
  const timer = setTimeout(() => {
216
231
  handles.delete(task.id);
232
+ if (!isSessionActive(ctx, generation)) return;
217
233
  if (Date.now() < dueAt) {
218
- scheduleTaskHandle(task, ctx);
234
+ scheduleTaskHandle(task, ctx, generation);
219
235
  return;
220
236
  }
221
- void fireTask(task.id, ctx);
237
+ void fireTask(task.id, ctx, generation);
222
238
  }, timerDelay);
223
239
  handles.set(task.id, { kind: "timeout", handle: timer });
224
240
  }
225
241
 
226
- function rescheduleAll(ctx = activeCtx): void {
227
- if (!ctx) return;
242
+ function rescheduleAll(ctx = activeCtx, generation = sessionGeneration): void {
243
+ if (!ctx || !isSessionActive(ctx, generation)) return;
228
244
  clearTimers();
229
- for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx);
245
+ for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
230
246
  updateStatus(ctx);
231
247
  }
232
248
 
249
+ async function catchUpOverdueCronTasks(
250
+ ctx: ExtensionContext,
251
+ generation: number,
252
+ options: { windowHours: number; maxFire: number },
253
+ ): Promise<void> {
254
+ const overdue = core.selectCatchUpCronTasks(visibleTasks(ctx), new Date(), options);
255
+ if (overdue.length === 0 || !isSessionActive(ctx, generation)) return;
256
+
257
+ if (ctx.hasUI) {
258
+ ctx.ui.notify(
259
+ `Catching up ${overdue.length} missed cron task(s) (window ${options.windowHours}h, limit ${options.maxFire})`,
260
+ "info",
261
+ );
262
+ }
263
+
264
+ for (const { task, missedAt } of overdue) {
265
+ 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);
271
+ }
272
+ }
273
+
233
274
  function recordMessage(content: string, details?: Record<string, any>, triggerTurn = false): void {
234
275
  pi.sendMessage(
235
276
  {
@@ -242,7 +283,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
242
283
  );
243
284
  }
244
285
 
245
- async function executeTask(task: ScheduledTask, ctx: ExtensionContext): Promise<Record<string, any>> {
286
+ async function executeTask(
287
+ task: ScheduledTask,
288
+ ctx: ExtensionContext,
289
+ isActive: () => boolean,
290
+ ): Promise<Record<string, any>> {
246
291
  if (task.action === "notify") {
247
292
  const message = task.message ?? "Scheduled reminder";
248
293
  if (ctx.hasUI) ctx.ui.notify(message, "info");
@@ -279,6 +324,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
279
324
  stderr: truncateMiddle(result.stderr ?? "", MAX_STORED_OUTPUT_CHARS),
280
325
  };
281
326
 
327
+ if (!isActive()) return { ...shellResult, outputSuppressed: true };
328
+
282
329
  recordMessage(
283
330
  `🖥️ Scheduled command ${task.id} finished with exit code ${result.code}: ${task.command}`,
284
331
  { task, result: shellResult },
@@ -296,40 +343,67 @@ export default function schedulerExtension(pi: ExtensionAPI) {
296
343
  throw new Error(`Unsupported scheduled action: ${task.action}`);
297
344
  }
298
345
 
299
- async function fireTask(taskId: string, ctx: ExtensionContext): Promise<void> {
346
+ async function fireTask(taskId: string, ctx: ExtensionContext, generation = sessionGeneration): Promise<void> {
347
+ if (!isSessionActive(ctx, generation)) return;
300
348
  const task = tasks.find((candidate) => candidate.id === taskId);
301
349
  if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
302
350
  if (!taskBelongsToSession(task, ctx)) return;
303
351
 
304
352
  firing.add(task.id);
353
+ const attemptId = randomUUID();
305
354
  try {
306
- core.markScheduledTaskRunning(tasks, task.id, new Date());
355
+ core.markScheduledTaskRunning(tasks, task.id, new Date(), {
356
+ runOwner: { pid: process.pid, attemptId, sessionFile: currentSessionFile(ctx) },
357
+ });
307
358
  await saveTasks();
359
+ 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
+ }
369
+ return;
370
+ }
308
371
  updateStatus(ctx);
309
- const result = await executeTask(task, ctx);
310
- core.markScheduledTaskCompleted(tasks, task.id, new Date(), result, { ok: result.ok !== false });
372
+ 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 });
311
376
  await saveTasks();
312
377
  } catch (error: any) {
313
- core.markScheduledTaskFailed(tasks, task.id, new Date(), error);
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);
314
381
  await saveTasks();
315
- const message = `Scheduled task ${task.id} failed: ${error?.message ?? String(error)}`;
316
- if (ctx.hasUI) ctx.ui.notify(message, "error");
317
- recordMessage(`⚠️ ${message}`, { task, error: error?.message ?? String(error) }, false);
382
+ if (isSessionActive(ctx, generation)) {
383
+ const message = `Scheduled task ${currentTask.id} failed: ${error?.message ?? String(error)}`;
384
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
385
+ recordMessage(`⚠️ ${message}`, { task: currentTask, error: error?.message ?? String(error) }, false);
386
+ }
318
387
  } finally {
319
388
  firing.delete(task.id);
320
- rescheduleAll(ctx);
389
+ if (isSessionActive(ctx, generation)) rescheduleAll(ctx, generation);
390
+ else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
321
391
  }
322
392
  }
323
393
 
324
394
  async function createAndSchedule(input: Record<string, any>, ctx: ExtensionContext): Promise<ScheduledTask> {
325
395
  const scope = input.scope ?? "session";
396
+ const sessionFile = scope === "session" ? currentSessionFile(ctx) : undefined;
397
+ if (scope === "session" && !sessionFile) {
398
+ throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
399
+ }
326
400
  const task = core.createScheduledTask(
327
401
  {
328
402
  ...input,
329
403
  schedule: input.schedule ?? input.when ?? input.whenText,
330
404
  cwd: input.cwd ?? ctx.cwd,
331
405
  scope,
332
- sessionFile: scope === "session" ? currentSessionFile(ctx) : undefined,
406
+ sessionFile,
333
407
  },
334
408
  new Date(),
335
409
  );
@@ -385,11 +459,34 @@ export default function schedulerExtension(pi: ExtensionAPI) {
385
459
 
386
460
  pi.on("session_start", async (_event, ctx) => {
387
461
  activeCtx = ctx;
462
+ const generation = ++sessionGeneration;
388
463
  await loadTasks();
389
- rescheduleAll(ctx);
464
+ if (!isSessionActive(ctx, generation)) return;
465
+
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
+ }
476
+ }
477
+
478
+ rescheduleAll(ctx, generation);
479
+ const catchUpOptions = core.parseCatchUpOptions(process.env);
480
+ void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
481
+ if (isSessionActive(ctx, generation) && ctx.hasUI) {
482
+ ctx.ui.notify(`Failed to catch up missed cron tasks: ${error?.message ?? String(error)}`, "error");
483
+ }
484
+ });
390
485
  });
391
486
 
392
487
  pi.on("session_shutdown", async (_event, ctx) => {
488
+ if (!isSessionActive(ctx)) return;
489
+ ++sessionGeneration;
393
490
  clearTimers();
394
491
  if (ctx.hasUI) {
395
492
  ctx.ui.setStatus("scheduler", undefined);
@@ -689,9 +786,17 @@ export default function schedulerExtension(pi: ExtensionAPI) {
689
786
  task = core.removeScheduledTask(tasks, visibleRemoved.id);
690
787
  clearHandle(task.id);
691
788
  } else {
692
- const updates = { ...params };
789
+ const updates: Record<string, any> = { ...params };
693
790
  delete updates.action;
694
791
  delete updates.id;
792
+ if (updates.scope === "session") {
793
+ updates.sessionFile = currentSessionFile(ctx);
794
+ if (!updates.sessionFile) {
795
+ throw new Error("Session-scoped tasks require a persisted Pi session");
796
+ }
797
+ } else if (updates.scope !== undefined) {
798
+ updates.sessionFile = null;
799
+ }
695
800
  task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
696
801
  }
697
802
 
@@ -29,6 +29,8 @@ const MINUTE = 60 * SECOND;
29
29
  const HOUR = 60 * MINUTE;
30
30
  const DAY = 24 * HOUR;
31
31
  const WEEK = 7 * DAY;
32
+ const DEFAULT_CATCHUP_WINDOW_HOURS = 24;
33
+ const DEFAULT_CATCHUP_MAX_FIRE = 5;
32
34
 
33
35
  function asDate(value) {
34
36
  const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
@@ -397,7 +399,7 @@ function normalizeTask(task, nowValue = new Date()) {
397
399
  const migrated = { ...task, action, type, schedule, status };
398
400
  migrated.enabled = task.enabled === undefined ? !isTerminal(migrated) : Boolean(task.enabled);
399
401
  migrated.runCount = Number.isInteger(task.runCount) && task.runCount >= 0 ? task.runCount : 0;
400
- migrated.scope = VALID_SCOPES.has(task.scope) ? task.scope : "session";
402
+ migrated.scope = VALID_SCOPES.has(task.scope) ? task.scope : task.sessionFile ? "session" : task.cwd ? "cwd" : "global";
401
403
  migrated.whenText = compactSpaces(task.whenText ?? task.when ?? schedule);
402
404
  migrated.createdAt = Number.isNaN(Date.parse(task.createdAt)) ? asDate(nowValue).toISOString() : task.createdAt;
403
405
  if (task.maxRuns !== undefined) migrated.maxRuns = normalizeMaxRuns(task.maxRuns);
@@ -449,6 +451,13 @@ function sortByNextRun(a, b) {
449
451
  return (Number.isFinite(aTime) ? aTime : Number.POSITIVE_INFINITY) - (Number.isFinite(bTime) ? bTime : Number.POSITIVE_INFINITY);
450
452
  }
451
453
 
454
+ function taskMatchesScope(task, context = {}) {
455
+ const scope = task.scope ?? "session";
456
+ if (scope === "global") return true;
457
+ if (scope === "cwd") return Boolean(task.cwd && context.cwd && task.cwd === context.cwd);
458
+ return Boolean(task.sessionFile && context.sessionFile && task.sessionFile === context.sessionFile);
459
+ }
460
+
452
461
  function pendingTasks(tasks) {
453
462
  return tasks
454
463
  .filter((task) => task.enabled !== false && !isTerminal(task))
@@ -461,6 +470,56 @@ function dueTasks(tasks, nowValue = new Date()) {
461
470
  return pendingTasks(tasks).filter((task) => task.status === "pending" && Date.parse(task.nextRun ?? task.dueAt) <= now);
462
471
  }
463
472
 
473
+ function parseCatchUpOptions(env = {}) {
474
+ const rawWindow = typeof env.PI_SCHEDULER_CATCHUP_WINDOW_H === "string"
475
+ ? env.PI_SCHEDULER_CATCHUP_WINDOW_H.trim()
476
+ : env.PI_SCHEDULER_CATCHUP_WINDOW_H;
477
+ const parsedWindow = rawWindow === undefined || rawWindow === "" ? Number.NaN : Number(rawWindow);
478
+ const windowHours = Number.isFinite(parsedWindow) && parsedWindow >= 0 && Number.isFinite(parsedWindow * HOUR)
479
+ ? parsedWindow
480
+ : DEFAULT_CATCHUP_WINDOW_HOURS;
481
+
482
+ const rawMax = typeof env.PI_SCHEDULER_CATCHUP_MAX === "string"
483
+ ? env.PI_SCHEDULER_CATCHUP_MAX.trim()
484
+ : env.PI_SCHEDULER_CATCHUP_MAX;
485
+ const parsedMax = rawMax === undefined || rawMax === "" ? Number.NaN : Number(rawMax);
486
+ const maxFire = Number.isSafeInteger(parsedMax) && parsedMax >= 0 ? parsedMax : DEFAULT_CATCHUP_MAX_FIRE;
487
+ return { windowHours, maxFire };
488
+ }
489
+
490
+ function latestMissedCronRun(task, now) {
491
+ const persistedNext = Date.parse(task.nextRun ?? task.dueAt ?? "");
492
+ if (!Number.isFinite(persistedNext) || persistedNext > now.getTime()) return undefined;
493
+
494
+ let cron;
495
+ try {
496
+ cron = new Cron(task.schedule, { paused: true }, () => {});
497
+ const [latest] = cron.previousRuns(1, now);
498
+ if (latest && latest.getTime() >= persistedNext && latest.getTime() <= now.getTime()) return latest;
499
+ } catch {
500
+ return undefined;
501
+ } finally {
502
+ cron?.stop();
503
+ }
504
+ return new Date(persistedNext);
505
+ }
506
+
507
+ function selectCatchUpCronTasks(tasks, nowValue = new Date(), options = {}) {
508
+ const now = asDate(nowValue);
509
+ const windowHours = Number(options.windowHours);
510
+ const maxFire = Number(options.maxFire);
511
+ if (!Number.isFinite(windowHours) || windowHours < 0 || !Number.isSafeInteger(maxFire) || maxFire <= 0) return [];
512
+ const windowMs = windowHours * HOUR;
513
+ if (!Number.isFinite(windowMs)) return [];
514
+
515
+ return tasks
516
+ .filter((task) => task.type === "cron" && task.enabled !== false && task.status === "pending")
517
+ .map((task) => ({ task, missedAt: latestMissedCronRun(task, now) }))
518
+ .filter((entry) => entry.missedAt && now.getTime() - entry.missedAt.getTime() <= windowMs)
519
+ .sort((a, b) => b.missedAt.getTime() - a.missedAt.getTime())
520
+ .slice(0, maxFire);
521
+ }
522
+
464
523
  function findTask(tasks, idOrPrefix) {
465
524
  const id = compactSpaces(idOrPrefix);
466
525
  return tasks.find((task) => task.id === id || task.id.startsWith(id));
@@ -524,7 +583,8 @@ function updateScheduledTask(tasks, idOrPrefix, updates = {}, nowValue = new Dat
524
583
  if (updates.description !== undefined) task.description = compactSpaces(updates.description);
525
584
  if (updates.maxRuns !== undefined) task.maxRuns = normalizeMaxRuns(updates.maxRuns);
526
585
  if (updates.cwd !== undefined) task.cwd = String(updates.cwd);
527
- if (updates.sessionFile !== undefined) task.sessionFile = String(updates.sessionFile);
586
+ if (updates.sessionFile === null) delete task.sessionFile;
587
+ else if (updates.sessionFile !== undefined) task.sessionFile = String(updates.sessionFile);
528
588
  if (updates.timeoutMs !== undefined) task.timeoutMs = validateTimeoutMs(updates.timeoutMs);
529
589
  if (updates.followUpPrompt !== undefined) task.followUpPrompt = compactSpaces(updates.followUpPrompt) || undefined;
530
590
  if (updates.successPrompt !== undefined) task.successPrompt = compactSpaces(updates.successPrompt) || undefined;
@@ -555,7 +615,7 @@ function updateScheduledTask(tasks, idOrPrefix, updates = {}, nowValue = new Dat
555
615
  return task;
556
616
  }
557
617
 
558
- function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date()) {
618
+ function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date(), options = {}) {
559
619
  const now = asDate(nowValue);
560
620
  const task = findTask(tasks, idOrPrefix);
561
621
  if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
@@ -563,10 +623,12 @@ function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date()) {
563
623
  task.status = "running";
564
624
  task.lastStatus = "running";
565
625
  task.startedAt = now.toISOString();
626
+ if (options.runOwner) task.runOwner = { ...options.runOwner, startedAt: now.toISOString() };
566
627
  return task;
567
628
  }
568
629
 
569
630
  function finishTaskAfterRun(task, now, ok, result) {
631
+ delete task.runOwner;
570
632
  task.runCount = (Number.isInteger(task.runCount) ? task.runCount : 0) + 1;
571
633
  task.lastRun = now.toISOString();
572
634
  task.lastStatus = ok ? "success" : "error";
@@ -617,6 +679,17 @@ function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error
617
679
  return finishTaskAfterRun(task, asDate(nowValue), false, undefined);
618
680
  }
619
681
 
682
+ function recoverInterruptedTasks(tasks, nowValue = new Date(), options = {}) {
683
+ const now = asDate(nowValue);
684
+ const interrupted = tasks.filter(
685
+ (task) => task.enabled !== false && task.status === "running" && !options.isOwnerActive?.(task.runOwner),
686
+ );
687
+ for (const task of interrupted) {
688
+ markScheduledTaskFailed(tasks, task.id, now, new Error("Scheduled task was interrupted before completion"));
689
+ }
690
+ return interrupted;
691
+ }
692
+
620
693
  function shellResultOk(result) {
621
694
  if (typeof result?.ok === "boolean") return result.ok;
622
695
  if (typeof result?.code === "number") return result.code === 0 && result.killed !== true;
@@ -731,8 +804,12 @@ module.exports = {
731
804
  createScheduledTask,
732
805
  normalizeTask,
733
806
  sanitizeTasks,
807
+ taskMatchesScope,
734
808
  pendingTasks,
735
809
  dueTasks,
810
+ parseCatchUpOptions,
811
+ selectCatchUpCronTasks,
812
+ recoverInterruptedTasks,
736
813
  cancelScheduledTask,
737
814
  disableScheduledTask,
738
815
  enableScheduledTask,
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jl1990/pi-scheduler",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Give Pi coding agents a clock: schedule reminders, shell commands, and self-waking prompts for CI polling and autonomous follow-ups.",
5
5
  "author": "jl1990",
6
6
  "license": "MIT",