@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 +13 -1
- package/extensions/scheduler/index.ts +135 -25
- package/extensions/scheduler/scheduler-core.cjs +80 -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");
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|