@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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/src/schedule.ts CHANGED
@@ -12,17 +12,43 @@
12
12
  * of dispatching a user message — schedule fires bypass maxConcurrent so
13
13
  * a 5-minute interval can't be deferred behind 4 long-running agents.
14
14
  * - Result delivery is implicit: spawn → background completion → existing
15
- * `subagent-notification` followUp path. No new delivery code.
15
+ * completion notification, delivered through the steering queue. No new
16
+ * delivery code.
16
17
  */
17
18
 
18
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import { Cron } from "croner";
20
21
  import { nanoid } from "nanoid";
21
22
  import type { AgentManager } from "./agent-manager.js";
23
+ import { resolveSpawnType } from "./agent-types.js";
22
24
  import { resolveModel } from "./model-resolver.js";
23
25
  import type { ScheduleStore } from "./schedule-store.js";
24
26
  import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
25
27
 
28
+ /**
29
+ * Smallest armable interval. The parsers' smallest unit is one second, so a
30
+ * sub-second value can only come from a corrupt store; the values closest to
31
+ * zero are the ones Node clamps to ~1 ms.
32
+ */
33
+ const MIN_ARMABLE_INTERVAL_MS = 1000;
34
+
35
+ /**
36
+ * Largest delay `setTimeout`/`setInterval` accept. Node clamps anything above
37
+ * this to ~1 ms and warns, so an out-of-range delay would hot-loop instead of
38
+ * staying inert.
39
+ */
40
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
41
+
42
+ /** True when a stored interval is a real integer the JS timer can hold. */
43
+ function isArmableInterval(value: unknown): value is number {
44
+ return (
45
+ typeof value === "number" &&
46
+ Number.isInteger(value) &&
47
+ value >= MIN_ARMABLE_INTERVAL_MS &&
48
+ value <= MAX_TIMER_DELAY_MS
49
+ );
50
+ }
51
+
26
52
  /** Event emitted on `pi.events` for cross-extension consumers. */
27
53
  export type ScheduleChangeEvent =
28
54
  | { type: "added"; job: ScheduledSubagent }
