@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 +13 -1
- package/extensions/scheduler/index.ts +139 -27
- package/extensions/scheduler/scheduler-core.cjs +99 -3
- package/package.json +1 -1
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
|
-
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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} ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|