@jl1990/pi-scheduler 0.3.0 → 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 +193 -142
- package/extensions/scheduler/scheduler-coordination.cjs +55 -0
- package/extensions/scheduler/scheduler-core.cjs +31 -4
- package/extensions/scheduler/scheduler-lifecycle.cjs +16 -0
- 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,13 +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");
|
|
12
|
+
const lifecycle = require("./scheduler-lifecycle.cjs");
|
|
13
|
+
const coordination = require("./scheduler-coordination.cjs");
|
|
14
|
+
const { createTaskStore } = require("./task-store.cjs");
|
|
13
15
|
|
|
14
16
|
const ACTIONS = ["notify", "prompt", "shell", "message"] as const;
|
|
15
17
|
const TYPES = ["once", "interval", "cron"] as const;
|
|
@@ -17,8 +19,9 @@ const SCOPES = ["session", "cwd", "global"] as const;
|
|
|
17
19
|
const WAKE_ON = ["always", "failure", "success", "never"] as const;
|
|
18
20
|
const MANAGE_ACTIONS = ["enable", "disable", "remove", "update", "cleanup"] as const;
|
|
19
21
|
|
|
20
|
-
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");
|
|
21
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;
|
|
22
25
|
const DEFAULT_SHELL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
23
26
|
const MAX_STORED_OUTPUT_CHARS = 12_000;
|
|
24
27
|
const MAX_PROMPT_OUTPUT_CHARS = 18_000;
|
|
@@ -118,43 +121,35 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
118
121
|
let handles = new Map<string, TimerHandle>();
|
|
119
122
|
let activeCtx: ExtensionContext | undefined;
|
|
120
123
|
let sessionGeneration = 0;
|
|
121
|
-
let
|
|
124
|
+
let stateRevision = -1;
|
|
122
125
|
let widgetEnabled = true;
|
|
123
126
|
const firing = new Set<string>();
|
|
127
|
+
const store = createTaskStore({ stateFile: STATE_FILE, sanitize: core.sanitizeTasks });
|
|
124
128
|
|
|
125
129
|
function isSessionActive(ctx: ExtensionContext, generation = sessionGeneration): boolean {
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
ctx.isIdle(); // Pi throws when a captured extension context has become stale.
|
|
129
|
-
return true;
|
|
130
|
-
} catch {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
130
|
+
return lifecycle.isSessionContextActive(activeCtx, ctx, sessionGeneration, generation);
|
|
133
131
|
}
|
|
134
132
|
|
|
135
|
-
async function loadTasks(): Promise<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
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
|
+
);
|
|
147
144
|
}
|
|
148
145
|
|
|
149
|
-
async function
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
157
|
-
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;
|
|
158
153
|
}
|
|
159
154
|
|
|
160
155
|
function clearHandle(id: string): void {
|
|
@@ -218,11 +213,15 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
218
213
|
});
|
|
219
214
|
handles.set(task.id, { kind: "cron", handle: cron });
|
|
220
215
|
} catch (error: any) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
});
|
|
226
225
|
}
|
|
227
226
|
return;
|
|
228
227
|
}
|
|
@@ -244,13 +243,45 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
244
243
|
handles.set(task.id, { kind: "timeout", handle: timer });
|
|
245
244
|
}
|
|
246
245
|
|
|
247
|
-
function rescheduleAll(
|
|
246
|
+
function rescheduleAll(generation = sessionGeneration): void {
|
|
247
|
+
const ctx = activeCtx;
|
|
248
248
|
if (!ctx || !isSessionActive(ctx, generation)) return;
|
|
249
249
|
clearTimers();
|
|
250
250
|
for (const task of core.pendingTasks(tasks)) scheduleTaskHandle(task, ctx, generation);
|
|
251
251
|
updateStatus(ctx);
|
|
252
252
|
}
|
|
253
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
|
+
|
|
254
285
|
async function catchUpOverdueCronTasks(
|
|
255
286
|
ctx: ExtensionContext,
|
|
256
287
|
generation: number,
|
|
@@ -268,11 +299,10 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
268
299
|
|
|
269
300
|
for (const { task, missedAt } of overdue) {
|
|
270
301
|
if (!isSessionActive(ctx, generation)) return;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
await fireTask(task.id, ctx, generation);
|
|
302
|
+
await fireTask(task.id, ctx, generation, {
|
|
303
|
+
expectedNextRun: task.nextRun ?? task.dueAt,
|
|
304
|
+
occurrence: missedAt.toISOString(),
|
|
305
|
+
});
|
|
276
306
|
}
|
|
277
307
|
}
|
|
278
308
|
|
|
@@ -348,51 +378,75 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
348
378
|
throw new Error(`Unsupported scheduled action: ${task.action}`);
|
|
349
379
|
}
|
|
350
380
|
|
|
351
|
-
async function fireTask(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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;
|
|
356
388
|
|
|
357
|
-
firing.add(
|
|
389
|
+
firing.add(taskId);
|
|
358
390
|
const attemptId = randomUUID();
|
|
391
|
+
let task: ScheduledTask | undefined;
|
|
359
392
|
try {
|
|
360
|
-
|
|
361
|
-
|
|
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 } };
|
|
362
411
|
});
|
|
363
|
-
|
|
412
|
+
if (!task) return;
|
|
413
|
+
|
|
364
414
|
if (!isSessionActive(ctx, generation)) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
delete
|
|
372
|
-
|
|
373
|
-
}
|
|
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
|
+
});
|
|
374
424
|
return;
|
|
375
425
|
}
|
|
426
|
+
|
|
376
427
|
updateStatus(ctx);
|
|
377
428
|
const result = await executeTask(task, ctx, () => isSessionActive(ctx, generation));
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
});
|
|
382
434
|
} catch (error: any) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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)}`;
|
|
389
444
|
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
390
|
-
recordMessage(`⚠️ ${message}`, { task:
|
|
445
|
+
recordMessage(`⚠️ ${message}`, { task: failedTask, error: error?.message ?? String(error) }, false);
|
|
391
446
|
}
|
|
392
447
|
} finally {
|
|
393
|
-
firing.delete(
|
|
394
|
-
if (
|
|
395
|
-
else if (activeCtx) rescheduleAll(activeCtx, sessionGeneration);
|
|
448
|
+
firing.delete(taskId);
|
|
449
|
+
if (activeCtx) rescheduleAll(sessionGeneration);
|
|
396
450
|
}
|
|
397
451
|
}
|
|
398
452
|
|
|
@@ -402,19 +456,21 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
402
456
|
if (scope === "session" && !sessionFile) {
|
|
403
457
|
throw new Error("Session-scoped tasks require a persisted Pi session; use scope 'cwd' or 'global' instead");
|
|
404
458
|
}
|
|
405
|
-
const task =
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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();
|
|
418
474
|
return task;
|
|
419
475
|
}
|
|
420
476
|
|
|
@@ -431,12 +487,12 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
431
487
|
return { ...base, message: parsed.payload };
|
|
432
488
|
}
|
|
433
489
|
|
|
434
|
-
function cleanupVisibleTasks(ctx: ExtensionContext): ScheduledTask[] {
|
|
435
|
-
const removable =
|
|
436
|
-
(task) => task
|
|
437
|
-
|
|
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));
|
|
438
494
|
const removableIds = new Set(removable.map((task) => task.id));
|
|
439
|
-
|
|
495
|
+
current.splice(0, current.length, ...current.filter((task) => !removableIds.has(task.id)));
|
|
440
496
|
for (const task of removable) clearHandle(task.id);
|
|
441
497
|
return removable;
|
|
442
498
|
}
|
|
@@ -446,11 +502,11 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
446
502
|
id: string,
|
|
447
503
|
mutator: (visible: ScheduledTask[]) => ScheduledTask,
|
|
448
504
|
): Promise<ScheduledTask> {
|
|
449
|
-
await
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
rescheduleAll(
|
|
505
|
+
const task = await transactTasks((current) => {
|
|
506
|
+
const visible = current.filter((candidate) => taskBelongsToSession(candidate, ctx));
|
|
507
|
+
return mutator(visible);
|
|
508
|
+
});
|
|
509
|
+
rescheduleAll();
|
|
454
510
|
return task;
|
|
455
511
|
}
|
|
456
512
|
|
|
@@ -465,22 +521,20 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
465
521
|
pi.on("session_start", async (_event, ctx) => {
|
|
466
522
|
activeCtx = ctx;
|
|
467
523
|
const generation = ++sessionGeneration;
|
|
468
|
-
await
|
|
524
|
+
const interrupted = await transactTasks((current) =>
|
|
525
|
+
core.recoverInterruptedTasks(current, new Date(), { isOwnerActive: isRunOwnerActive }),
|
|
526
|
+
);
|
|
469
527
|
if (!isSessionActive(ctx, generation)) return;
|
|
470
528
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
ctx.ui.notify(
|
|
477
|
-
`Recovered ${interrupted.length} task(s) interrupted before completion; recurring tasks were rescheduled`,
|
|
478
|
-
"warning",
|
|
479
|
-
);
|
|
480
|
-
}
|
|
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
|
+
);
|
|
481
534
|
}
|
|
482
535
|
|
|
483
|
-
rescheduleAll(
|
|
536
|
+
rescheduleAll(generation);
|
|
537
|
+
startStateRefresh(generation);
|
|
484
538
|
const catchUpOptions = core.parseCatchUpOptions(process.env);
|
|
485
539
|
void catchUpOverdueCronTasks(ctx, generation, catchUpOptions).catch((error: any) => {
|
|
486
540
|
if (isSessionActive(ctx, generation) && ctx.hasUI) {
|
|
@@ -490,9 +544,9 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
490
544
|
});
|
|
491
545
|
|
|
492
546
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
493
|
-
if (activeCtx !== ctx) return;
|
|
494
547
|
++sessionGeneration;
|
|
495
548
|
clearTimers();
|
|
549
|
+
refreshLoop.stop();
|
|
496
550
|
if (ctx.hasUI) {
|
|
497
551
|
ctx.ui.setStatus("scheduler", undefined);
|
|
498
552
|
ctx.ui.setWidget("scheduler", undefined);
|
|
@@ -596,12 +650,13 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
596
650
|
handler: async (args, ctx) => {
|
|
597
651
|
const id = args.trim();
|
|
598
652
|
try {
|
|
599
|
-
await
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
});
|
|
602
658
|
clearHandle(removed.id);
|
|
603
|
-
|
|
604
|
-
rescheduleAll(ctx);
|
|
659
|
+
rescheduleAll();
|
|
605
660
|
ctx.ui.notify(`Removed scheduled task ${removed.id}`, "info");
|
|
606
661
|
} catch (error: any) {
|
|
607
662
|
ctx.ui.notify(error?.message ?? String(error), "error");
|
|
@@ -612,10 +667,8 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
612
667
|
pi.registerCommand("schedule-cleanup", {
|
|
613
668
|
description: "Remove disabled/completed/cancelled/failed scheduled tasks visible to this session",
|
|
614
669
|
handler: async (_args, ctx) => {
|
|
615
|
-
await
|
|
616
|
-
|
|
617
|
-
await saveTasks();
|
|
618
|
-
rescheduleAll(ctx);
|
|
670
|
+
const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
|
|
671
|
+
rescheduleAll();
|
|
619
672
|
ctx.ui.notify(`Cleaned up ${removed.length} scheduled task(s)`, "info");
|
|
620
673
|
},
|
|
621
674
|
});
|
|
@@ -772,41 +825,39 @@ export default function schedulerExtension(pi: ExtensionAPI) {
|
|
|
772
825
|
failurePrompt: Type.Optional(Type.String()),
|
|
773
826
|
}),
|
|
774
827
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
775
|
-
await loadTasks();
|
|
776
828
|
if (params.action === "cleanup") {
|
|
777
|
-
const removed = cleanupVisibleTasks(ctx);
|
|
778
|
-
|
|
779
|
-
rescheduleAll(ctx);
|
|
829
|
+
const removed = await transactTasks((current) => cleanupVisibleTasks(current, ctx));
|
|
830
|
+
rescheduleAll();
|
|
780
831
|
return { content: [{ type: "text", text: `Cleaned up ${removed.length} scheduled task(s).` }], details: { removed } };
|
|
781
832
|
}
|
|
782
833
|
|
|
783
834
|
if (!params.id) throw new Error("id is required for this management action");
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
+
|
|
794
848
|
const updates: Record<string, any> = { ...params };
|
|
795
849
|
delete updates.action;
|
|
796
850
|
delete updates.id;
|
|
797
851
|
if (updates.scope === "session") {
|
|
798
852
|
updates.sessionFile = currentSessionFile(ctx);
|
|
799
|
-
if (!updates.sessionFile)
|
|
800
|
-
throw new Error("Session-scoped tasks require a persisted Pi session");
|
|
801
|
-
}
|
|
853
|
+
if (!updates.sessionFile) throw new Error("Session-scoped tasks require a persisted Pi session");
|
|
802
854
|
} else if (updates.scope !== undefined) {
|
|
803
855
|
updates.sessionFile = null;
|
|
804
856
|
}
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
rescheduleAll(ctx);
|
|
857
|
+
return core.updateScheduledTask(visible, params.id, updates, new Date());
|
|
858
|
+
});
|
|
859
|
+
if (params.action === "remove") clearHandle(task.id);
|
|
860
|
+
rescheduleAll();
|
|
810
861
|
return {
|
|
811
862
|
content: [{ type: "text", text: `${params.action} scheduled task ${task.id}` }],
|
|
812
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
|
}
|
|
@@ -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 };
|