@carbon-ni/pi-schedule 0.0.0-stage → 0.1.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.
@@ -0,0 +1,269 @@
1
+ import {
2
+ emptyState,
3
+ createTask,
4
+ cancelTask,
5
+ dueTasks,
6
+ beginDispatch,
7
+ confirmDispatch,
8
+ type State,
9
+ type Task,
10
+ type Creator,
11
+ type CreateRequest,
12
+ } from "../domain/scheduler.ts";
13
+
14
+ export interface Store {
15
+ load(): State;
16
+ save(state: State): void;
17
+ close(): void;
18
+ }
19
+
20
+ interface Dependencies {
21
+ now(): number;
22
+ id(): string;
23
+ available(): boolean;
24
+ deliver(task: Task): void;
25
+ notify(message: string): void;
26
+ }
27
+
28
+ const GRACE_MS = 120_000;
29
+
30
+ export class Runtime {
31
+ private state = emptyState();
32
+ private closed = false;
33
+ private lastTick: number;
34
+ private waiting = new Set<string>();
35
+ private parentId?: string;
36
+
37
+ private readonly store: Store;
38
+ readonly sessionId: string;
39
+ private readonly projectPath: string;
40
+ private readonly deps: Dependencies;
41
+
42
+ constructor(store: Store, sessionId: string, projectPath: string, deps: Dependencies) {
43
+ this.store = store;
44
+ this.sessionId = sessionId;
45
+ this.projectPath = projectPath;
46
+ this.deps = deps;
47
+ this.lastTick = deps.now();
48
+ }
49
+
50
+ start(): void {
51
+ this.state = this.store.load();
52
+ const now = this.deps.now();
53
+ const tasks = { ...this.state.tasks };
54
+ for (const task of Object.values(tasks)) {
55
+ if (task.status === "pending" && task.dueAt <= now) {
56
+ tasks[task.id] = { ...task, status: "overdue" };
57
+ }
58
+ }
59
+ this.commit({ ...this.state, tasks });
60
+ for (const task of this.list().filter((t) => ["overdue", "uncertain"].includes(t.status))) {
61
+ this.deps.notify(`${task.id} is ${task.status}. Inspect /schedule list before recovery.`);
62
+ }
63
+ }
64
+
65
+ get stopped(): boolean {
66
+ return this.closed;
67
+ }
68
+
69
+ private assertOpen(): void {
70
+ if (this.closed) throw new Error("Scheduler is closed. Reload Pi before scheduling.");
71
+ }
72
+
73
+ private commit(next: State): void {
74
+ this.assertOpen();
75
+ try {
76
+ this.store.save(next);
77
+ } catch (error) {
78
+ this.closed = true;
79
+ try {
80
+ this.store.close();
81
+ } catch {
82
+ /* Preserve the persistence failure; stale ownership fails closed. */
83
+ }
84
+ throw new Error(
85
+ `Scheduler stopped after persistence failure: ${String(error)}. Use /reload before retrying.`,
86
+ { cause: error },
87
+ );
88
+ }
89
+ this.state = next;
90
+ }
91
+
92
+ private task(id: string): Task {
93
+ const task = Object.hasOwn(this.state.tasks, id) ? this.state.tasks[id] : undefined;
94
+ if (!task || task.sessionId !== this.sessionId)
95
+ throw new Error("Unknown task. Use /schedule list.");
96
+ return task;
97
+ }
98
+
99
+ list(): Task[] {
100
+ return Object.values(this.state.tasks).map((t) => ({ ...t }));
101
+ }
102
+
103
+ inspect(id: string): { task: Task; chain: State["chains"][string] } {
104
+ const task = this.task(id);
105
+ return { task: { ...task }, chain: { ...this.state.chains[task.rootId] } };
106
+ }
107
+
108
+ newUserRequest(): void {
109
+ this.parentId = undefined;
110
+ this.commit({ ...this.state, activeRootId: undefined });
111
+ }
112
+
113
+ scheduledTurn(id: string): void {
114
+ const task = this.task(id);
115
+ if (task.status === "uncertain") this.commit(confirmDispatch(this.state, id, this.sessionId));
116
+ else if (task.status !== "dispatched")
117
+ throw new Error("No dispatch intent for this scheduled turn.");
118
+ this.parentId = id;
119
+ this.commit({ ...this.state, activeRootId: task.rootId });
120
+ }
121
+
122
+ create(request: Omit<CreateRequest, "projectPath" | "parentId">, creator: Creator): Task {
123
+ this.assertOpen();
124
+ if (Object.keys(this.state.tasks).length >= 1000) {
125
+ throw new Error("Session task history limit reached (1000). Start a new Pi session.");
126
+ }
127
+ let parentId: string | undefined;
128
+ if (creator === "agent" && this.state.activeRootId) {
129
+ const chain = this.state.chains[this.state.activeRootId];
130
+ if (chain.cancelled)
131
+ throw new Error("Follow-up chain is cancelled. Wait for a new user request.");
132
+ if (
133
+ this.list().some(
134
+ (t) =>
135
+ t.rootId === this.state.activeRootId &&
136
+ ["pending", "overdue", "uncertain"].includes(t.status),
137
+ )
138
+ ) {
139
+ throw new Error("Chain already has pending or uncertain work. Use /schedule list.");
140
+ }
141
+ parentId =
142
+ this.parentId ??
143
+ this.list()
144
+ .filter((t) => t.rootId === this.state.activeRootId && t.status === "dispatched")
145
+ .at(-1)?.id;
146
+ if (!parentId) throw new Error("No dispatched parent. Wait for the scheduled check.");
147
+ }
148
+ const id = this.deps.id();
149
+ const next = createTask(
150
+ this.state,
151
+ { ...request, projectPath: this.projectPath, parentId },
152
+ {
153
+ id,
154
+ sessionId: this.sessionId,
155
+ creator,
156
+ now: this.deps.now(),
157
+ },
158
+ );
159
+ if (creator === "agent") next.activeRootId = next.tasks[id].rootId;
160
+ this.commit(next);
161
+ return { ...this.state.tasks[id] };
162
+ }
163
+
164
+ cancel(id: string): void {
165
+ this.commit(cancelTask(this.state, id, this.sessionId));
166
+ this.waiting.delete(id);
167
+ }
168
+
169
+ dismiss(id: string): void {
170
+ const task = this.task(id);
171
+ if (task.status !== "uncertain")
172
+ throw new Error("Only uncertain dispatches need /schedule dismiss <id>.");
173
+ this.commit(
174
+ cancelTask(
175
+ { ...this.state, tasks: { ...this.state.tasks, [id]: { ...task, status: "pending" } } },
176
+ id,
177
+ this.sessionId,
178
+ ),
179
+ );
180
+ }
181
+
182
+ budget(id: string, runs: number, seconds: number): void {
183
+ const task = this.task(id);
184
+ if (
185
+ !Number.isSafeInteger(runs) ||
186
+ runs < 1 ||
187
+ runs > 20 ||
188
+ !Number.isSafeInteger(seconds) ||
189
+ seconds < 1 ||
190
+ seconds > 86400
191
+ ) {
192
+ throw new Error("Usage: /schedule budget <id> <1..20 runs> <duration up to 24h>.");
193
+ }
194
+ const chain = this.state.chains[task.rootId];
195
+ if (runs < chain.usedRuns) throw new Error("Budget cannot be lower than already used runs.");
196
+ this.commit({
197
+ ...this.state,
198
+ chains: {
199
+ ...this.state.chains,
200
+ [task.rootId]: { ...chain, maxRuns: runs, expiresAt: this.deps.now() + seconds * 1000 },
201
+ },
202
+ });
203
+ }
204
+
205
+ run(id: string): void {
206
+ const task = this.task(id);
207
+ if (task.status !== "overdue")
208
+ throw new Error(`Task is ${task.status}; only overdue work can be explicitly run.`);
209
+ if (!this.deps.available() || this.list().some((t) => t.status === "uncertain")) {
210
+ throw new Error("Wait until Pi is idle and resolve uncertain dispatches first.");
211
+ }
212
+ this.dispatch(task);
213
+ }
214
+
215
+ private dispatch(task: Task): void {
216
+ const pending = {
217
+ ...this.state,
218
+ tasks: { ...this.state.tasks, [task.id]: { ...task, status: "pending" as const } },
219
+ };
220
+ this.commit(beginDispatch(pending, task.id, this.sessionId, this.deps.now()));
221
+ // Only the actual Pi run-start event acknowledges delivery. A void API call cannot.
222
+ try {
223
+ this.deps.deliver(task);
224
+ } catch (error) {
225
+ this.deps.notify(
226
+ `${task.id} dispatch uncertain: ${String(error)}. Inspect before /schedule dismiss ${task.id}.`,
227
+ );
228
+ }
229
+ }
230
+
231
+ tick(): void {
232
+ if (this.closed) return;
233
+ const now = this.deps.now();
234
+ const gap = now - this.lastTick > GRACE_MS;
235
+ this.lastTick = now;
236
+ const available = this.deps.available();
237
+ const due = dueTasks(this.state, now, this.sessionId);
238
+ for (const task of due) {
239
+ const chain = this.state.chains[task.rootId];
240
+ const expired =
241
+ (task.creator === "agent" || task.parentId !== undefined) && now >= chain.expiresAt;
242
+ if (expired || gap || (now - task.dueAt > GRACE_MS && !this.waiting.has(task.id))) {
243
+ this.commit({
244
+ ...this.state,
245
+ tasks: { ...this.state.tasks, [task.id]: { ...task, status: "overdue" } },
246
+ });
247
+ this.waiting.delete(task.id);
248
+ this.deps.notify(
249
+ `${task.id} overdue${expired ? " (chain expired)" : ""}. Inspect /schedule list; use /schedule run ${task.id} when ready.`,
250
+ );
251
+ continue;
252
+ }
253
+ if (!available) {
254
+ this.waiting.add(task.id);
255
+ continue;
256
+ }
257
+ if (this.list().some((t) => t.status === "uncertain")) return;
258
+ this.dispatch(task);
259
+ this.waiting.delete(task.id);
260
+ return;
261
+ }
262
+ }
263
+
264
+ close(): void {
265
+ if (this.closed) return;
266
+ this.closed = true;
267
+ this.store.close();
268
+ }
269
+ }
@@ -0,0 +1,58 @@
1
+ export const help = `Schedule one-off work in this open Pi session.
2
+ Requires a writable private store and an awake computer. No background service.
3
+
4
+ /schedule in 30m Check CI for owner/repo#123 and report failures
5
+ /schedule at 2026-11-09T14:30:00+01:00 Check deployment status
6
+ /schedule list
7
+ /schedule show S-123 Inspect saved request and chain budget
8
+ /schedule cancel S-123
9
+ /schedule run S-123 Explicitly run overdue work when idle
10
+ /schedule dismiss S-123 Dismiss an uncertain dispatch after inspection
11
+ /schedule budget S-123 5 3h User-only: total chain runs and expiry from now
12
+
13
+ Use a complete instruction with the exact PR/project. Natural language uses
14
+ schedule_task; agent-created checks explain why and default to 3 runs / 2 hours.
15
+ Current permissions still apply. Closing Pi stops execution. Reload/resume of
16
+ missed work or a sleep/clock gap over 2 minutes marks it overdue; no auto catch-up.
17
+ Dispatched means Pi started the turn, not that the requested check succeeded.
18
+ Inspect uncertain work before dismissing; it is never automatically replayed.`;
19
+
20
+ export type Command =
21
+ | { action: "help" | "list" }
22
+ | { action: "cancel" | "run" | "dismiss" | "show"; id: string }
23
+ | { action: "budget"; id: string; runs: number; seconds: number }
24
+ | { action: "create"; instruction: string; delaySeconds?: number; runAt?: string };
25
+
26
+ function invalid(): never {
27
+ throw new Error("Invalid schedule command. Use /schedule help for examples.");
28
+ }
29
+
30
+ export function duration(text: string): number {
31
+ const match = /^(\d+)(s|m|h|d)$/.exec(text);
32
+ if (!match) return invalid();
33
+ const units: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
34
+ const seconds = Number(match[1]) * units[match[2]];
35
+ if (!Number.isSafeInteger(seconds) || seconds < 1) return invalid();
36
+ return seconds;
37
+ }
38
+
39
+ export function parseCommand(text: string): Command {
40
+ const parts = text.trim().split(/\s+/);
41
+ const [action, arg, ...rest] = parts;
42
+ if ((!action || action === "help" || action === "list") && !arg)
43
+ return { action: action === "list" ? "list" : "help" };
44
+ if (["cancel", "run", "dismiss", "show"].includes(action) && arg && rest.length === 0) {
45
+ return { action: action as "cancel" | "run" | "dismiss" | "show", id: arg };
46
+ }
47
+ if (action === "budget" && arg && rest.length === 2 && /^\d+$/.test(rest[0])) {
48
+ return { action: "budget", id: arg, runs: Number(rest[0]), seconds: duration(rest[1]) };
49
+ }
50
+ if ((action === "in" || action === "at") && arg && rest.length > 0) {
51
+ return {
52
+ action: "create",
53
+ instruction: rest.join(" "),
54
+ ...(action === "in" ? { delaySeconds: duration(arg) } : { runAt: arg }),
55
+ };
56
+ }
57
+ return invalid();
58
+ }
@@ -0,0 +1,242 @@
1
+ export type Creator = "user" | "agent";
2
+ export type Status = "pending" | "uncertain" | "dispatched" | "cancelled" | "overdue";
3
+
4
+ export interface Task {
5
+ id: string;
6
+ sessionId: string;
7
+ creator: Creator;
8
+ instruction: string;
9
+ projectPath: string;
10
+ target: string;
11
+ reason?: string;
12
+ parentId?: string;
13
+ rootId: string;
14
+ createdAt: number;
15
+ dueAt: number;
16
+ status: Status;
17
+ }
18
+
19
+ export interface Chain {
20
+ usedRuns: number;
21
+ maxRuns: number;
22
+ expiresAt: number;
23
+ cancelled: boolean;
24
+ }
25
+
26
+ export interface State {
27
+ version: 1;
28
+ tasks: Record<string, Task>;
29
+ chains: Record<string, Chain>;
30
+ activeRootId?: string;
31
+ }
32
+
33
+ export interface CreateRequest {
34
+ instruction: string;
35
+ projectPath: string;
36
+ target: string;
37
+ delaySeconds?: number;
38
+ runAt?: string;
39
+ reason?: string;
40
+ parentId?: string;
41
+ }
42
+
43
+ /** Attribution must be supplied by a trusted adapter, never by model arguments. */
44
+ export interface CreateContext {
45
+ now: number;
46
+ id: string;
47
+ sessionId: string;
48
+ creator: Creator;
49
+ }
50
+
51
+ const MAX_PENDING = 20;
52
+ const MAX_INSTRUCTION_BYTES = 8192;
53
+ const MAX_AGENT_RUNS = 3;
54
+ const CHAIN_LIFETIME_MS = 2 * 60 * 60 * 1000;
55
+
56
+ export function emptyState(): State {
57
+ return { version: 1, tasks: {}, chains: {} };
58
+ }
59
+
60
+ function requireText(value: string, label: string): void {
61
+ if (typeof value !== "string" || !value.trim()) {
62
+ throw new Error(`${label} must not be blank.`);
63
+ }
64
+ }
65
+
66
+ function requireTime(value: number): void {
67
+ if (!Number.isSafeInteger(value) || Math.abs(value) > 8.64e15) {
68
+ throw new Error("Time must be a valid integer timestamp in milliseconds.");
69
+ }
70
+ }
71
+
72
+ function getOwn<T>(records: Record<string, T>, key: string): T | undefined {
73
+ return Object.hasOwn(records, key) ? records[key] : undefined;
74
+ }
75
+
76
+ function ownedTask(state: State, id: string, sessionId: string): Task {
77
+ const task = getOwn(state.tasks, id);
78
+ if (!task || task.sessionId !== sessionId) {
79
+ throw new Error("Task not found in this session. Use /schedule list.");
80
+ }
81
+ return task;
82
+ }
83
+
84
+ function deadline(request: CreateRequest, now: number): number {
85
+ if ((request.delaySeconds !== undefined) === (request.runAt !== undefined)) {
86
+ throw new Error("Choose exactly one of delaySeconds or runAt.");
87
+ }
88
+
89
+ let dueAt: number;
90
+ if (request.delaySeconds !== undefined) {
91
+ if (!Number.isSafeInteger(request.delaySeconds) || request.delaySeconds <= 0) {
92
+ throw new Error("delaySeconds must be a positive integer.");
93
+ }
94
+ dueAt = now + request.delaySeconds * 1000;
95
+ } else {
96
+ const match =
97
+ /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(
98
+ request.runAt!,
99
+ );
100
+ if (!match) {
101
+ throw new Error("runAt needs an ISO-8601 timestamp with seconds and timezone offset.");
102
+ }
103
+ // Date.parse normalizes impossible dates such as February 30; reject them.
104
+ const calendar = new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
105
+ if (!Number.isFinite(calendar.getTime()) || calendar.getUTCDate() !== Number(match[3])) {
106
+ throw new Error("runAt must contain a real calendar date.");
107
+ }
108
+ dueAt = Date.parse(request.runAt!);
109
+ }
110
+
111
+ requireTime(dueAt);
112
+ if (dueAt <= now) throw new Error("Due time must be in the future.");
113
+ return dueAt;
114
+ }
115
+
116
+ export function createTask(state: State, request: CreateRequest, ctx: CreateContext): State {
117
+ requireTime(ctx.now);
118
+ requireText(ctx.id, "Task ID");
119
+ requireText(ctx.sessionId, "Session ID");
120
+ requireText(request.instruction, "Instruction");
121
+ requireText(request.projectPath, "Project path");
122
+ requireText(request.target, "Target");
123
+ if (new TextEncoder().encode(request.instruction).length > MAX_INSTRUCTION_BYTES) {
124
+ throw new Error("Instruction must not exceed 8192 UTF-8 bytes.");
125
+ }
126
+ if (getOwn(state.tasks, ctx.id)) throw new Error("Task ID already exists.");
127
+ const pending = Object.values(state.tasks).filter(
128
+ (t) => t.sessionId === ctx.sessionId && (t.status === "pending" || t.status === "overdue"),
129
+ );
130
+ if (pending.length >= MAX_PENDING)
131
+ throw new Error("Limit of 20 pending tasks reached. Use /schedule list.");
132
+
133
+ const dueAt = deadline(request, ctx.now);
134
+ if (ctx.creator === "agent") requireText(request.reason!, "Agent scheduling reason");
135
+
136
+ const parent =
137
+ request.parentId === undefined ? undefined : ownedTask(state, request.parentId, ctx.sessionId);
138
+ if (parent && parent.status !== "dispatched") {
139
+ throw new Error("Follow-up parent must have been dispatched.");
140
+ }
141
+ if (parent && (parent.target !== request.target || parent.projectPath !== request.projectPath)) {
142
+ throw new Error("Follow-up scope must match the original project and target.");
143
+ }
144
+ const rootId = parent?.rootId ?? ctx.id;
145
+ const chain = parent
146
+ ? state.chains[rootId]
147
+ : {
148
+ usedRuns: 0,
149
+ maxRuns: MAX_AGENT_RUNS,
150
+ expiresAt: ctx.now + CHAIN_LIFETIME_MS,
151
+ cancelled: false,
152
+ };
153
+ if (chain.cancelled) throw new Error("Follow-up chain is cancelled.");
154
+ if (parent && pending.some((t) => t.rootId === rootId)) {
155
+ throw new Error("This chain already has a pending follow-up. Use /schedule list.");
156
+ }
157
+ const consumesBudget = ctx.creator === "agent" || parent !== undefined;
158
+ if (consumesBudget && chain.usedRuns >= chain.maxRuns) {
159
+ throw new Error("Follow-up run budget exhausted. Ask the user before scheduling more work.");
160
+ }
161
+ if (consumesBudget && dueAt >= chain.expiresAt) {
162
+ throw new Error("Follow-up deadline exceeds chain expiry. Report unresolved work to the user.");
163
+ }
164
+
165
+ const task: Task = {
166
+ id: ctx.id,
167
+ sessionId: ctx.sessionId,
168
+ creator: ctx.creator,
169
+ instruction: request.instruction,
170
+ projectPath: request.projectPath,
171
+ target: request.target,
172
+ reason: request.reason,
173
+ parentId: request.parentId,
174
+ rootId,
175
+ createdAt: ctx.now,
176
+ dueAt,
177
+ status: "pending",
178
+ };
179
+ return {
180
+ ...state,
181
+ tasks: { ...state.tasks, [task.id]: task },
182
+ chains: {
183
+ ...state.chains,
184
+ [rootId]: {
185
+ ...chain,
186
+ usedRuns: chain.usedRuns + Number(consumesBudget),
187
+ },
188
+ },
189
+ };
190
+ }
191
+
192
+ export function dueTasks(state: State, now: number, sessionId: string): Task[] {
193
+ requireTime(now);
194
+ return Object.values(state.tasks)
195
+ .filter((t) => t.sessionId === sessionId && t.status === "pending" && t.dueAt <= now)
196
+ .sort((a, b) => a.dueAt - b.dueAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
197
+ .map((t) => ({ ...t }));
198
+ }
199
+
200
+ export function cancelTask(state: State, id: string, sessionId: string): State {
201
+ const task = ownedTask(state, id, sessionId);
202
+ if (task.status === "uncertain") {
203
+ throw new Error("Dispatch may already have started; inspect the conversation before recovery.");
204
+ }
205
+ const tasks = { ...state.tasks };
206
+ for (const candidate of Object.values(tasks)) {
207
+ if (
208
+ candidate.rootId === task.rootId &&
209
+ (candidate.status === "pending" || candidate.status === "overdue")
210
+ ) {
211
+ tasks[candidate.id] = { ...candidate, status: "cancelled" };
212
+ }
213
+ }
214
+ return {
215
+ ...state,
216
+ tasks,
217
+ chains: { ...state.chains, [task.rootId]: { ...state.chains[task.rootId], cancelled: true } },
218
+ };
219
+ }
220
+
221
+ /** Persist this returned intent before delivery; never automatically replay it. */
222
+ export function beginDispatch(state: State, id: string, sessionId: string, now: number): State {
223
+ requireTime(now);
224
+ const task = ownedTask(state, id, sessionId);
225
+ if (task.status !== "pending" || task.dueAt > now) {
226
+ throw new Error("Task must be pending and due before dispatch.");
227
+ }
228
+ const chain = state.chains[task.rootId];
229
+ if (
230
+ chain.cancelled ||
231
+ ((task.creator === "agent" || task.parentId !== undefined) && now >= chain.expiresAt)
232
+ ) {
233
+ throw new Error("Follow-up chain is cancelled or expired. Report unresolved work to the user.");
234
+ }
235
+ return { ...state, tasks: { ...state.tasks, [id]: { ...task, status: "uncertain" } } };
236
+ }
237
+
238
+ export function confirmDispatch(state: State, id: string, sessionId: string): State {
239
+ const task = ownedTask(state, id, sessionId);
240
+ if (task.status !== "uncertain") throw new Error("No dispatch intent to acknowledge.");
241
+ return { ...state, tasks: { ...state.tasks, [id]: { ...task, status: "dispatched" } } };
242
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { register } from "./infra/extension.ts";
3
+
4
+ export default function schedule(pi: ExtensionAPI): void {
5
+ register(pi);
6
+ }