@@ -59,6 +85,10 @@ export class SubagentScheduler {
59
85
  this.ctx = ctx;
60
86
  this.manager = manager;
61
87
  this.store = store;
88
+ store.onReclassified = ids => {
89
+ for (const id of ids) this.unscheduleJob(id);
90
+ };
91
+ store.onPromoted = ids => this.armPromoted(ids);
62
92
 
63
93
  for (const job of store.list()) {
64
94
  if (job.enabled) this.scheduleJob(job);
@@ -71,6 +101,10 @@ export class SubagentScheduler {
71
101
  this.jobs.clear();
72
102
  for (const t of this.intervals.values()) clearTimeout(t);
73
103
  this.intervals.clear();
104
+ if (this.store) {
105
+ this.store.onReclassified = undefined;
106
+ this.store.onPromoted = undefined;
107
+ }
74
108
  this.store = undefined;
75
109
  this.pi = undefined;
76
110
  this.ctx = undefined;
@@ -121,14 +155,18 @@ export class SubagentScheduler {
121
155
  const job = this.buildJob(input);
122
156
  store.add(job);
123
157
  if (job.enabled) this.scheduleJob(job);
124
- this.emit({ type: "added", job });
125
- return job;
158
+ // The arm guard can disable the fresh record; report what the store holds.
159
+ const stored = store.get(job.id) ?? job;
160
+ this.emit({ type: "added", job: stored });
161
+ return stored;
126
162
  }
127
163
 
128
164
  removeJob(id: string): boolean {
129
165
  const store = this.requireStore();
130
- if (!store.get(id)) return false;
131
166
  this.unscheduleJob(id);
167
+ // No pre-check on store.get(id): an id reclassified out of the live set
168
+ // still has a preserved record to purge, and store.remove() reports
169
+ // whether anything was actually removed.
132
170
  const ok = store.remove(id);
133
171
  if (ok) this.emit({ type: "removed", jobId: id });
134
172
  return ok;
@@ -141,48 +179,108 @@ export class SubagentScheduler {
141
179
  if (!updated) return undefined;
142
180
  this.unscheduleJob(id);
143
181
  if (updated.enabled) this.scheduleJob(updated);
144
- this.emit({ type: "updated", job: updated });
145
- return updated;
182
+ // The arm guard can disable the patched record; report what the store holds.
183
+ const stored = store.get(id) ?? updated;
184
+ this.emit({ type: "updated", job: stored });
185
+ return stored;
146
186
  }
147
187
 
148
- /** Next-run time as ISO, or undefined if not currently armed. */
188
+ /**
189
+ * Next-run time as ISO, or undefined when there is none (disabled, unknown
190
+ * type, or corrupt interval). A valid one-shot too distant for the JS timer
191
+ * still reports its date — the store keeps it enabled for a later start.
192
+ */
149
193
  getNextRun(jobId: string): string | undefined {
150
194
  const cron = this.jobs.get(jobId);
151
195
  if (cron) return cron.nextRun()?.toISOString();
152
196
  const job = this.store?.get(jobId);
153
197
  if (!job?.enabled) return undefined;
154
- if (job.scheduleType === "once") return job.schedule;
155
- if (job.scheduleType === "interval" && job.intervalMs) {
198
+ if (job.scheduleType === "once") {
199
+ if (typeof job.schedule !== "string") return undefined;
200
+ const d = new Date(job.schedule);
201
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
202
+ }
203
+ if (job.scheduleType === "interval") {
204
+ // The same armable predicate as scheduleJob: a record that cannot arm
205
+ // has no next run to advertise.
206
+ const intervalMs = job.intervalMs;
207
+ if (!isArmableInterval(intervalMs)) return undefined;
156
208
  // Before the first fire there's no `lastRun`, so fall back to "now" —
157
209
  // accurate at create time (setInterval was just armed) and within
158
210
  // intervalMs of correct in any pre-first-fire view.
159
211
  const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now();
160
- return new Date(base + job.intervalMs).toISOString();
212
+ const next = base + intervalMs;
213
+ const d = new Date(next);
214
+ if (Number.isNaN(d.getTime())) return undefined;
215
+ return d.toISOString();
161
216
  }
162
217
  return undefined;
163
218
  }
164
219
 
165
220
  // ── Scheduling primitives ────────────────────────────────────────────
166
221
 
222
+ /**
223
+ * Arm records the store promoted back into the live set. Called from the
224
+ * store after it released the mutation lock, so the arm guard's writes are
225
+ * safe. A record this scheduler already holds a timer for is skipped, so a
226
+ * promotion report cannot double-arm.
227
+ */
228
+ private armPromoted(ids: string[]): void {
229
+ const store = this.store;
230
+ if (!store) return; // stopped between the load and the drain
231
+ for (const id of ids) {
232
+ if (this.jobs.has(id) || this.intervals.has(id)) continue;
233
+ const job = store.get(id);
234
+ if (job?.enabled) this.scheduleJob(job);
235
+ }
236
+ }
237
+
167
238
  private scheduleJob(job: ScheduledSubagent): void {
168
239
  const store = this.store;
169
240
  if (!store) return;
170
241
  try {
171
- if (job.scheduleType === "interval" && job.intervalMs) {
172
- const t = setInterval(() => this.executeJob(job.id), job.intervalMs);
173
- this.intervals.set(job.id, t);
242
+ if (job.scheduleType === "interval") {
243
+ const intervalMs = job.intervalMs;
244
+ if (!isArmableInterval(intervalMs)) {
245
+ // Outside the timer range and cannot be honored — Node clamps the
246
+ // delay to ~1 ms, turning the job into a hot loop. Disable it and
247
+ // mark it broken, mirroring the past-one-shot branch below.
248
+ store.update(job.id, { enabled: false, lastStatus: "error" });
249
+ this.emit({
250
+ type: "error",
251
+ jobId: job.id,
252
+ error: `Interval ${job.intervalMs} is outside the armable range (${MIN_ARMABLE_INTERVAL_MS}–${MAX_TIMER_DELAY_MS} ms)`,
253
+ });
254
+ } else {
255
+ const t = setInterval(() => this.executeJob(job.id), intervalMs);
256
+ this.intervals.set(job.id, t);
257
+ }
174
258
  } else if (job.scheduleType === "once") {
175
259
  const target = new Date(job.schedule).getTime();
176
260
  const delay = target - Date.now();
177
261
  if (delay > 0) {
178
- const t = setTimeout(() => {
179
- this.executeJob(job.id);
180
- // Auto-disable one-shots after they fire (mirrors pi-cron-schedule)
181
- store.update(job.id, { enabled: false });
182
- const updated = store.get(job.id);
183
- if (updated) this.emit({ type: "updated", job: updated });
184
- }, delay);
185
- this.intervals.set(job.id, t);
262
+ if (delay > MAX_TIMER_DELAY_MS) {
263
+ // A valid but distant target: leave it enabled so a later start can
264
+ // arm it once the target comes inside the timer range.
265
+ this.emit({
266
+ type: "error",
267
+ jobId: job.id,
268
+ error: `Scheduled time ${job.schedule} is more than ${MAX_TIMER_DELAY_MS} ms away — not armed`,
269
+ });
270
+ } else {
271
+ const t = setTimeout(() => {
272
+ try {
273
+ this.executeJob(job.id);
274
+ // Auto-disable one-shots after they fire (mirrors pi-cron-schedule)
275
+ store.update(job.id, { enabled: false });
276
+ const updated = store.get(job.id);
277
+ if (updated) this.emit({ type: "updated", job: updated });
278
+ } catch (err) {
279
+ this.reportJobError(job.id, err);
280
+ }
281
+ }, delay);
282
+ this.intervals.set(job.id, t);
283
+ }
186
284
  } else {
187
285
  // Past timestamp — disable, mark error, never fire
188
286
  store.update(job.id, { enabled: false, lastStatus: "error" });
@@ -214,7 +312,9 @@ export class SubagentScheduler {
214
312
  /**
215
313
  * Fire a job: persist running state, spawn (bypassing the concurrency
216
314
  * queue), persist completion. Fire-and-forget: the timer tick returns
217
- * immediately so other jobs keep firing.
315
+ * immediately so other jobs keep firing. Never throws: this runs from a
316
+ * bare timer callback, where an escaping error would be an uncaught
317
+ * exception and take the host process down.
218
318
  */
219
319
  private executeJob(id: string): void {
220
320
  const store = this.store;
@@ -222,25 +322,47 @@ export class SubagentScheduler {
222
322
  const ctx = this.ctx;
223
323
  const manager = this.manager;
224
324
  if (!store || !pi || !ctx || !manager) return;
225
- const job = store.get(id);
226
- if (!job?.enabled) return;
227
-
228
- store.update(id, { lastStatus: "running" });
229
-
230
- // Resolve model at fire time — registry contents may have changed since the
231
- // job was created (auth added/removed). Fall back silently to spawn-default
232
- // if resolution fails; the spawn path handles undefined model gracefully.
233
- let resolvedModel: any | undefined;
234
- if (job.model) {
235
- const r = resolveModel(job.model, ctx.modelRegistry);
236
- if (typeof r !== "string") resolvedModel = r;
237
- }
238
-
239
- let agentId: string;
240
325
  try {
241
- agentId = manager.spawn(pi, ctx, job.subagent_type, job.prompt, {
326
+ const job = store.get(id);
327
+ if (!job?.enabled) return;
328
+
329
+ // The registry can change after arming (e.g. the user disables default
330
+ // agents, or deletes the agent a job names). Re-resolve at fire time with
331
+ // the same decision point every other spawn path uses (#183), so the
332
+ // project's `fallbackSubagent` policy applies here too — an unresolvable
333
+ // type routes to the configured fallback, or is refused under `none`.
334
+ // This does not reload from disk (the scheduler has no reason to rebuild
335
+ // process-global state from a timer), so it catches changes that went
336
+ // through /agents or an Agent call — not a file deleted directly from a
337
+ // shell. A *persisted* job whose type was already unresolvable when the
338
+ // store loaded never reaches this point: `sanitizeJob` rejects it on load
339
+ // and the record is filed under `skipped`, so the policy below covers the
340
+ // registry changes that happen while the job is armed.
341
+ const dispatch = resolveSpawnType(job.subagent_type);
342
+ if (!dispatch.ok) {
343
+ // The fire aborts here, so stop the timer: an interval that keeps
344
+ // ticking would outlive the record the store is about to reclassify
345
+ // into `skipped`, and a later load could revive and fire it.
346
+ this.unscheduleJob(id);
347
+ this.reportJobError(id, dispatch.message);
348
+ return;
349
+ }
350
+
351
+ // Persist the running state before spawning. A missing record means the
352
+ // job vanished (removed elsewhere) between get() and update() — abort.
353
+ if (!store.update(id, { lastStatus: "running" })) return;
354
+
355
+ // Resolve model at fire time — registry contents may have changed since the
356
+ // job was created (auth added/removed). Fall back silently to spawn-default
357
+ // if resolution fails; the spawn path handles undefined model gracefully.
358
+ let resolvedModel: any | undefined;
359
+ if (job.model) {
360
+ const r = resolveModel(job.model, ctx.modelRegistry);
361
+ if (typeof r !== "string") resolvedModel = r;
362
+ }
363
+
364
+ const agentId = manager.spawn(pi, ctx, dispatch.type, job.prompt, {
242
365
  description: job.description,
243
- isBackground: true,
244
366
  bypassQueue: true,
245
367
  model: resolvedModel,
246
368
  maxTurns: job.max_turns,
@@ -248,42 +370,54 @@ export class SubagentScheduler {
248
370
  thinkingLevel: job.thinking,
249
371
  isolation: job.isolation,
250
372
  });
373
+
374
+ this.emit({ type: "fired", jobId: id, agentId, name: job.name });
375
+
376
+ const finalize = (status: "success" | "error") => {
377
+ try {
378
+ const next = this.getNextRun(id);
379
+ const current = store.get(id);
380
+ store.update(id, {
381
+ lastRun: new Date().toISOString(),
382
+ lastStatus: status,
383
+ runCount: (current?.runCount ?? 0) + 1,
384
+ nextRun: next,
385
+ });
386
+ } catch (err) {
387
+ this.reportJobError(id, err);
388
+ }
389
+ };
390
+
391
+ const record = manager.getRecord(agentId);
392
+ // AgentManager's promise resolves either way (its .catch returns ""), so we
393
+ // can't infer success/failure from the promise — read record.status instead.
394
+ // Terminal states: completed/steered = success; error/aborted/stopped = error.
395
+ if (record?.promise) {
396
+ record.promise
397
+ .then(() => {
398
+ const r = manager.getRecord(agentId);
399
+ const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped";
400
+ finalize(failed ? "error" : "success");
401
+ })
402
+ .catch(() => finalize("error"));
403
+ } else {
404
+ // Spawn returned without a promise (defensive — bypassQueue path always sets one).
405
+ finalize("success");
406
+ }
251
407
  } catch (err) {
252
- const error = err instanceof Error ? err.message : String(err);
253
- store.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" });
254
- this.emit({ type: "error", jobId: id, error });
255
- return;
408
+ this.reportJobError(id, err);
256
409
  }
410
+ }
257
411
 
258
- this.emit({ type: "fired", jobId: id, agentId, name: job.name });
259
-
260
- const record = manager.getRecord(agentId);
261
- const finalize = (status: "success" | "error") => {
262
- const next = this.getNextRun(id);
263
- const current = store.get(id);
264
- store.update(id, {
265
- lastRun: new Date().toISOString(),
266
- lastStatus: status,
267
- runCount: (current?.runCount ?? 0) + 1,
268
- nextRun: next,
269
- });
270
- };
271
-
272
- // AgentManager's promise resolves either way (its .catch returns ""), so we
273
- // can't infer success/failure from the promise — read record.status instead.
274
- // Terminal states: completed/steered = success; error/aborted/stopped = error.
275
- if (record?.promise) {
276
- record.promise
277
- .then(() => {
278
- const r = manager.getRecord(agentId);
279
- const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped";
280
- finalize(failed ? "error" : "success");
281
- })
282
- .catch(() => finalize("error"));
283
- } else {
284
- // Spawn returned without a promise (defensive — bypassQueue path always sets one).
285
- finalize("success");
412
+ /** Mark a firing job errored and surface the failure; never rethrows. */
413
+ private reportJobError(id: string, err: unknown): void {
414
+ const error = err instanceof Error ? err.message : String(err);
415
+ try {
416
+ this.store?.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" });
417
+ } catch {
418
+ // Best effort — the error event below is the only reliable surface.
286
419
  }
420
+ this.emit({ type: "error", jobId: id, error });
287
421
  }
288
422
 
289
423
  private emit(event: ScheduleChangeEvent): void {
@@ -309,7 +443,14 @@ export class SubagentScheduler {
309
443
  if (rel !== null) return { type: "once", normalized: rel };
310
444
  // "5m" — interval
311
445
  const ivl = SubagentScheduler.parseInterval(trimmed);
312
- if (ivl !== null) return { type: "interval", intervalMs: ivl, normalized: trimmed };
446
+ if (ivl !== null) {
447
+ if (!isArmableInterval(ivl)) {
448
+ throw new Error(
449
+ `Interval "${trimmed}" is not armable — use between ${MIN_ARMABLE_INTERVAL_MS} ms (1s) and ${MAX_TIMER_DELAY_MS} ms (about 24.8 days).`,
450
+ );
451
+ }
452
+ return { type: "interval", intervalMs: ivl, normalized: trimmed };
453
+ }
313
454
  // ISO timestamp — one-shot. Reject past timestamps upfront so we never
314
455
  // create a dead-on-arrival record (scheduleJob's safety net still catches
315
456
  // micro-races from `+0s`-style relatives).
@@ -340,8 +481,9 @@ export class SubagentScheduler {
340
481
  };
341
482
  }
342
483
  try {
343
- // Croner validates by construction.
344
- new Cron(expr, () => {});
484
+ // Validate without arming: a Cron constructed with a callback schedules
485
+ // itself and would keep the process alive until stopped.
486
+ new Cron(expr, { paused: true }, () => {}).stop();
345
487
  return { valid: true };
346
488
  } catch (e) {
347
489
  return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" };
@@ -353,13 +495,16 @@ export class SubagentScheduler {
353
495
  const m = s.match(/^\+(\d+)(s|m|h|d)$/);
354
496
  if (!m) return null;
355
497
  const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
356
- return new Date(Date.now() + ms).toISOString();
498
+ const d = new Date(Date.now() + ms);
499
+ if (Number.isNaN(d.getTime())) return null;
500
+ return d.toISOString();
357
501
  }
358
502
 
359
- /** "10s"/"5m"/"1h"/"2d" → milliseconds. */
503
+ /** "10s"/"5m"/"1h"/"2d" → milliseconds, or null when outside the Date range. */
360
504
  static parseInterval(s: string): number | null {
361
505
  const m = s.match(/^(\d+)(s|m|h|d)$/);
362
506
  if (!m) return null;
363
- return parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
507
+ const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
508
+ return Number.isFinite(ms) && !Number.isNaN(new Date(ms).getTime()) ? ms : null;
364
509
  }
365
510
  }