@jl1990/pi-scheduler 0.2.4 → 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/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";
@@ -40,12 +41,19 @@ function currentSessionFile(ctx: ExtensionContext): string | undefined {
40
41
  }
41
42
 
42
43
  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;
44
+ return core.taskMatchesScope(task, { cwd: ctx.cwd, sessionFile: currentSessionFile(ctx) });
45
+ }
46
46
 
47
- const sessionFile = currentSessionFile(ctx);
48
- return !task.sessionFile || !sessionFile || task.sessionFile === sessionFile;
47
+ function isRunOwnerActive(owner: Record<string, any> | undefined): boolean {
48
+ const pid = Number(owner?.pid);
49
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
50
+ if (pid === process.pid) return true;
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch (error: any) {
55
+ return error?.code === "EPERM";
56
+ }
49
57
  }
50
58
 
51
59
  function sendAgentPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
@@ -109,10 +117,21 @@ export default function schedulerExtension(pi: ExtensionAPI) {
109
117
  let tasks: ScheduledTask[] = [];
110
118
  let handles = new Map<string, TimerHandle>();
111
119
  let activeCtx: ExtensionContext | undefined;
120
+ let sessionGeneration = 0;
112
121
  let saveQueue: Promise<void> = Promise.resolve();
113
122
  let widgetEnabled = true;
114
123
  const firing = new Set<string>();
115
124
 
125
+ 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
+ }
133
+ }
134
+
116
135
  async function loadTasks(): Promise<void> {
117
136
  try {
118
137
  const raw = await readFile(STATE_FILE, "utf8");
@@ -186,7 +205,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
186
205
  updateWidget(ctx);
187
206
  }
188
207
 
189
- function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext): void {
208
+ function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext, generation = sessionGeneration): void {
209
+ if (!isSessionActive(ctx, generation)) return;
190
210
  if (task.enabled === false || task.status !== "pending") return;
191
211
  if (!taskBelongsToSession(task, ctx)) return;
192
212
  clearHandle(task.id);
@@ -194,7 +214,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
194
214
  if (task.type === "cron") {
195
215
  try {
196
216
  const cron = new Cron(task.schedule, () => {
197
- void fireTask(task.id, ctx);
217
+ void fireTask(task.id, ctx, generation);
198
218
  });
199
219
  handles.set(task.id, { kind: "cron", handle: cron });
200
220
  } catch (error: any) {
@@ -214,22 +234,48 @@ export default function schedulerExtension(pi: ExtensionAPI) {
214
234
 
215
235
  const timer = setTimeout(() => {
216
236
  handles.delete(task.id);
237
+ if (!isSessionActive(ctx, generation)) return;
217
238
  if (Date.now() < dueAt) {
218
- scheduleTaskHandle(task, ctx);
239
+ scheduleTaskHandle(task, ctx, generation);
219
240
  return;
220
241
  }
221
- void fireTask(task.id, ctx);
242
+ void fireTask(task.id, ctx, generation);
222
243
  }, timerDelay);
223
244
  handles.set(task.id, { kind: "timeout", handle: timer });
224
245
  }
225
246
 
226
- function rescheduleAll(ctx = activeCtx): void {
227
- if (!ctx) return;
247
+ function rescheduleAll(ctx = activeCtx, generation = sessionGeneration): void {
248
+ if (!ctx || !isSessionActive(ctx, generation)) return;
228
249
  clearTimers();
229
- for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx);
250
+ for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
230
251
  updateStatus(ctx);
231
252
  }
232
253
 
254
+ async function catchUpOverdueCronTasks(
255
+ ctx: ExtensionContext,
256
+ generation: number,
257
+ options: { windowHours: number; maxFire: number },
258
+ ): Promise<void> {
259
+ const overdue = core.selectCatchUpCronTasks(visibleTasks(ctx), new Date(), options);
260
+ if (overdue.length === 0 || !isSessionActive(ctx, generation)) return;
261
+
262
+ if (ctx.hasUI) {
263
+ ctx.ui.notify(
264
+ `Catching up ${overdue.length} missed cron task(s) (window ${options.windowHours}h, limit ${options.maxFire})`,
265
+ "info",
266
+ );
267
+ }
268
+
269
+ for (const { task, missedAt } of overdue) {
270
+ 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);
276
+ }
277
+ }
278
+
233
279
  function recordMessage(content: string, details?: Record<string, any>, triggerTurn = false): void {
234
280
  pi.sendMessage(
235
281
  {
@@ -242,7 +288,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
242
288
  );
243
289
  }
244
290
 
245
- async function executeTask(task: ScheduledTask, ctx: ExtensionContext): Promise<Record<string, any>> {
291
+ async function executeTask(
292
+ task: ScheduledTask,
293
+ ctx: ExtensionContext,
294
+ isActive: () => boolean,
295
+ ): Promise<Record<string, any>> {
246
296
  if (task.action === "notify") {
247
297
  const message = task.message ?? "Scheduled reminder";
248
298
  if (ctx.hasUI) ctx.ui.notify(message, "info");
@@ -279,6 +329,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
279
329
  stderr: truncateMiddle(result.stderr ?? "", MAX_STORED_OUTPUT_CHARS),
280
330
  };
281
331
 
332
+ if (!isActive()) return { ...shellResult, outputSuppressed: true };
333
+
282
334
  recordMessage(
283
335
  `🖥️ Scheduled command ${task.id} finished with exit code ${result.code}: ${task.command}`,
284
336
  { task, result: shellResult },
@@ -296,40 +348,67 @@ export default function schedulerExtension(pi: ExtensionAPI) {
296
348
  throw new Error(`Unsupported scheduled action: ${task.action}`);
297
349
  }
298
350
 
299
- async function fireTask(taskId: string, ctx: ExtensionContext): Promise<void> {
351
+ async function fireTask(taskId: string, ctx: ExtensionContext, generation = sessionGeneration): Promise<void> {
352
+ if (!isSessionActive(ctx, generation)) return;
300
353
  const task = tasks.find((candidate) => candidate.id === taskId);
301
354
  if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
302
355
  if (!taskBelongsToSession(task, ctx)) return;
303
356
 
304
357
  firing.add(task.id);
358
+ const attemptId = randomUUID();
305
359
  try {
306
- core.markScheduledTaskRunning(tasks, task.id, new Date());
360
+ core.markScheduledTaskRunning(tasks, task.id, new Date(), {
361
+ runOwner: { pid: process.pid, attemptId, sessionFile: currentSessionFile(ctx) },
362
+ });
307
363
  await saveTasks();
364
+ 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
+ }
374
+ return;
375
+ }
308
376
  updateStatus(ctx);
309
- const result = await executeTask(task, ctx);
310
- core.markScheduledTaskCompleted(tasks, task.id, new Date(), result, { ok: result.ok !== false });
377
+ 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 });
311
381
  await saveTasks();
312
382
  } catch (error: any) {
313
- core.markScheduledTaskFailed(tasks, task.id, new Date(), error);
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);
314
386
  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);
387
+ if (isSessionActive(ctx, generation)) {
388
+ const message = `Scheduled task ${currentTask.id} failed: ${error?.message ?? String(error)}`;
389
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
390
+ recordMessage(`⚠️ ${message}`, { task: currentTask, error: error?.message ?? String(error) }, false);
391
+ }
318
392
  } finally {
319
393
  firing.delete(task.id);
320
- rescheduleAll(ctx);
394
+ if (isSessionActive(ctx, generation)) rescheduleAll(ctx, generation);
395
+ else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
321
396
  }
322
397
  }
323
398
 
324
399
  async function createAndSchedule(input: Record<string, any>, ctx: ExtensionContext): Promise<ScheduledTask> {
325
400
  const scope = input.scope ?? "session";
401
+ const sessionFile = scope === "session" ? currentSessionFile(ctx) : undefined;
402
+ if (scope === "session" && !sessionFile) {
403
+ throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
404
+ }
326
405
  const task = core.createScheduledTask(
327
406
  {
328
407
  ...input,
329
408
  schedule: input.schedule ?? input.when ?? input.whenText,
330
409
  cwd: input.cwd ?? ctx.cwd,
331
410
  scope,
332
- sessionFile: scope === "session" ? currentSessionFile(ctx) : undefined,
411
+ sessionFile,
333
412
  },
334
413
  new Date(),
335
414
  );
@@ -385,11 +464,34 @@ export default function schedulerExtension(pi: ExtensionAPI) {
385
464
 
386
465
  pi.on("session_start", async (_event, ctx) => {
387
466
  activeCtx = ctx;
467
+ const generation = ++sessionGeneration;
388
468
  await loadTasks();
389
- rescheduleAll(ctx);
469
+ if (!isSessionActive(ctx, generation)) return;
470
+
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
+ }
481
+ }
482
+
483
+ rescheduleAll(ctx, generation);
484
+ const catchUpOptions = core.parseCatchUpOptions(process.env);
485
+ void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
486
+ if (isSessionActive(ctx, generation) && ctx.hasUI) {
487
+ ctx.ui.notify(`Failed to catch up missed cron tasks: ${error?.message ?? String(error)}`, "error");
488
+ }
489
+ });
390
490
  });
391
491
 
392
492
  pi.on("session_shutdown", async (_event, ctx) => {
493
+ if (activeCtx !== ctx) return;
494
+ ++sessionGeneration;
393
495
  clearTimers();
394
496
  if (ctx.hasUI) {
395
497
  ctx.ui.setStatus("scheduler", undefined);
@@ -689,9 +791,17 @@ export default function schedulerExtension(pi: ExtensionAPI) {
689
791
  task = core.removeScheduledTask(tasks, visibleRemoved.id);
690
792
  clearHandle(task.id);
691
793
  } else {
692
- const updates = { ...params };
794
+ const updates: Record<string, any> = { ...params };
693
795
  delete updates.action;
694
796
  delete updates.id;
797
+ if (updates.scope === "session") {
798
+ updates.sessionFile = currentSessionFile(ctx);
799
+ if (!updates.sessionFile) {
800
+ throw new Error("Session-scoped tasks require a persisted Pi session");
801
+ }
802
+ } else if (updates.scope !== undefined) {
803
+ updates.sessionFile = null;
804
+ }
695
805
  task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
696
806
  }
697
807
 
@@ -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,
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.0",
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",