@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/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
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
|
-
*
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
/**
|
|
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")
|
|
155
|
-
|
|
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
|
-
|
|
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"
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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)
|
|
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
|
-
//
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|