@jl1990/pi-scheduler 0.3.1 → 0.4.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 +4 -1
- package/extensions/scheduler/index.ts +191 -135
- package/extensions/scheduler/scheduler-coordination.cjs +55 -0
- package/extensions/scheduler/scheduler-core.cjs +31 -4
- package/extensions/scheduler/task-store.cjs +64 -0
- package/node_modules/graceful-fs/LICENSE +15 -0
- package/node_modules/graceful-fs/README.md +143 -0
- package/node_modules/graceful-fs/clone.js +23 -0
- package/node_modules/graceful-fs/graceful-fs.js +448 -0
- package/node_modules/graceful-fs/legacy-streams.js +118 -0
- package/node_modules/graceful-fs/package.json +53 -0
- package/node_modules/graceful-fs/polyfills.js +355 -0
- package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
- package/node_modules/proper-lockfile/LICENSE +21 -0
- package/node_modules/proper-lockfile/README.md +183 -0
- package/node_modules/proper-lockfile/index.js +40 -0
- package/node_modules/proper-lockfile/lib/adapter.js +85 -0
- package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
- package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
- package/node_modules/proper-lockfile/package.json +71 -0
- package/node_modules/retry/.npmignore +3 -0
- package/node_modules/retry/.travis.yml +15 -0
- package/node_modules/retry/License +21 -0
- package/node_modules/retry/Makefile +18 -0
- package/node_modules/retry/README.md +227 -0
- package/node_modules/retry/equation.gif +0 -0
- package/node_modules/retry/example/dns.js +31 -0
- package/node_modules/retry/example/stop.js +40 -0
- package/node_modules/retry/index.js +1 -0
- package/node_modules/retry/lib/retry.js +100 -0
- package/node_modules/retry/lib/retry_operation.js +158 -0
- package/node_modules/retry/package.json +32 -0
- package/node_modules/retry/test/common.js +10 -0
- package/node_modules/retry/test/integration/test-forever.js +24 -0
- package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
- package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
- package/node_modules/retry/test/integration/test-timeouts.js +69 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +39 -0
- package/node_modules/signal-exit/index.js +202 -0
- package/node_modules/signal-exit/package.json +38 -0
- package/node_modules/signal-exit/signals.js +53 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Without scheduling, the agent has to stop and hope you come back. With Pi Schedu
|
|
|
28
28
|
- **Scopes** — bind tasks to a session, cwd/project, or all sessions.
|
|
29
29
|
- **Compact widget** — shows the next few scheduled actions below the editor.
|
|
30
30
|
- **Persistent state** — scheduled tasks are stored in `~/.pi/agent/state/scheduler/tasks.json`.
|
|
31
|
+
- **Multi-process coordination** — atomic state transactions and task claims allow multiple Pi agents to share a cwd safely.
|
|
31
32
|
|
|
32
33
|
## Install
|
|
33
34
|
|
|
@@ -235,7 +236,9 @@ Cron catch-up can be configured with environment variables:
|
|
|
235
236
|
|
|
236
237
|
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
|
|
|
238
|
-
Task state is
|
|
239
|
+
Task state is coordinated across Pi processes with an atomic filesystem lock. Multiple agents may share the same cwd: all relevant processes can observe `cwd` tasks, while an atomic claim ensures only one process executes each due occurrence. The process that wins the claim receives prompt/message follow-ups. Use `session` scope when a follow-up must return to one specific Pi session.
|
|
240
|
+
|
|
241
|
+
Running processes refresh shared scheduler state every five seconds so they can discover tasks created by another agent. `global` tasks use the same single-claim behavior across all running Pi processes.
|
|
239
242
|
|
|
240
243
|
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.
|
|
241
244
|
|
|
@@ -3,14 +3,15 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Cron } from "croner";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
7
6
|
import { homedir } from "node:os";
|
|
8
|
-
import {
|
|
7
|
+
import { join } from "node:path";
|
|
9
8
|
import { Type } from "typebox";
|
|
10
9
|
|
|
11
10
|
// Keep the scheduler logic testable from plain node --test.
|
|
12
11
|
const core = require("./scheduler-core.cjs");
|
|
13
12
|
const lifecycle = require("./scheduler-lifecycle.cjs");
|
|
13
|
+
const coordination = require("./scheduler-coordination.cjs");
|
|
14
|
+
const { createTaskStore } = require("./task-store.cjs");
|
|
14
15
|
|
|
15
16
|
const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
|
|
16
17
|
const TYPES = ["once", "interval", "cron"] as const;
|
|
@@ -18,8 +19,9 @@ const SCOPES = ["session", "cwd", "global"] as const;
|
|
|
18
19
|
const WAKE_ON = ["always", "failure", "success", "never"] as const;
|
|
19
20
|
const MANAGE_ACTIONS = ["enable", "disable", "remove", "update", "cleanup"] as const;
|
|
20
21
|
|
|
21
|
-
const STATE_FILE = join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
|
|
22
|
+
const STATE_FILE = process.env.PI_SCHEDULER_STATE_FILE || join(homedir(), ".pi", "agent", "state", "scheduler", "tasks.json");
|
|
22
23
|
const MAX_TIMER_DELAY_MS = 2_147_483_647; // setTimeout's practical max (~24.8 days)
|
|
24
|
+
const STATE_REFRESH_INTERVAL_MS = 5_000;
|
|
23
25
|
const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
26
|
const MAX_STORED_OUTPUT_CHARS = 12_000;
|
|
25
27
|
const MAX_PROMPT_OUTPUT_CHARS = 18_000;
|
|
@@ -119,37 +121,35 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
119
121
|
let handles = new Map<string, TimerHandle>();
|
|
120
122
|
let activeCtx: ExtensionContext | undefined;
|
|
121
123
|
let sessionGeneration = 0;
|
|
122
|
-
let
|
|
124
|
+
let stateRevision = -1;
|
|
123
125
|
let widgetEnabled = true;
|
|
124
126
|
const firing = new Set<string>();
|
|
127
|
+
const store = createTaskStore({ stateFile: STATE_FILE, sanitize: core.sanitizeTasks });
|
|
125
128
|
|
|
126
129
|
function isSessionActive(ctx: ExtensionContext, generation = sessionGeneration): boolean {
|
|
127
130
|
return lifecycle.isSessionContextActive(activeCtx, ctx, sessionGeneration, generation);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
async function loadTasks(): Promise<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
133
|
+
async function loadTasks(): Promise<boolean> {
|
|
134
|
+
const snapshot = await store.read();
|
|
135
|
+
return coordination.reconcileSnapshot(
|
|
136
|
+
snapshot,
|
|
137
|
+
stateRevision,
|
|
138
|
+
(nextTasks: ScheduledTask[], revision: number) => {
|
|
139
|
+
tasks = nextTasks;
|
|
140
|
+
stateRevision = revision;
|
|
141
|
+
},
|
|
142
|
+
() => rescheduleAll(),
|
|
143
|
+
);
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
async function
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
return saveQueue;
|
|
146
|
+
async function transactTasks<T>(mutator: (current: ScheduledTask[]) => T | Promise<T>): Promise<T> {
|
|
147
|
+
const transaction = await store.transact(mutator);
|
|
148
|
+
if (transaction.revision >= stateRevision) {
|
|
149
|
+
tasks = transaction.tasks;
|
|
150
|
+
stateRevision = transaction.revision;
|
|
151
|
+
}
|
|
152
|
+
return transaction.result;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
function clearHandle(id: string): void {
|
|
@@ -213,11 +213,15 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
213
213
|
});
|
|
214
214
|
handles.set(task.id, { kind: "cron", handle: cron });
|
|
215
215
|
} catch (error: any) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
const message = error?.message ?? String(error);
|
|
217
|
+
void transactTasks((current) => {
|
|
218
|
+
const persisted = current.find((candidate) => candidate.id === task.id);
|
|
219
|
+
if (!persisted) return;
|
|
220
|
+
persisted.enabled = false;
|
|
221
|
+
persisted.status = "failed";
|
|
222
|
+
persisted.lastStatus = "error";
|
|
223
|
+
persisted.lastError = message;
|
|
224
|
+
});
|
|
221
225
|
}
|
|
222
226
|
return;
|
|
223
227
|
}
|
|
@@ -239,13 +243,45 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
239
243
|
handles.set(task.id, { kind: "timeout", handle: timer });
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
function rescheduleAll(
|
|
246
|
+
function rescheduleAll(generation = sessionGeneration): void {
|
|
247
|
+
const ctx = activeCtx;
|
|
243
248
|
if (!ctx || !isSessionActive(ctx, generation)) return;
|
|
244
249
|
clearTimers();
|
|
245
250
|
for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
|
|
246
251
|
updateStatus(ctx);
|
|
247
252
|
}
|
|
248
253
|
|
|
254
|
+
async function refreshFromStore(generation = sessionGeneration): Promise<void> {
|
|
255
|
+
const ctx = activeCtx;
|
|
256
|
+
if (!ctx || !isSessionActive(ctx, generation)) return;
|
|
257
|
+
await coordination.refreshSchedulerState({
|
|
258
|
+
store,
|
|
259
|
+
currentRevision: () => stateRevision,
|
|
260
|
+
isOwnerActive: isRunOwnerActive,
|
|
261
|
+
recoverInterrupted: core.recoverInterruptedTasks,
|
|
262
|
+
now: () => new Date(),
|
|
263
|
+
install: (nextTasks: ScheduledTask[], revision: number) => {
|
|
264
|
+
tasks = nextTasks;
|
|
265
|
+
stateRevision = revision;
|
|
266
|
+
},
|
|
267
|
+
reconcile: () => rescheduleAll(generation),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const refreshLoop = coordination.createRefreshLoop({
|
|
272
|
+
intervalMs: STATE_REFRESH_INTERVAL_MS,
|
|
273
|
+
run: refreshFromStore,
|
|
274
|
+
onError: (error: any) => {
|
|
275
|
+
if (activeCtx?.hasUI) activeCtx.ui.notify(`Scheduler state refresh failed: ${error?.message ?? String(error)}`, "error");
|
|
276
|
+
},
|
|
277
|
+
setInterval,
|
|
278
|
+
clearInterval,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
function startStateRefresh(generation: number): void {
|
|
282
|
+
refreshLoop.start(generation);
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
async function catchUpOverdueCronTasks(
|
|
250
286
|
ctx: ExtensionContext,
|
|
251
287
|
generation: number,
|
|
@@ -263,11 +299,10 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
263
299
|
|
|
264
300
|
for (const { task, missedAt } of overdue) {
|
|
265
301
|
if (!isSessionActive(ctx, generation)) return;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
await fireTask(task.id, ctx, generation);
|
|
302
|
+
await fireTask(task.id, ctx, generation, {
|
|
303
|
+
expectedNextRun: task.nextRun ?? task.dueAt,
|
|
304
|
+
occurrence: missedAt.toISOString(),
|
|
305
|
+
});
|
|
271
306
|
}
|
|
272
307
|
}
|
|
273
308
|
|
|
@@ -343,51 +378,75 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
343
378
|
throw new Error(`Unsupported scheduled action: ${task.action}`);
|
|
344
379
|
}
|
|
345
380
|
|
|
346
|
-
async function fireTask(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
381
|
+
async function fireTask(
|
|
382
|
+
taskId: string,
|
|
383
|
+
ctx: ExtensionContext,
|
|
384
|
+
generation = sessionGeneration,
|
|
385
|
+
claimOptions?: { expectedNextRun?: string; occurrence?: string },
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
if (!isSessionActive(ctx, generation) || firing.has(taskId)) return;
|
|
351
388
|
|
|
352
|
-
firing.add(
|
|
389
|
+
firing.add(taskId);
|
|
353
390
|
const attemptId = randomUUID();
|
|
391
|
+
let task: ScheduledTask | undefined;
|
|
354
392
|
try {
|
|
355
|
-
|
|
356
|
-
|
|
393
|
+
// Claim under the shared state lock. Other Pi processes may have armed the
|
|
394
|
+
// same cwd/global task, but only one can transition it to running.
|
|
395
|
+
task = await transactTasks((current) => {
|
|
396
|
+
const candidate = current.find((item) => item.id === taskId);
|
|
397
|
+
if (!candidate || candidate.enabled === false || candidate.status !== "pending") return undefined;
|
|
398
|
+
if (!taskBelongsToSession(candidate, ctx)) return undefined;
|
|
399
|
+
const persistedNextRun = candidate.nextRun ?? candidate.dueAt;
|
|
400
|
+
if (claimOptions?.expectedNextRun && persistedNextRun !== claimOptions.expectedNextRun) return undefined;
|
|
401
|
+
const dueAt = Date.parse(claimOptions?.occurrence ?? persistedNextRun ?? "");
|
|
402
|
+
if (!Number.isFinite(dueAt) || dueAt > Date.now()) return undefined;
|
|
403
|
+
if (claimOptions?.occurrence) {
|
|
404
|
+
candidate.nextRun = claimOptions.occurrence;
|
|
405
|
+
candidate.dueAt = claimOptions.occurrence;
|
|
406
|
+
}
|
|
407
|
+
core.markScheduledTaskRunning(current, candidate.id, new Date(), {
|
|
408
|
+
runOwner: { pid: process.pid, attemptId, sessionFile: currentSessionFile(ctx) },
|
|
409
|
+
});
|
|
410
|
+
return { ...candidate, runOwner: { ...candidate.runOwner } };
|
|
357
411
|
});
|
|
358
|
-
|
|
412
|
+
if (!task) return;
|
|
413
|
+
|
|
359
414
|
if (!isSessionActive(ctx, generation)) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
delete
|
|
367
|
-
|
|
368
|
-
}
|
|
415
|
+
await transactTasks((current) => {
|
|
416
|
+
const persisted = current.find((candidate) => candidate.id === taskId);
|
|
417
|
+
if (persisted?.runOwner?.attemptId !== attemptId) return;
|
|
418
|
+
persisted.status = "pending";
|
|
419
|
+
persisted.lastStatus = "error";
|
|
420
|
+
persisted.lastError = "Scheduled task start was cancelled because the Pi session changed";
|
|
421
|
+
delete persisted.runOwner;
|
|
422
|
+
delete persisted.startedAt;
|
|
423
|
+
});
|
|
369
424
|
return;
|
|
370
425
|
}
|
|
426
|
+
|
|
371
427
|
updateStatus(ctx);
|
|
372
428
|
const result = await executeTask(task, ctx, () => isSessionActive(ctx, generation));
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
429
|
+
await transactTasks((current) => {
|
|
430
|
+
const persisted = current.find((candidate) => candidate.id === taskId);
|
|
431
|
+
if (persisted?.runOwner?.attemptId !== attemptId) return;
|
|
432
|
+
core.markScheduledTaskCompleted(current, persisted.id, new Date(), result, { ok: result.ok !== false });
|
|
433
|
+
});
|
|
377
434
|
} catch (error: any) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
435
|
+
let failedTask: ScheduledTask | undefined;
|
|
436
|
+
await transactTasks((current) => {
|
|
437
|
+
const persisted = current.find((candidate) => candidate.id === taskId);
|
|
438
|
+
if (persisted?.runOwner?.attemptId !== attemptId) return;
|
|
439
|
+
core.markScheduledTaskFailed(current, persisted.id, new Date(), error);
|
|
440
|
+
failedTask = { ...persisted };
|
|
441
|
+
});
|
|
442
|
+
if (failedTask && isSessionActive(ctx, generation)) {
|
|
443
|
+
const message = `Scheduled task ${taskId} failed: ${error?.message ?? String(error)}`;
|
|
384
444
|
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
385
|
-
recordMessage(`⚠️ ${message}`, { task:
|
|
445
|
+
recordMessage(`⚠️ ${message}`, { task: failedTask, error: error?.message ?? String(error) }, false);
|
|
386
446
|
}
|
|
387
447
|
} finally {
|
|
388
|
-
firing.delete(
|
|
389
|
-
if (
|
|
390
|
-
else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
|
|
448
|
+
firing.delete(taskId);
|
|
449
|
+
if (activeCtx) rescheduleAll(sessionGeneration);
|
|
391
450
|
}
|
|
392
451
|
}
|
|
393
452
|
|
|
@@ -397,19 +456,21 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
397
456
|
if (scope === "session" && !sessionFile) {
|
|
398
457
|
throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
|
|
399
458
|
}
|
|
400
|
-
const task =
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
459
|
+
const task = await transactTasks((current) => {
|
|
460
|
+
const created = core.createScheduledTask(
|
|
461
|
+
{
|
|
462
|
+
...input,
|
|
463
|
+
schedule: input.schedule ?? input.when ?? input.whenText,
|
|
464
|
+
cwd: input.cwd ?? ctx.cwd,
|
|
465
|
+
scope,
|
|
466
|
+
sessionFile,
|
|
467
|
+
},
|
|
468
|
+
new Date(),
|
|
469
|
+
);
|
|
470
|
+
current.push(created);
|
|
471
|
+
return created;
|
|
472
|
+
});
|
|
473
|
+
rescheduleAll();
|
|
413
474
|
return task;
|
|
414
475
|
}
|
|
415
476
|
|
|
@@ -426,12 +487,12 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
426
487
|
return { ...base, message: parsed.payload };
|
|
427
488
|
}
|
|
428
489
|
|
|
429
|
-
function cleanupVisibleTasks(ctx: ExtensionContext): ScheduledTask[] {
|
|
430
|
-
const removable =
|
|
431
|
-
(task) => task
|
|
432
|
-
|
|
490
|
+
function cleanupVisibleTasks(current: ScheduledTask[], ctx: ExtensionContext): ScheduledTask[] {
|
|
491
|
+
const removable = current
|
|
492
|
+
.filter((task) => taskBelongsToSession(task, ctx))
|
|
493
|
+
.filter((task) => task.enabled === false || ["fired", "cancelled", "failed"].includes(task.status));
|
|
433
494
|
const removableIds = new Set(removable.map((task) => task.id));
|
|
434
|
-
|
|
495
|
+
current.splice(0, current.length, ...current.filter((task) => !removableIds.has(task.id)));
|
|
435
496
|
for (const task of removable) clearHandle(task.id);
|
|
436
497
|
return removable;
|
|
437
498
|
}
|
|
@@ -441,11 +502,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
441
502
|
id: string,
|
|
442
503
|
mutator: (visible: ScheduledTask[]) => ScheduledTask,
|
|
443
504
|
): Promise<ScheduledTask> {
|
|
444
|
-
await
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
rescheduleAll(
|
|
505
|
+
const task = await transactTasks((current) => {
|
|
506
|
+
const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
|
|
507
|
+
return mutator(visible);
|
|
508
|
+
});
|
|
509
|
+
rescheduleAll();
|
|
449
510
|
return task;
|
|
450
511
|
}
|
|
451
512
|
|
|
@@ -460,22 +521,20 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
460
521
|
pi.on("session_start", async (_event, ctx) => {
|
|
461
522
|
activeCtx = ctx;
|
|
462
523
|
const generation = ++sessionGeneration;
|
|
463
|
-
await
|
|
524
|
+
const interrupted = await transactTasks((current) =>
|
|
525
|
+
core.recoverInterruptedTasks(current, new Date(), { isOwnerActive: isRunOwnerActive }),
|
|
526
|
+
);
|
|
464
527
|
if (!isSessionActive(ctx, generation)) return;
|
|
465
528
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
ctx.ui.notify(
|
|
472
|
-
`Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
|
|
473
|
-
"warning",
|
|
474
|
-
);
|
|
475
|
-
}
|
|
529
|
+
if (interrupted.length > 0 && ctx.hasUI) {
|
|
530
|
+
ctx.ui.notify(
|
|
531
|
+
`Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
|
|
532
|
+
"warning",
|
|
533
|
+
);
|
|
476
534
|
}
|
|
477
535
|
|
|
478
|
-
rescheduleAll(
|
|
536
|
+
rescheduleAll(generation);
|
|
537
|
+
startStateRefresh(generation);
|
|
479
538
|
const catchUpOptions = core.parseCatchUpOptions(process.env);
|
|
480
539
|
void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
|
|
481
540
|
if (isSessionActive(ctx, generation) && ctx.hasUI) {
|
|
@@ -485,9 +544,9 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
485
544
|
});
|
|
486
545
|
|
|
487
546
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
488
|
-
if (!isSessionActive(ctx)) return;
|
|
489
547
|
++sessionGeneration;
|
|
490
548
|
clearTimers();
|
|
549
|
+
refreshLoop.stop();
|
|
491
550
|
if (ctx.hasUI) {
|
|
492
551
|
ctx.ui.setStatus("scheduler", undefined);
|
|
493
552
|
ctx.ui.setWidget("scheduler", undefined);
|
|
@@ -591,12 +650,13 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
591
650
|
handler: async (args, ctx) => {
|
|
592
651
|
const id = args.trim();
|
|
593
652
|
try {
|
|
594
|
-
await
|
|
595
|
-
|
|
596
|
-
|
|
653
|
+
const removed = await transactTasks((current) => {
|
|
654
|
+
const visible = current.filter((task) => taskBelongsToSession(task, ctx));
|
|
655
|
+
const visibleRemoved = core.removeScheduledTask(visible, id);
|
|
656
|
+
return core.removeScheduledTask(current, visibleRemoved.id);
|
|
657
|
+
});
|
|
597
658
|
clearHandle(removed.id);
|
|
598
|
-
|
|
599
|
-
rescheduleAll(ctx);
|
|
659
|
+
rescheduleAll();
|
|
600
660
|
ctx.ui.notify(`Removed scheduled task ${removed.id}`, "info");
|
|
601
661
|
} catch (error: any) {
|
|
602
662
|
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
@@ -607,10 +667,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
607
667
|
pi.registerCommand("schedule-cleanup", {
|
|
608
668
|
description: "Remove disabled/completed/cancelled/failed scheduled tasks visible to this session",
|
|
609
669
|
handler: async (_args, ctx) => {
|
|
610
|
-
await
|
|
611
|
-
|
|
612
|
-
await saveTasks();
|
|
613
|
-
rescheduleAll(ctx);
|
|
670
|
+
const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
|
|
671
|
+
rescheduleAll();
|
|
614
672
|
ctx.ui.notify(`Cleaned up ${removed.length} scheduled task(s)`, "info");
|
|
615
673
|
},
|
|
616
674
|
});
|
|
@@ -767,41 +825,39 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
767
825
|
failurePrompt: Type.Optional(Type.String()),
|
|
768
826
|
}),
|
|
769
827
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
770
|
-
await loadTasks();
|
|
771
828
|
if (params.action === "cleanup") {
|
|
772
|
-
const removed = cleanupVisibleTasks(ctx);
|
|
773
|
-
|
|
774
|
-
rescheduleAll(ctx);
|
|
829
|
+
const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
|
|
830
|
+
rescheduleAll();
|
|
775
831
|
return { content: [{ type: "text", text: `Cleaned up ${removed.length} scheduled task(s).` }], details: { removed } };
|
|
776
832
|
}
|
|
777
833
|
|
|
778
834
|
if (!params.id) throw new Error("id is required for this management action");
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
835
|
+
const task = await transactTasks((current) => {
|
|
836
|
+
const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
|
|
837
|
+
if (params.action === "enable") {
|
|
838
|
+
return core.enableScheduledTask(visible, params.id, new Date());
|
|
839
|
+
}
|
|
840
|
+
if (params.action === "disable") {
|
|
841
|
+
return core.disableScheduledTask(visible, params.id, new Date());
|
|
842
|
+
}
|
|
843
|
+
if (params.action === "remove") {
|
|
844
|
+
const visibleRemoved = core.removeScheduledTask(visible, params.id);
|
|
845
|
+
return core.removeScheduledTask(current, visibleRemoved.id);
|
|
846
|
+
}
|
|
847
|
+
|
|
789
848
|
const updates: Record<string, any> = { ...params };
|
|
790
849
|
delete updates.action;
|
|
791
850
|
delete updates.id;
|
|
792
851
|
if (updates.scope === "session") {
|
|
793
852
|
updates.sessionFile = currentSessionFile(ctx);
|
|
794
|
-
if (!updates.sessionFile)
|
|
795
|
-
throw new Error("Session-scoped tasks require a persisted Pi session");
|
|
796
|
-
}
|
|
853
|
+
if (!updates.sessionFile) throw new Error("Session-scoped tasks require a persisted Pi session");
|
|
797
854
|
} else if (updates.scope !== undefined) {
|
|
798
855
|
updates.sessionFile = null;
|
|
799
856
|
}
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
rescheduleAll(ctx);
|
|
857
|
+
return core.updateScheduledTask(visible, params.id, updates, new Date());
|
|
858
|
+
});
|
|
859
|
+
if (params.action === "remove") clearHandle(task.id);
|
|
860
|
+
rescheduleAll();
|
|
805
861
|
return {
|
|
806
862
|
content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
|
|
807
863
|
details: { task, pending: core.pendingTasks(tasks) },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function reconcileSnapshot(snapshot, currentRevision, install, reconcile) {
|
|
4
|
+
if (snapshot.revision <= currentRevision) return false;
|
|
5
|
+
install(snapshot.tasks, snapshot.revision);
|
|
6
|
+
reconcile();
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function needsInterruptedRunRecovery(tasks, isOwnerActive) {
|
|
11
|
+
return tasks.some((task) => task.status === "running" && !isOwnerActive(task.runOwner));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function refreshSchedulerState(options) {
|
|
15
|
+
let snapshot = await options.store.read();
|
|
16
|
+
if (needsInterruptedRunRecovery(snapshot.tasks, options.isOwnerActive)) {
|
|
17
|
+
const transaction = await options.store.transact((tasks) =>
|
|
18
|
+
options.recoverInterrupted(tasks, options.now(), { isOwnerActive: options.isOwnerActive }),
|
|
19
|
+
);
|
|
20
|
+
snapshot = transaction;
|
|
21
|
+
}
|
|
22
|
+
return reconcileSnapshot(snapshot, options.currentRevision(), options.install, options.reconcile);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createRefreshLoop(options) {
|
|
26
|
+
let handle;
|
|
27
|
+
let inFlight = false;
|
|
28
|
+
|
|
29
|
+
async function tick(generation) {
|
|
30
|
+
if (inFlight) return;
|
|
31
|
+
inFlight = true;
|
|
32
|
+
try {
|
|
33
|
+
await options.run(generation);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
options.onError?.(error);
|
|
36
|
+
} finally {
|
|
37
|
+
inFlight = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stop() {
|
|
42
|
+
if (handle !== undefined) options.clearInterval(handle);
|
|
43
|
+
handle = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function start(generation) {
|
|
47
|
+
stop();
|
|
48
|
+
handle = options.setInterval(() => tick(generation), options.intervalMs);
|
|
49
|
+
handle?.unref?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { start, stop, tick };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createRefreshLoop, reconcileSnapshot, refreshSchedulerState };
|
|
@@ -544,7 +544,6 @@ function disableScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
|
|
|
544
544
|
task.enabled = false;
|
|
545
545
|
task.disabledAt = now.toISOString();
|
|
546
546
|
task.nextRun = undefined;
|
|
547
|
-
if (task.status === "running") task.status = "pending";
|
|
548
547
|
return task;
|
|
549
548
|
}
|
|
550
549
|
|
|
@@ -628,6 +627,7 @@ function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date(), opti
|
|
|
628
627
|
}
|
|
629
628
|
|
|
630
629
|
function finishTaskAfterRun(task, now, ok, result) {
|
|
630
|
+
const remainDisabled = task.enabled === false;
|
|
631
631
|
delete task.runOwner;
|
|
632
632
|
task.runCount = (Number.isInteger(task.runCount) ? task.runCount : 0) + 1;
|
|
633
633
|
task.lastRun = now.toISOString();
|
|
@@ -645,6 +645,13 @@ function finishTaskAfterRun(task, now, ok, result) {
|
|
|
645
645
|
return task;
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
+
if (remainDisabled) {
|
|
649
|
+
task.enabled = false;
|
|
650
|
+
task.status = "pending";
|
|
651
|
+
task.nextRun = undefined;
|
|
652
|
+
return task;
|
|
653
|
+
}
|
|
654
|
+
|
|
648
655
|
task.enabled = true;
|
|
649
656
|
task.status = "pending";
|
|
650
657
|
try {
|
|
@@ -664,7 +671,11 @@ function markScheduledTaskCompleted(tasks, idOrPrefix, nowValue = new Date(), re
|
|
|
664
671
|
const now = asDate(nowValue);
|
|
665
672
|
const task = findTask(tasks, idOrPrefix);
|
|
666
673
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
667
|
-
if (task.status === "cancelled")
|
|
674
|
+
if (task.status === "cancelled") {
|
|
675
|
+
delete task.runOwner;
|
|
676
|
+
delete task.startedAt;
|
|
677
|
+
return task;
|
|
678
|
+
}
|
|
668
679
|
return finishTaskAfterRun(task, now, options.ok !== false, result);
|
|
669
680
|
}
|
|
670
681
|
|
|
@@ -675,6 +686,11 @@ function markScheduledTaskFired(tasks, idOrPrefix, nowValue = new Date(), result
|
|
|
675
686
|
function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error) {
|
|
676
687
|
const task = findTask(tasks, idOrPrefix);
|
|
677
688
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
689
|
+
if (task.status === "cancelled") {
|
|
690
|
+
delete task.runOwner;
|
|
691
|
+
delete task.startedAt;
|
|
692
|
+
return task;
|
|
693
|
+
}
|
|
678
694
|
task.lastError = error instanceof Error ? error.message : String(error);
|
|
679
695
|
return finishTaskAfterRun(task, asDate(nowValue), false, undefined);
|
|
680
696
|
}
|
|
@@ -682,10 +698,21 @@ function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error
|
|
|
682
698
|
function recoverInterruptedTasks(tasks, nowValue = new Date(), options = {}) {
|
|
683
699
|
const now = asDate(nowValue);
|
|
684
700
|
const interrupted = tasks.filter(
|
|
685
|
-
(task) => task.
|
|
701
|
+
(task) => task.status === "running" && !options.isOwnerActive?.(task.runOwner),
|
|
686
702
|
);
|
|
687
703
|
for (const task of interrupted) {
|
|
688
|
-
|
|
704
|
+
const error = new Error("Scheduled task was interrupted before completion");
|
|
705
|
+
if (task.enabled === false) {
|
|
706
|
+
// Preserve an external disable decision while clearing the abandoned run.
|
|
707
|
+
task.status = "pending";
|
|
708
|
+
task.lastStatus = "error";
|
|
709
|
+
task.lastError = error.message;
|
|
710
|
+
task.nextRun = undefined;
|
|
711
|
+
delete task.runOwner;
|
|
712
|
+
delete task.startedAt;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
markScheduledTaskFailed(tasks, task.id, now, error);
|
|
689
716
|
}
|
|
690
717
|
return interrupted;
|
|
691
718
|
}
|