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