@jl1990/pi-scheduler 0.2.3 → 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");
@@ -170,9 +189,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
170
189
 
171
190
  const lines = ["⏰ Scheduled Actions"];
172
191
  for (const task of upcoming) {
173
- const next = task.nextRun ? core.formatRelativeTime(task.nextRun) : "no next run";
192
+ const relative = task.nextRun ? core.formatRelativeTime(task.nextRun) : "no next run";
193
+ const absolute = task.nextRun ? core.formatAbsoluteTime(task.nextRun) : "";
194
+ const when = absolute ? `${relative} (${absolute})` : relative;
174
195
  const last = task.lastStatus ? ` last=${task.lastStatus}` : "";
175
- lines.push(` ✓ ${taskLabel(task)} ${task.action}/${task.type} ${next} runs=${task.runCount ?? 0}${last}`);
196
+ lines.push(` ✓ ${taskLabel(task)} ${task.action}/${task.type} ${when} runs=${task.runCount ?? 0}${last}`);
176
197
  }
177
198
  ctx.ui.setWidget("scheduler", lines, { placement: "belowEditor" });
178
199
  }
@@ -184,7 +205,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
184
205
  updateWidget(ctx);
185
206
  }
186
207
 
187
- function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext): void {
208
+ function scheduleTaskHandle(task: ScheduledTask, ctx: ExtensionContext, generation = sessionGeneration): void {
209
+ if (!isSessionActive(ctx, generation)) return;
188
210
  if (task.enabled === false || task.status !== "pending") return;
189
211
  if (!taskBelongsToSession(task, ctx)) return;
190
212
  clearHandle(task.id);
@@ -192,7 +214,7 @@ export default function schedulerExtension(pi: ExtensionAPI) {
192
214
  if (task.type === "cron") {
193
215
  try {
194
216
  const cron = new Cron(task.schedule, () => {
195
- void fireTask(task.id, ctx);
217
+ void fireTask(task.id, ctx, generation);
196
218
  });
197
219
  handles.set(task.id, { kind: "cron", handle: cron });
198
220
  } catch (error: any) {
@@ -212,22 +234,48 @@ export default function schedulerExtension(pi: ExtensionAPI) {
212
234
 
213
235
  const timer = setTimeout(() => {
214
236
  handles.delete(task.id);
237
+ if (!isSessionActive(ctx, generation)) return;
215
238
  if (Date.now() < dueAt) {
216
- scheduleTaskHandle(task, ctx);
239
+ scheduleTaskHandle(task, ctx, generation);
217
240
  return;
218
241
  }
219
- void fireTask(task.id, ctx);
242
+ void fireTask(task.id, ctx, generation);
220
243
  }, timerDelay);
221
244
  handles.set(task.id, { kind: "timeout", handle: timer });
222
245
  }
223
246
 
224
- function rescheduleAll(ctx = activeCtx): void {
225
- if (!ctx) return;
247
+ function rescheduleAll(ctx = activeCtx, generation = sessionGeneration): void {
248
+ if (!ctx || !isSessionActive(ctx, generation)) return;
226
249
  clearTimers();
227
- for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx);
250
+ for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
228
251
  updateStatus(ctx);
229
252
  }
230
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
+
231
279
  function recordMessage(content: string, details?: Record<string, any>, triggerTurn = false): void {
232
280
  pi.sendMessage(
233
281
  {
@@ -240,7 +288,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
240
288
  );
241
289
  }
242
290
 
243
- 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>> {
244
296
  if (task.action === "notify") {
245
297
  const message = task.message ?? "Scheduled reminder";
246
298
  if (ctx.hasUI) ctx.ui.notify(message, "info");
@@ -277,6 +329,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
277
329
  stderr: truncateMiddle(result.stderr ?? "", MAX_STORED_OUTPUT_CHARS),
278
330
  };
279
331
 
332
+ if (!isActive()) return { ...shellResult, outputSuppressed: true };
333
+
280
334
  recordMessage(
281
335
  `🖥️ Scheduled command ${task.id} finished with exit code ${result.code}: ${task.command}`,
282
336
  { task, result: shellResult },
@@ -294,40 +348,67 @@ export default function schedulerExtension(pi: ExtensionAPI) {
294
348
  throw new Error(`Unsupported scheduled action: ${task.action}`);
295
349
  }
296
350
 
297
- 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;
298
353
  const task = tasks.find((candidate) => candidate.id === taskId);
299
354
  if (!task || task.enabled === false || task.status !== "pending" || firing.has(task.id)) return;
300
355
  if (!taskBelongsToSession(task, ctx)) return;
301
356
 
302
357
  firing.add(task.id);
358
+ const attemptId = randomUUID();
303
359
  try {
304
- 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
+ });
305
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
+ }
306
376
  updateStatus(ctx);
307
- const result = await executeTask(task, ctx);
308
- 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 });
309
381
  await saveTasks();
310
382
  } catch (error: any) {
311
- 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);
312
386
  await saveTasks();
313
- const message = `Scheduled task ${task.id} failed: ${error?.message ?? String(error)}`;
314
- if (ctx.hasUI) ctx.ui.notify(message, "error");
315
- 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
+ }
316
392
  } finally {
317
393
  firing.delete(task.id);
318
- rescheduleAll(ctx);
394
+ if (isSessionActive(ctx, generation)) rescheduleAll(ctx, generation);
395
+ else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
319
396
  }
320
397
  }
321
398
 
322
399
  async function createAndSchedule(input: Record<string, any>, ctx: ExtensionContext): Promise<ScheduledTask> {
323
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
+ }
324
405
  const task = core.createScheduledTask(
325
406
  {
326
407
  ...input,
327
408
  schedule: input.schedule ?? input.when ?? input.whenText,
328
409
  cwd: input.cwd ?? ctx.cwd,
329
410
  scope,
330
- sessionFile: scope === "session" ? currentSessionFile(ctx) : undefined,
411
+ sessionFile,
331
412
  },
332
413
  new Date(),
333
414
  );
@@ -383,11 +464,34 @@ export default function schedulerExtension(pi: ExtensionAPI) {
383
464
 
384
465
  pi.on("session_start", async (_event, ctx) => {
385
466
  activeCtx = ctx;
467
+ const generation = ++sessionGeneration;
386
468
  await loadTasks();
387
- 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
+ });
388
490
  });
389
491
 
390
492
  pi.on("session_shutdown", async (_event, ctx) => {
493
+ if (activeCtx !== ctx) return;
494
+ ++sessionGeneration;
391
495
  clearTimers();
392
496
  if (ctx.hasUI) {
393
497
  ctx.ui.setStatus("scheduler", undefined);
@@ -687,9 +791,17 @@ export default function schedulerExtension(pi: ExtensionAPI) {
687
791
  task = core.removeScheduledTask(tasks, visibleRemoved.id);
688
792
  clearHandle(task.id);
689
793
  } else {
690
- const updates = { ...params };
794
+ const updates: Record<string, any> = { ...params };
691
795
  delete updates.action;
692
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
+ }
693
805
  task = core.updateScheduledTask(visibleTasks(ctx), params.id, updates, new Date());
694
806
  }
695
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;
@@ -670,6 +743,24 @@ function formatRelativeTime(dueAt, nowValue = new Date()) {
670
743
  return `in ${parts.join(" ") || "<1s"}`;
671
744
  }
672
745
 
746
+ function formatAbsoluteTime(nextRun, nowValue = new Date()) {
747
+ const date = new Date(nextRun);
748
+ if (!Number.isFinite(date.getTime())) return "unknown";
749
+ const now = asDate(nowValue);
750
+ const tomorrow = new Date(now);
751
+ tomorrow.setDate(tomorrow.getDate() + 1);
752
+
753
+ const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
754
+
755
+ if (date.toDateString() === now.toDateString()) {
756
+ return `at ${timeStr}`;
757
+ }
758
+ if (date.toDateString() === tomorrow.toDateString()) {
759
+ return `tomorrow at ${timeStr}`;
760
+ }
761
+ return `on ${date.toLocaleDateString([], { month: "short", day: "numeric" })} at ${timeStr}`;
762
+ }
763
+
673
764
  function formatSchedule(task) {
674
765
  if (task.type === "interval") return `every ${task.schedule}`;
675
766
  if (task.type === "cron") return `cron ${task.schedule}`;
@@ -713,8 +804,12 @@ module.exports = {
713
804
  createScheduledTask,
714
805
  normalizeTask,
715
806
  sanitizeTasks,
807
+ taskMatchesScope,
716
808
  pendingTasks,
717
809
  dueTasks,
810
+ parseCatchUpOptions,
811
+ selectCatchUpCronTasks,
812
+ recoverInterruptedTasks,
718
813
  cancelScheduledTask,
719
814
  disableScheduledTask,
720
815
  enableScheduledTask,
@@ -726,6 +821,7 @@ module.exports = {
726
821
  markScheduledTaskFailed,
727
822
  shouldWakeForShellResult,
728
823
  selectShellFollowUpPrompt,
824
+ formatAbsoluteTime,
729
825
  formatRelativeTime,
730
826
  formatTaskLine,
731
827
  formatTaskList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jl1990/pi-scheduler",
3
- "version": "0.2.3",
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",