@apisurf/ngn-core 0.3.1 → 0.5.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.
Files changed (3) hide show
  1. package/dist/core.d.ts +465 -66
  2. package/dist/core.js +1642 -170
  3. package/package.json +4 -4
package/dist/core.js CHANGED
@@ -325,7 +325,8 @@ var Entry = class {
325
325
  shouldRetry: taskExports.shouldRetry,
326
326
  onSuccess: taskExports.onSuccess,
327
327
  onError: taskExports.onError,
328
- onComplete: taskExports.onComplete
328
+ onComplete: taskExports.onComplete,
329
+ next: taskExports.next
329
330
  };
330
331
  }
331
332
  get paths() {
@@ -364,7 +365,8 @@ function runDeadline({
364
365
  startedAt,
365
366
  scheduledAt,
366
367
  nextAt,
367
- budgetMs
368
+ budgetMs,
369
+ periodMs = null
368
370
  }) {
369
371
  const candidates = [];
370
372
  if (nextAt != null) {
@@ -372,23 +374,39 @@ function runDeadline({
372
374
  candidates.push(nextAt - Math.min(5e3, Math.max(0, period) * 0.1));
373
375
  }
374
376
  if (budgetMs != null) candidates.push(startedAt + budgetMs);
377
+ else if (nextAt == null && periodMs != null) candidates.push(startedAt + periodMs);
375
378
  return candidates.length ? Math.floor(Math.min(...candidates)) : null;
376
379
  }
377
- function deadlineSignal(deadline) {
380
+ function deadlineSignal(deadline, outer) {
378
381
  const controller = new AbortController();
379
- if (deadline == null) return { signal: controller.signal, dispose: () => {
380
- } };
382
+ const onOuter = () => controller.abort(outer?.reason);
383
+ if (outer?.aborted) onOuter();
384
+ else outer?.addEventListener("abort", onOuter, { once: true });
385
+ const detach = () => outer?.removeEventListener("abort", onOuter);
386
+ if (deadline == null) return { signal: controller.signal, dispose: detach };
381
387
  const abort = () => controller.abort(new Error("The run reached its deadline"));
382
388
  const delay = deadline - Date.now();
383
389
  if (delay <= 0) {
384
390
  abort();
385
- return { signal: controller.signal, dispose: () => {
386
- } };
387
- }
388
- const timer = setTimeout(abort, delay);
389
- timer.unref();
390
- return { signal: controller.signal, dispose: () => clearTimeout(timer) };
391
+ return { signal: controller.signal, dispose: detach };
392
+ }
393
+ let timer;
394
+ const arm = () => {
395
+ const left = deadline - Date.now();
396
+ if (left <= 0) return abort();
397
+ timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
398
+ timer.unref();
399
+ };
400
+ arm();
401
+ return {
402
+ signal: controller.signal,
403
+ dispose: () => {
404
+ clearTimeout(timer);
405
+ detach();
406
+ }
407
+ };
391
408
  }
409
+ var MAX_TIMER_MS = 2 ** 31 - 1;
392
410
 
393
411
  // src/runtime/locks.ts
394
412
  var LockBusyError = class extends Error {
@@ -401,10 +419,14 @@ var LockBusyError = class extends Error {
401
419
  var DEFAULT_TTL_MS = 6e4;
402
420
  var MAX_POLL_MS = 1e3;
403
421
  var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
422
+ var RESERVED_LOCK_PREFIX = "ngn:";
404
423
  function assertLockName(name) {
405
424
  if (typeof name !== "string" || !name.trim()) {
406
425
  throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
407
426
  }
427
+ if (name.startsWith(RESERVED_LOCK_PREFIX)) {
428
+ throw new Error(`Lock "${name}": names starting with "${RESERVED_LOCK_PREFIX}" are ngn's own`);
429
+ }
408
430
  }
409
431
  function createRunLocks(service, taskRunId) {
410
432
  async function tryHold(name, ttlMs) {
@@ -477,6 +499,11 @@ function exclusiveNames(value, taskPath) {
477
499
  `Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
478
500
  );
479
501
  }
502
+ if (name.startsWith(RESERVED_LOCK_PREFIX)) {
503
+ throw new Error(
504
+ `Invalid exclusive for ${taskPath}: "${name}" starts with "${RESERVED_LOCK_PREFIX}", which ngn keeps for itself`
505
+ );
506
+ }
480
507
  }
481
508
  return names;
482
509
  }
@@ -1176,8 +1203,157 @@ function breakerSkips(failedInARow, breaker) {
1176
1203
  return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
1177
1204
  }
1178
1205
 
1206
+ // src/runtime/clock.ts
1207
+ var systemClock = {
1208
+ now: () => Date.now(),
1209
+ sleep(ms, signal) {
1210
+ return new Promise((resolve2) => {
1211
+ if (signal?.aborted) return resolve2();
1212
+ const done = () => {
1213
+ clearTimeout(timer);
1214
+ signal?.removeEventListener("abort", done);
1215
+ resolve2();
1216
+ };
1217
+ const timer = setTimeout(done, Math.max(0, ms));
1218
+ signal?.addEventListener("abort", done, { once: true });
1219
+ });
1220
+ }
1221
+ };
1222
+ var WAIT_STEP_MS = 1e3;
1223
+
1224
+ // src/runtime/cron.ts
1225
+ import { Cron } from "croner";
1226
+ var CRON_OPTIONS = { mode: "5-or-6-parts", paused: true };
1227
+ var parsed = /* @__PURE__ */ new Map();
1228
+ function cronOf(pattern) {
1229
+ let cron = parsed.get(pattern);
1230
+ if (!cron) {
1231
+ cron = new Cron(pattern, CRON_OPTIONS);
1232
+ parsed.set(pattern, cron);
1233
+ }
1234
+ return cron;
1235
+ }
1236
+ function isCronPatternValid(pattern) {
1237
+ try {
1238
+ cronOf(pattern);
1239
+ return true;
1240
+ } catch {
1241
+ return false;
1242
+ }
1243
+ }
1244
+ function nextCronSlot(pattern, after) {
1245
+ return cronOf(pattern).nextRun(new Date(after))?.getTime() ?? null;
1246
+ }
1247
+
1248
+ // src/runtime/timing.ts
1249
+ var INTERVAL_KEYS = /* @__PURE__ */ new Set(["every", "edge", "min", "max", "backoff"]);
1250
+ var BACKOFF_KEYS = /* @__PURE__ */ new Set(["factor", "jitter"]);
1251
+ var MIN_EVERY_MS = 1e3;
1252
+ var DEFAULT_MAX_FACTOR = 32;
1253
+ var DEFAULT_BACKOFF = { factor: 2, jitter: 0.1 };
1254
+ function unknownKeys(value, known, what = "") {
1255
+ const unknown = Object.keys(value).filter((key) => !known.has(key));
1256
+ if (!unknown.length) return null;
1257
+ return `unknown ${what}${unknown.length === 1 ? "key" : "keys"} ${unknown.join(", ")}`;
1258
+ }
1259
+ function resolveTiming(value, taskPath) {
1260
+ if (value == null) throw new Error(`timing pattern is missing for ${taskPath}`);
1261
+ if (typeof value === "string") {
1262
+ if (!isCronPatternValid(value))
1263
+ throw new Error(`Invalid cron pattern: ${value} for ${taskPath}`);
1264
+ return { kind: "cron", pattern: value };
1265
+ }
1266
+ const fail = (what) => {
1267
+ throw new Error(`Invalid timing for ${taskPath}: ${what}`);
1268
+ };
1269
+ if (typeof value !== "object" || Array.isArray(value)) {
1270
+ fail('expected a cron pattern or an object like { every: "5m" }');
1271
+ }
1272
+ const timing = value;
1273
+ const unknown = unknownKeys(timing, INTERVAL_KEYS);
1274
+ if (unknown) fail(unknown);
1275
+ if (timing.every == null) fail("every is required");
1276
+ const everyMs = toMs(timing.every, `timing.every for ${taskPath}`);
1277
+ if (everyMs < MIN_EVERY_MS) fail("every must be at least 1s");
1278
+ const { edge = "leading" } = timing;
1279
+ if (edge !== "leading" && edge !== "trailing") {
1280
+ fail(`edge must be "leading" or "trailing", got ${JSON.stringify(edge)}`);
1281
+ }
1282
+ return {
1283
+ kind: "interval",
1284
+ everyMs,
1285
+ edge,
1286
+ ...resolveLimits(timing, everyMs, taskPath, fail),
1287
+ backoff: resolveBackoff(timing.backoff, fail)
1288
+ };
1289
+ }
1290
+ function resolveLimits(timing, everyMs, taskPath, fail) {
1291
+ const limit = (key, fallback) => timing[key] == null ? fallback : toMs(timing[key], `timing.${key} for ${taskPath}`);
1292
+ const minMs = limit("min", everyMs);
1293
+ const maxMs = limit("max", everyMs * DEFAULT_MAX_FACTOR);
1294
+ if (minMs < MIN_EVERY_MS) fail("min must be at least 1s");
1295
+ if (minMs > everyMs) fail("min must not be more than every");
1296
+ if (everyMs > maxMs) fail("every must not be more than max");
1297
+ return { minMs, maxMs };
1298
+ }
1299
+ function resolveBackoff(value, fail) {
1300
+ if (value === false) return null;
1301
+ if (value == null || value === true) return DEFAULT_BACKOFF;
1302
+ if (typeof value !== "object" || Array.isArray(value)) {
1303
+ fail("backoff must be false or an object like { factor: 2, jitter: 0.1 }");
1304
+ }
1305
+ const backoff = value;
1306
+ const unknown = unknownKeys(backoff, BACKOFF_KEYS, "backoff ");
1307
+ if (unknown) fail(unknown);
1308
+ const { factor = DEFAULT_BACKOFF.factor, jitter = DEFAULT_BACKOFF.jitter } = backoff;
1309
+ if (typeof factor !== "number" || !Number.isFinite(factor) || factor < 1) {
1310
+ fail(`backoff.factor must be a number of at least 1, got ${JSON.stringify(factor)}`);
1311
+ }
1312
+ if (typeof jitter !== "number" || !(jitter >= 0 && jitter <= 1)) {
1313
+ fail(`backoff.jitter must be a number from 0 to 1, got ${JSON.stringify(jitter)}`);
1314
+ }
1315
+ return { factor, jitter };
1316
+ }
1317
+ function timingFromFlag(flag) {
1318
+ try {
1319
+ if (toMs(flag) >= MIN_EVERY_MS) return { every: flag };
1320
+ } catch {
1321
+ }
1322
+ return isCronPatternValid(flag) ? flag : null;
1323
+ }
1324
+ var NAMED_DELAYS = /* @__PURE__ */ new Set(["every", "min", "max"]);
1325
+ function nextDelay(value) {
1326
+ if (typeof value === "string" && NAMED_DELAYS.has(value)) return value;
1327
+ if (typeof value !== "string" && typeof value !== "number") return null;
1328
+ try {
1329
+ return toMs(value);
1330
+ } catch {
1331
+ return null;
1332
+ }
1333
+ }
1334
+ function parseNextDecision(value) {
1335
+ if (value === void 0) return void 0;
1336
+ const delay = nextDelay(value);
1337
+ if (delay != null) return { delay, reason: null };
1338
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1339
+ const { in: wanted, reason, ...rest } = value;
1340
+ const inDelay = nextDelay(wanted);
1341
+ if (inDelay != null && Object.keys(rest).length === 0 && (reason == null || typeof reason === "string")) {
1342
+ return { delay: inDelay, reason: reason || null };
1343
+ }
1344
+ }
1345
+ throw new Error(
1346
+ `next returned ${JSON.stringify(value) ?? String(value)}; expected undefined, a duration, "every", "min", "max" or { in, reason }`
1347
+ );
1348
+ }
1349
+
1179
1350
  // src/runtime/task.ts
1180
- var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1351
+ var NEXT_HOOK_TIMEOUT_MS = 5e3;
1352
+ var FAILURE_STREAK_LOOKBACK = 256;
1353
+ function streakOf(outcomes) {
1354
+ const firstOther = outcomes.findIndex((tick) => tick.outcome !== "failure");
1355
+ return firstOther === -1 ? outcomes.length : firstOther;
1356
+ }
1181
1357
  var failedRuns = /* @__PURE__ */ new WeakMap();
1182
1358
  function failedRunId(error) {
1183
1359
  return runFailure(error)?.runId ?? null;
@@ -1193,10 +1369,9 @@ var Task = class {
1193
1369
  kv;
1194
1370
  sqlite;
1195
1371
  meta;
1372
+ schedulingServices;
1196
1373
  entry = null;
1197
1374
  entryExports = null;
1198
- /** Executions started and not yet settled. */
1199
- inFlight = 0;
1200
1375
  constructor({
1201
1376
  entryParams,
1202
1377
  buildConfigFn
@@ -1205,13 +1380,14 @@ var Task = class {
1205
1380
  this.entry = new Entry(entryParams, {
1206
1381
  onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
1207
1382
  });
1208
- const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
1383
+ const { taskCallbacks, startRun, history, env, kv, sqlite, scheduling } = buildConfigFn({
1209
1384
  sourcePath: this.entry.paths.source,
1210
1385
  compiledPath: this.entry.paths.compiled
1211
1386
  });
1212
1387
  this.taskCallbacks = taskCallbacks;
1213
1388
  this.startRun = startRun;
1214
1389
  this.history = history;
1390
+ this.schedulingServices = scheduling;
1215
1391
  this.sqlite = sqlite;
1216
1392
  this.meta = {
1217
1393
  fileTaskId: -1,
@@ -1241,48 +1417,52 @@ var Task = class {
1241
1417
  invariant5(this.entry, "Entry not initialized.");
1242
1418
  return this.entry.codeHash;
1243
1419
  }
1244
- get timingPattern() {
1420
+ /** The `timing` export, as the latest run (or `loadEntry`) read it. */
1421
+ get timing() {
1245
1422
  invariant5(this.entryExports, "Entry exports not initialized.");
1246
1423
  return this.entryExports.timing;
1247
1424
  }
1248
- /**
1249
- * The task's `overlap` export, `"skip"` when it has none. Throws on anything
1250
- * else, naming the task, so a typo fails at schedule time.
1251
- */
1252
- get overlapPolicy() {
1425
+ /** The `next` export, as loaded: unchecked, for whoever schedules the task to check. */
1426
+ get nextHook() {
1253
1427
  invariant5(this.entryExports, "Entry exports not initialized.");
1254
- const { overlap = "skip" } = this.entryExports;
1255
- invariant5(
1256
- overlap === "skip" || overlap === "allow",
1257
- `Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
1258
- );
1259
- return overlap;
1428
+ return this.entryExports.next;
1260
1429
  }
1261
1430
  /**
1262
1431
  * Check the exports that shape how the task is scheduled, so a bad value
1263
- * fails when the task is scheduled rather than on its first tick.
1432
+ * fails when the task is scheduled rather than on its first tick. ngn runs
1433
+ * one run of a task at a time, so `overlap` may only say so.
1264
1434
  */
1265
1435
  validateExports() {
1266
1436
  invariant5(this.entryExports, "Entry exports not initialized.");
1267
- void this.overlapPolicy;
1268
- const { budget, exclusive, retry } = this.entryExports;
1437
+ const { budget, exclusive, retry, overlap = "skip" } = this.entryExports;
1438
+ invariant5(
1439
+ overlap !== "allow",
1440
+ `overlap "allow" for ${this.taskPath}: ngn runs one run of a task at a time, across every process on the database, so a task cannot run beside itself`
1441
+ );
1442
+ invariant5(
1443
+ overlap === "skip",
1444
+ `Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip"`
1445
+ );
1269
1446
  if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
1270
1447
  exclusiveNames(exclusive, this.taskPath);
1271
1448
  resolveRetry(retry, this.taskPath);
1272
1449
  }
1273
- /** Whether an execution of this task is in progress. */
1274
- get isRunning() {
1275
- return this.inFlight > 0;
1450
+ /** The `retry` export, checked. */
1451
+ get retryPolicy() {
1452
+ invariant5(this.entryExports, "Entry exports not initialized.");
1453
+ return resolveRetry(this.entryExports.retry, this.taskPath);
1454
+ }
1455
+ /** The schedule and lease services this task's runs are recorded with. */
1456
+ get scheduling() {
1457
+ return this.schedulingServices;
1276
1458
  }
1277
1459
  /**
1278
- * Record a run that ngn decided not to start, without loading or calling any
1279
- * of the task's code. Needs the task to have been registered by an earlier
1280
- * execution, which is always so for a run skipped because one is in progress.
1460
+ * Load the task file and register it and its version, without running it:
1461
+ * its schedule is kept against the task's id. Returns that id.
1281
1462
  */
1282
- async skip(reason) {
1283
- invariant5(this.entry, "Entry not initialized.");
1284
- const run = await this.startRun();
1285
- await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
1463
+ async register() {
1464
+ await this.loadAndRegister();
1465
+ return this.meta.fileTaskId;
1286
1466
  }
1287
1467
  hasTaskExport() {
1288
1468
  return this.entryExports?.task !== void 0;
@@ -1353,31 +1533,106 @@ var Task = class {
1353
1533
  }
1354
1534
  }
1355
1535
  /**
1356
- * Run the task for one tick, retrying as its `retry` export allows. A
1357
- * scheduled tick passes when it fired and when the next one is due, which
1358
- * `ctx.run.deadline` and the retry window are worked out from.
1536
+ * Run the task for one tick, retrying as its `retry` export allows, and say
1537
+ * how it ended. A scheduled tick passes when it fired and when the next one
1538
+ * is due, which `ctx.run.deadline` and the retry window are worked out from.
1539
+ * Never throws: a failure is an outcome, its error recorded on its run.
1540
+ */
1541
+ executeTick(schedule = {}) {
1542
+ return this.executeWithRetries(schedule);
1543
+ }
1544
+ /**
1545
+ * One tick, for a caller that wants the task's value: what it returned, or
1546
+ * undefined for a skip. Throws what the tick failed with.
1359
1547
  */
1360
1548
  async execute(schedule = {}) {
1361
- this.inFlight++;
1362
- try {
1363
- return await this.executeWithRetries(schedule);
1364
- } finally {
1365
- this.inFlight--;
1366
- }
1549
+ const outcome = await this.executeTick(schedule);
1550
+ if (outcome.status === "failure") throw outcome.error;
1551
+ return outcome.result;
1367
1552
  }
1368
1553
  async executeWithRetries(schedule) {
1369
- const tick2 = {
1554
+ const tick = {
1370
1555
  firstRunId: null,
1556
+ runId: null,
1371
1557
  retry: resolveRetry(void 0, this.taskPath),
1372
- failedContext: null
1558
+ failedContext: null,
1559
+ final: null
1373
1560
  };
1561
+ const outcome = await this.attempts(schedule, tick);
1562
+ if (schedule.next) outcome.next = await this.askNext(outcome, tick, schedule.next);
1563
+ return outcome;
1564
+ }
1565
+ /**
1566
+ * Ask the `next` hook when to run next. What it throws, returns that it may
1567
+ * not, or a hook that takes too long, is recorded on the tick's final run in
1568
+ * stage `next`, and leaves the decision to the policy, as does a tick that
1569
+ * never got as far as loading the task's exports.
1570
+ */
1571
+ async askNext(outcome, tick, { previousDelayMs, timeoutMs = NEXT_HOOK_TIMEOUT_MS }) {
1572
+ const final = tick.final;
1573
+ const hook = final?.exports.next;
1574
+ if (!final || typeof hook !== "function" || outcome.runId == null) return null;
1575
+ const told = {
1576
+ status: outcome.status,
1577
+ result: outcome.result,
1578
+ error: outcome.error,
1579
+ skipReason: outcome.skipReason,
1580
+ failedInARow: await this.failedInARow(),
1581
+ previousDelayMs,
1582
+ runId: outcome.runId
1583
+ };
1584
+ let timer;
1585
+ try {
1586
+ const value = await Promise.race([
1587
+ (async () => hook(told, final.context))(),
1588
+ new Promise((_, reject) => {
1589
+ timer = setTimeout(
1590
+ () => reject(new Error(`next took longer than ${timeoutMs / 1e3}s`)),
1591
+ timeoutMs
1592
+ );
1593
+ })
1594
+ ]);
1595
+ return parseNextDecision(value) ?? null;
1596
+ } catch (error) {
1597
+ await final.run.callbacks.onHookError?.({ path: final.path, stage: "next", error });
1598
+ return null;
1599
+ } finally {
1600
+ clearTimeout(timer);
1601
+ }
1602
+ }
1603
+ /** The tick's attempts, retrying as `retry` allows, and how the last one ended. */
1604
+ async attempts(schedule, tick) {
1605
+ const clock = schedule.clock ?? systemClock;
1606
+ const outcome = (attempts, end) => ({
1607
+ result: void 0,
1608
+ error: void 0,
1609
+ skipReason: null,
1610
+ firstRunId: tick.firstRunId,
1611
+ runId: tick.runId,
1612
+ attempts,
1613
+ endedAt: clock.now(),
1614
+ next: null,
1615
+ ...end
1616
+ });
1374
1617
  for (let attempt = 1; ; attempt++) {
1375
- tick2.failedContext = null;
1618
+ tick.failedContext = null;
1619
+ if (schedule.signal?.aborted) {
1620
+ return outcome(attempt - 1, { status: "failure", error: schedule.signal.reason });
1621
+ }
1376
1622
  try {
1377
- return await this.executeOnce(schedule, tick2);
1623
+ const end = await this.executeOnce(schedule, tick, clock);
1624
+ return end.status === "success" ? outcome(attempt, { status: "success", result: end.result }) : outcome(attempt, { status: "skipped", skipReason: end.reason });
1378
1625
  } catch (error) {
1379
- if (!await this.shouldRetry(error, attempt, schedule, tick2)) throw error;
1380
- await sleep2(tick2.retry.delayMs);
1626
+ let retry;
1627
+ try {
1628
+ retry = await this.shouldRetry(error, attempt, schedule, tick, clock);
1629
+ } catch (thrown) {
1630
+ return outcome(attempt, { status: "failure", error: thrown });
1631
+ }
1632
+ if (!retry || schedule.signal?.aborted)
1633
+ return outcome(attempt, { status: "failure", error });
1634
+ await clock.sleep(tick.retry.delayMs, schedule.signal);
1635
+ if (schedule.signal?.aborted) return outcome(attempt, { status: "failure", error });
1381
1636
  }
1382
1637
  }
1383
1638
  }
@@ -1386,10 +1641,19 @@ var Task = class {
1386
1641
  * fails the same way every time. Then it has to fit before the next tick,
1387
1642
  * and the task's `shouldRetry` gets the last word.
1388
1643
  */
1389
- async shouldRetry(error, attempt, { nextAt = null }, tick2) {
1390
- if (!tick2.failedContext || !retryFits({ attempt, retry: tick2.retry, nextAt })) return false;
1644
+ async shouldRetry(error, attempt, { nextAt = null }, tick, clock) {
1645
+ if (!tick.failedContext) return false;
1646
+ if (!retryFits({ attempt, retry: tick.retry, nextAt, now: clock.now() })) return false;
1391
1647
  const { shouldRetry } = this.entryExports ?? {};
1392
- return shouldRetry ? await shouldRetry(error, tick2.failedContext) !== false : true;
1648
+ return shouldRetry ? await shouldRetry(error, tick.failedContext) !== false : true;
1649
+ }
1650
+ /**
1651
+ * The task's latest ticks that failed in a row, from its run history, so a
1652
+ * restart does not reset the count. Ticks closed as interrupted count, since
1653
+ * their runs were closed as failed; skipped ticks neither break nor extend it.
1654
+ */
1655
+ async failedInARow() {
1656
+ return streakOf(await this.history.recentTickOutcomes(FAILURE_STREAK_LOOKBACK));
1393
1657
  }
1394
1658
  /**
1395
1659
  * Whether the breaker holds this tick back: after `after` failed ticks in a
@@ -1399,11 +1663,7 @@ var Task = class {
1399
1663
  async breakerOpen(retry) {
1400
1664
  if (!retry.breaker) return false;
1401
1665
  const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
1402
- const failedInARow = outcomes.findIndex((tick2) => tick2.outcome !== "failure");
1403
- const wanted = breakerSkips(
1404
- failedInARow === -1 ? outcomes.length : failedInARow,
1405
- retry.breaker
1406
- );
1666
+ const wanted = breakerSkips(streakOf(outcomes), retry.breaker);
1407
1667
  if (wanted === 0) return false;
1408
1668
  return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
1409
1669
  }
@@ -1443,14 +1703,17 @@ var Task = class {
1443
1703
  * Load the task file, open the attempt's `task_runs` row, then evaluate the
1444
1704
  * file's code; what that throws is recorded on the row as failed in `load`.
1445
1705
  */
1446
- async openRun(tick2) {
1706
+ async openRun(tick, onRunStarted) {
1447
1707
  invariant5(this.entry, "Entry not initialized.");
1708
+ tick.final = null;
1448
1709
  await this.loadAndRegister();
1449
1710
  const meta = { ...this.meta };
1450
1711
  const path = this.entry.paths.relativeEntry;
1451
- const run = await this.startRun({ retryOf: tick2.firstRunId });
1452
- const firstAttempt = tick2.firstRunId == null;
1453
- tick2.firstRunId ??= run.id;
1712
+ const run = await this.startRun({ retryOf: tick.firstRunId });
1713
+ const firstAttempt = tick.firstRunId == null;
1714
+ tick.firstRunId ??= run.id;
1715
+ tick.runId = run.id;
1716
+ onRunStarted?.(run.id);
1454
1717
  try {
1455
1718
  return { run, meta, path, firstAttempt, ...await this.readExports(path) };
1456
1719
  } catch (error) {
@@ -1464,12 +1727,20 @@ var Task = class {
1464
1727
  * cannot be loaded at all, or a run row that cannot be written, fails before
1465
1728
  * there is a row to record it on.
1466
1729
  */
1467
- async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1468
- const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick2);
1469
- tick2.retry = retry;
1470
- const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1471
- const { signal, dispose } = deadlineSignal(deadline);
1730
+ async executeOnce({
1731
+ scheduledAt = null,
1732
+ nextAt = null,
1733
+ periodMs = null,
1734
+ signal: outer,
1735
+ onRunStarted
1736
+ }, tick, clock) {
1737
+ const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick, onRunStarted);
1738
+ tick.retry = retry;
1739
+ const startedAt = clock.now();
1740
+ const deadline = runDeadline({ startedAt, scheduledAt, nextAt, budgetMs, periodMs });
1741
+ const { signal, dispose } = deadlineSignal(deadline, outer);
1472
1742
  const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1743
+ tick.final = { run, path, context: taskContext, exports };
1473
1744
  let stage = "start";
1474
1745
  let exclusiveHeld = null;
1475
1746
  try {
@@ -1481,25 +1752,25 @@ var Task = class {
1481
1752
  });
1482
1753
  if ("reason" in gate) {
1483
1754
  await run.callbacks.onSkip?.({ path, reason: gate.reason });
1484
- return;
1755
+ return { status: "skipped", reason: gate.reason };
1485
1756
  }
1486
1757
  exclusiveHeld = gate.held;
1487
1758
  stage = "shouldSkip";
1488
1759
  if (await exports.shouldSkip?.(taskContext)) {
1489
1760
  await run.callbacks.onSkip?.({ path });
1490
- return;
1761
+ return { status: "skipped", reason: null };
1491
1762
  }
1492
1763
  stage = "start";
1493
1764
  await run.callbacks.onStart?.({ path });
1494
1765
  stage = "task";
1495
- tick2.failedContext = taskContext;
1766
+ tick.failedContext = taskContext;
1496
1767
  const value = await task(taskContext);
1497
- tick2.failedContext = null;
1768
+ tick.failedContext = null;
1498
1769
  stage = "record";
1499
1770
  await run.callbacks.onSuccess?.({ path, result: value });
1500
1771
  await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
1501
1772
  await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1502
- return value;
1773
+ return { status: "success", result: value };
1503
1774
  } catch (error) {
1504
1775
  await this.failRun(run, stage, error);
1505
1776
  if (stage === "task" || stage === "record") {
@@ -1572,61 +1843,1172 @@ var TaskLibrary = class {
1572
1843
  };
1573
1844
 
1574
1845
  // src/runtime/taskLibraryScheduler.ts
1575
- import invariant8 from "tiny-invariant";
1846
+ import invariant7 from "tiny-invariant";
1576
1847
  import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
1577
1848
 
1578
- // src/runtime/schedule.ts
1579
- import { Cron } from "croner";
1580
- import invariant7 from "tiny-invariant";
1581
- var CRON_OPTIONS = { mode: "5-or-6-parts" };
1582
- function isCronPatternValid(pattern) {
1583
- try {
1584
- new Cron(pattern, { ...CRON_OPTIONS, paused: true }).stop();
1585
- return true;
1586
- } catch {
1587
- return false;
1849
+ // src/runtime/taskSchedule.ts
1850
+ import { canTransition as canTransition2, errorSummary } from "@apisurf/ngn-schema/describe";
1851
+
1852
+ // src/db/services/schedule.ts
1853
+ import {
1854
+ canTransition,
1855
+ failureLine,
1856
+ SCHEDULE_TRANSITIONS
1857
+ } from "@apisurf/ngn-schema/describe";
1858
+ var FIELDS = ["next_at", "reason", "source", "holder", "anchor", "request"];
1859
+ var ScheduleStore = class {
1860
+ constructor(db2) {
1861
+ this.db = db2;
1588
1862
  }
1863
+ async read(taskId) {
1864
+ const { rows } = await this.db.execute({
1865
+ sql: "SELECT * FROM schedules WHERE file_task_id = ?",
1866
+ args: [taskId]
1867
+ });
1868
+ return rows[0] ?? null;
1869
+ }
1870
+ /**
1871
+ * Apply `change` to the task's schedule, as it stands now. The new row, or
1872
+ * why it was refused.
1873
+ */
1874
+ async transition(taskId, change) {
1875
+ const current = await this.read(taskId);
1876
+ const refused = refusal(current, change);
1877
+ if (refused) return { refused };
1878
+ const from = current?.state ?? null;
1879
+ const to = SCHEDULE_TRANSITIONS[change.event].to;
1880
+ const statements = [
1881
+ current ? this.update(taskId, from, to, change) : this.insert(taskId, to, change),
1882
+ this.event(taskId, from, change, current?.next_at ?? null)
1883
+ ];
1884
+ if (change.finalRunId != null)
1885
+ statements.push(this.finalRun(change.finalRunId, change, current));
1886
+ const next = change.followedBy;
1887
+ if (next) {
1888
+ statements.push(
1889
+ this.update(taskId, to, SCHEDULE_TRANSITIONS[next.event].to, next, change.at),
1890
+ this.event(taskId, to, next, change.set?.next_at ?? current?.next_at ?? null)
1891
+ );
1892
+ }
1893
+ const results = await this.db.batch(statements, "write");
1894
+ if (!results[0] || results[0].rowsAffected === 0) {
1895
+ return { refused: `${change.event}: the schedule changed before it could be written` };
1896
+ }
1897
+ return await this.read(taskId);
1898
+ }
1899
+ insert(taskId, to, change) {
1900
+ const set = change.set ?? {};
1901
+ return {
1902
+ sql: `INSERT INTO schedules (file_task_id, state, ${FIELDS.join(", ")}, updated_at)
1903
+ VALUES (:taskId, :to, ${FIELDS.map((f) => `:${f}`).join(", ")}, :at)
1904
+ ON CONFLICT(file_task_id) DO NOTHING`,
1905
+ args: {
1906
+ taskId,
1907
+ to,
1908
+ at: change.at,
1909
+ ...Object.fromEntries(FIELDS.map((f) => [f, set[f] ?? null]))
1910
+ }
1911
+ };
1912
+ }
1913
+ /**
1914
+ * The row update, conditional on the state it comes from (and the holder or
1915
+ * request the change names). `after`: the time of the transition this one
1916
+ * follows in the same write, which it applies only on top of.
1917
+ */
1918
+ update(taskId, from, to, change, after) {
1919
+ const set = change.set ?? {};
1920
+ const fields = FIELDS.filter((f) => f in set);
1921
+ const guards = [
1922
+ change.holder !== void 0 ? "AND holder IS :heldBy" : "",
1923
+ change.requestIs !== void 0 ? "AND request IS :requestIs" : "",
1924
+ after !== void 0 ? "AND updated_at = :after" : ""
1925
+ ];
1926
+ return {
1927
+ sql: `UPDATE schedules SET state = :to, updated_at = :at
1928
+ ${fields.map((f) => `, ${f} = :${f}`).join("")}
1929
+ WHERE file_task_id = :taskId AND state = :from ${guards.join(" ")}`,
1930
+ args: {
1931
+ taskId,
1932
+ from,
1933
+ to,
1934
+ at: change.at,
1935
+ ...change.holder !== void 0 ? { heldBy: change.holder } : {},
1936
+ ...change.requestIs !== void 0 ? { requestIs: change.requestIs } : {},
1937
+ ...after !== void 0 ? { after } : {},
1938
+ ...Object.fromEntries(fields.map((f) => [`${f}`, set[f] ?? null]))
1939
+ }
1940
+ };
1941
+ }
1942
+ /** The next run and its reason, on the tick's final run, written only if the event was. */
1943
+ finalRun(runId, change, current) {
1944
+ return {
1945
+ sql: `UPDATE task_runs SET next_at = :nextAt, next_reason = :reason
1946
+ WHERE id = :id AND changes() = 1`,
1947
+ args: {
1948
+ id: runId,
1949
+ nextAt: change.set?.next_at ?? current?.next_at ?? null,
1950
+ reason: change.set?.reason ?? current?.reason ?? null
1951
+ }
1952
+ };
1953
+ }
1954
+ /** The event, copied from the row the update left, written only if it applied. */
1955
+ event(taskId, from, change, previousNextAt) {
1956
+ return {
1957
+ sql: `INSERT INTO schedule_events (file_task_id, at, event, from_state, to_state,
1958
+ ${FIELDS.join(", ")}, origin, first_run_id, run_id, previous_next_at)
1959
+ SELECT s.file_task_id, :at, :event, :from, s.state, ${FIELDS.map((f) => `s.${f}`).join(", ")},
1960
+ :origin, :firstRunId, :runId, :previousNextAt
1961
+ FROM schedules s WHERE s.file_task_id = :taskId AND changes() = 1`,
1962
+ args: {
1963
+ taskId,
1964
+ at: change.at,
1965
+ event: change.event,
1966
+ from,
1967
+ origin: change.origin ?? null,
1968
+ firstRunId: change.firstRunId ?? null,
1969
+ runId: change.runId ?? null,
1970
+ previousNextAt
1971
+ }
1972
+ };
1973
+ }
1974
+ /**
1975
+ * Close the task's runs that never recorded an end, as failed in
1976
+ * `interrupted` with `why` as the error, in one write. `fromRunId` limits it
1977
+ * to one tick's runs. Returns the ids closed, oldest first.
1978
+ */
1979
+ async closeOpenRuns(taskId, why, { fromRunId = 0, at }) {
1980
+ const open = `file_task_id = :taskId AND id >= :fromRunId AND ended_at IS NULL
1981
+ AND status IN ('pending', 'running')`;
1982
+ const args = { taskId, fromRunId };
1983
+ const log = (value) => ({
1984
+ sql: `INSERT INTO logs (status, value, file_task_id, task_run_id)
1985
+ SELECT 'error', :value, file_task_id, id FROM task_runs WHERE ${open} ORDER BY id`,
1986
+ args: { ...args, value }
1987
+ });
1988
+ const [, , closed] = await this.db.batch(
1989
+ [
1990
+ log(failureLine("interrupted", new Error(why))),
1991
+ log("Task failed"),
1992
+ {
1993
+ sql: `UPDATE task_runs SET status = 'failure', ended_at = :at WHERE ${open} RETURNING id`,
1994
+ args: { ...args, at }
1995
+ }
1996
+ ],
1997
+ "write"
1998
+ );
1999
+ return closed.rows.map((row) => Number(row.id)).toSorted((a, b) => a - b);
2000
+ }
2001
+ };
2002
+ function refusal(current, change) {
2003
+ const from = current?.state ?? null;
2004
+ if (!canTransition(from, change.event)) {
2005
+ return `${change.event} is not allowed from ${from ?? "no schedule"}`;
2006
+ }
2007
+ if (change.holder !== void 0 && current?.holder !== change.holder) {
2008
+ return `${change.event}: the task is no longer held by this tick`;
2009
+ }
2010
+ if (change.requestIs !== void 0 && current?.request !== change.requestIs) {
2011
+ return `${change.event}: a request was made since the schedule was read`;
2012
+ }
2013
+ const next = change.followedBy;
2014
+ const to = SCHEDULE_TRANSITIONS[change.event].to;
2015
+ if (next && !canTransition(to, next.event)) return `${next.event} is not allowed from ${to}`;
2016
+ return null;
1589
2017
  }
1590
- async function tick(task, onError, schedule = {}) {
1591
- try {
1592
- if (task.isRunning && task.overlapPolicy === "skip") {
1593
- await task.skip("previous run still in progress");
1594
- return;
2018
+ function isRefusal(value) {
2019
+ return "refused" in value;
2020
+ }
2021
+
2022
+ // src/runtime/lease.ts
2023
+ var TASK_LEASE_TTL_MS = 6e4;
2024
+ function taskLeaseName(taskPath) {
2025
+ return `${RESERVED_LOCK_PREFIX}task:${taskPath}`;
2026
+ }
2027
+ var TaskLease = class _TaskLease {
2028
+ constructor(locks, name, holder, ttlMs, onLost) {
2029
+ this.locks = locks;
2030
+ this.name = name;
2031
+ this.holder = holder;
2032
+ this.ttlMs = ttlMs;
2033
+ this.onLost = onLost;
2034
+ this.renewal = setInterval(() => void this.renew(), Math.max(10, ttlMs / 3));
2035
+ this.renewal.unref();
2036
+ }
2037
+ controller = new AbortController();
2038
+ renewal = null;
2039
+ lastRenewed = Date.now();
2040
+ released = false;
2041
+ /** Take the task's lease, or say who has it. */
2042
+ static async take(locks, taskPath, {
2043
+ ttlMs = TASK_LEASE_TTL_MS,
2044
+ onLost = () => {
2045
+ }
2046
+ } = {}) {
2047
+ const name = taskLeaseName(taskPath);
2048
+ const holder = await locks.tryAcquire(name, ttlMs, null);
2049
+ if (holder) return new _TaskLease(locks, name, holder, ttlMs, onLost);
2050
+ const held = await locks.holderOf(name);
2051
+ return { busy: { holder: held?.holder ?? "", runId: held?.taskRunId ?? null } };
2052
+ }
2053
+ /** Aborts when the lease is lost. */
2054
+ get signal() {
2055
+ return this.controller.signal;
2056
+ }
2057
+ get lost() {
2058
+ return this.controller.signal.aborted;
2059
+ }
2060
+ /** Record the run the lease is working for, so a refusal can name it. */
2061
+ async setRun(runId) {
2062
+ if (this.released || this.lost) return;
2063
+ await this.locks.setRun(this.name, this.holder, runId).catch(() => {
2064
+ });
2065
+ }
2066
+ async renew() {
2067
+ if (this.released || this.lost) return;
2068
+ if (Date.now() - this.lastRenewed > this.ttlMs) return this.lose();
2069
+ try {
2070
+ if (await this.locks.renew(this.name, this.holder, this.ttlMs)) {
2071
+ this.lastRenewed = Date.now();
2072
+ } else {
2073
+ this.lose();
2074
+ }
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ lose() {
2079
+ if (this.lost) return;
2080
+ this.stopRenewing();
2081
+ this.controller.abort(new Error("the run lost its lease"));
2082
+ this.onLost(this);
2083
+ }
2084
+ stopRenewing() {
2085
+ if (this.renewal) clearInterval(this.renewal);
2086
+ this.renewal = null;
2087
+ }
2088
+ async release() {
2089
+ if (this.released) return;
2090
+ this.released = true;
2091
+ this.stopRenewing();
2092
+ await this.locks.release(this.name, this.holder).catch(() => {
2093
+ });
2094
+ }
2095
+ /** Stop renewing without giving the lease back, as a process that died would. */
2096
+ abandon() {
2097
+ this.released = true;
2098
+ this.stopRenewing();
2099
+ }
2100
+ };
2101
+
2102
+ // src/runtime/scheduleControl.ts
2103
+ import {
2104
+ encodeScheduleRequest,
2105
+ formatClock,
2106
+ formatDelay,
2107
+ parseScheduleRequest
2108
+ } from "@apisurf/ngn-schema/describe";
2109
+ var MIN_DELAY_MS = 1e3;
2110
+ async function controlSchedule(target, command) {
2111
+ for (let attempt = 0; attempt < 2; attempt++) {
2112
+ const row = await target.store.read(target.taskId);
2113
+ if (!row) {
2114
+ return {
2115
+ applied: false,
2116
+ error: true,
2117
+ message: `${target.name} has not been scheduled yet: ngn run schedules it`
2118
+ };
2119
+ }
2120
+ const plan = planCommand(row, command, target);
2121
+ if (!("change" in plan)) return plan;
2122
+ const result = await target.store.transition(target.taskId, plan.change);
2123
+ if (!isRefusal(result)) {
2124
+ return { applied: true, event: plan.change.event, row: result, message: plan.message };
1595
2125
  }
1596
- await task.execute(schedule);
2126
+ }
2127
+ return {
2128
+ applied: false,
2129
+ error: true,
2130
+ message: `the schedule of ${target.name} kept changing; try again`
2131
+ };
2132
+ }
2133
+ function planCommand(row, command, target) {
2134
+ if (command.kind === "pause") return planPause(row, target);
2135
+ if (command.kind === "resume") return planResume(row, target);
2136
+ return planNext(row, command.delayMs, target);
2137
+ }
2138
+ function withRequest(row, more) {
2139
+ return encodeScheduleRequest({ ...parseScheduleRequest(row.request), ...more });
2140
+ }
2141
+ function planPause(row, { name, now }) {
2142
+ if (row.state === "paused") {
2143
+ return { applied: false, error: false, message: `${name} is already paused` };
2144
+ }
2145
+ if (row.state === "running") {
2146
+ return {
2147
+ change: {
2148
+ event: "pause_requested",
2149
+ at: now,
2150
+ set: { request: withRequest(row, { pause: true }) },
2151
+ requestIs: row.request
2152
+ },
2153
+ message: `${name} has a run in progress; it pauses when the run ends`
2154
+ };
2155
+ }
2156
+ return {
2157
+ change: { event: "paused", at: now, set: { holder: null } },
2158
+ message: `paused ${name}: no runs until ngn resume ${name}`
2159
+ };
2160
+ }
2161
+ function planResume(row, { name, now, pattern }) {
2162
+ if (row.state !== "paused") {
2163
+ return { applied: false, error: false, message: `${name} isn't paused (${row.state})` };
2164
+ }
2165
+ const missed = row.next_at == null || row.next_at < now;
2166
+ let set = { reason: "resumed", source: "cli" };
2167
+ if (missed) {
2168
+ const nextAt2 = pattern ? nextCronSlot(pattern, now) ?? now : now;
2169
+ set = {
2170
+ next_at: nextAt2,
2171
+ anchor: nextAt2,
2172
+ source: "cli",
2173
+ reason: pattern ? "resumed; missed slots are not run, next on its pattern" : "resumed; it was due while paused, so it runs now"
2174
+ };
2175
+ }
2176
+ const nextAt = set.next_at ?? row.next_at;
2177
+ return {
2178
+ change: { event: "resumed", at: now, set },
2179
+ message: `resumed ${name}: next run ${nextAt <= now ? "now" : formatClock(nextAt)}`
2180
+ };
2181
+ }
2182
+ function planNext(row, delayMs, { name, now }) {
2183
+ if (row.state === "paused") {
2184
+ return {
2185
+ applied: false,
2186
+ error: true,
2187
+ message: `${name} is paused; run ngn resume ${name} first`
2188
+ };
2189
+ }
2190
+ const delay = delayMs === 0 ? 0 : Math.max(MIN_DELAY_MS, delayMs);
2191
+ const nextAt = now + delay;
2192
+ if (row.state === "running") {
2193
+ return {
2194
+ change: {
2195
+ event: "override_requested",
2196
+ at: now,
2197
+ set: { request: withRequest(row, { nextAt }) },
2198
+ requestIs: row.request
2199
+ },
2200
+ message: `${name} has a run in progress; when it ends, its next run is ${delay === 0 ? "now" : formatClock(nextAt)}`
2201
+ };
2202
+ }
2203
+ return {
2204
+ change: {
2205
+ event: "rescheduled",
2206
+ at: now,
2207
+ set: {
2208
+ next_at: nextAt,
2209
+ reason: `moved with ngn next: ${delay === 0 ? "now" : `in ${formatDelay(delay)}`}`,
2210
+ source: "cli",
2211
+ anchor: nextAt,
2212
+ holder: null
2213
+ }
2214
+ },
2215
+ message: `${name} runs next ${delay === 0 ? "now" : `at ${formatClock(nextAt)}`}`
2216
+ };
2217
+ }
2218
+ function honourRequest(change, row) {
2219
+ const { pause, nextAt } = parseScheduleRequest(row?.request);
2220
+ const set = { ...change.set, request: null };
2221
+ if (nextAt != null) {
2222
+ Object.assign(set, {
2223
+ next_at: nextAt,
2224
+ reason: `moved with ngn next to ${formatClock(nextAt)}`,
2225
+ source: "cli",
2226
+ anchor: nextAt
2227
+ });
2228
+ }
2229
+ return {
2230
+ ...change,
2231
+ set,
2232
+ requestIs: row?.request ?? null,
2233
+ ...pause ? { followedBy: { event: "paused", at: change.at } } : {}
2234
+ };
2235
+ }
2236
+
2237
+ // src/runtime/timingRule.ts
2238
+ import {
2239
+ describeTiming,
2240
+ formatClock as formatClock2,
2241
+ formatDelay as formatDelay2,
2242
+ formatMs
2243
+ } from "@apisurf/ngn-schema/describe";
2244
+ function gridSlotAfter(anchor, everyMs) {
2245
+ return (t) => anchor + (Math.floor((t - anchor) / everyMs) + 1) * everyMs;
2246
+ }
2247
+ function firstFittingSlot(slotAfter, plannedAt, endedAt) {
2248
+ const due = slotAfter(plannedAt);
2249
+ if (endedAt <= due) return { nextAt: due, due, missed: 0 };
2250
+ const nextAt = slotAfter(endedAt - 1);
2251
+ let missed = 0;
2252
+ for (let slot = due; slot < nextAt && missed < 1e3; slot = slotAfter(slot)) missed++;
2253
+ return { nextAt, due, missed };
2254
+ }
2255
+ function timingRule(timing, exported, random = Math.random) {
2256
+ const words = describeTiming(exported) ?? String(exported);
2257
+ if (timing.kind === "cron") return cronRule(timing.pattern, words);
2258
+ return timing.edge === "trailing" ? trailingRule(timing, words, random) : leadingRule(timing, words, random);
2259
+ }
2260
+ function failures(n) {
2261
+ return n === 1 ? "a failure" : `${n} failures in a row`;
2262
+ }
2263
+ function backoffDelay(timing, failedInARow, random) {
2264
+ const { factor, jitter } = timing.backoff;
2265
+ const base = Math.min(timing.maxMs, timing.everyMs * factor ** failedInARow);
2266
+ const jittered = Math.round(base * (1 + (random() * 2 - 1) * jitter));
2267
+ return Math.min(timing.maxMs, Math.max(timing.minMs, jittered));
2268
+ }
2269
+ function backoffDecision(timing, { status, endedAt, failedInARow }, random) {
2270
+ if (status !== "failure" || !timing.backoff) return null;
2271
+ const n = Math.max(1, failedInARow);
2272
+ const delay = backoffDelay(timing, n, random);
2273
+ const nextAt = endedAt + delay;
2274
+ return {
2275
+ nextAt,
2276
+ reason: `backing off after ${failures(n)}: ${formatDelay2(delay)}`,
2277
+ anchor: timing.edge === "leading" ? nextAt : null,
2278
+ source: "policy"
2279
+ };
2280
+ }
2281
+ function hookDecision(timing, { choice, endedAt }, onSchedule) {
2282
+ if (!choice) return null;
2283
+ if (choice.delay === "every") {
2284
+ const scheduled = onSchedule();
2285
+ return {
2286
+ ...scheduled,
2287
+ reason: choice.reason ?? `next hook: ${scheduled.reason}`,
2288
+ source: "hook"
2289
+ };
2290
+ }
2291
+ const named = typeof choice.delay === "string" ? choice.delay : null;
2292
+ const wanted = named === "min" ? timing.minMs : named === "max" ? timing.maxMs : choice.delay;
2293
+ const delay = Math.min(timing.maxMs, Math.max(timing.minMs, wanted));
2294
+ const clamped = delay === wanted ? "" : ` (${formatDelay2(wanted)} kept to ${delay === timing.minMs ? "min" : "max"} ${formatDelay2(delay)})`;
2295
+ const nextAt = endedAt + delay;
2296
+ return {
2297
+ nextAt,
2298
+ reason: `${choice.reason ?? `next hook: ${named ? `${named}, ` : ""}${formatDelay2(delay)}`}${clamped}`,
2299
+ anchor: timing.edge === "leading" ? nextAt : null,
2300
+ source: "hook"
2301
+ };
2302
+ }
2303
+ function intervalLatest(timing, now, slot, input) {
2304
+ if (input.source === "cli") return null;
2305
+ if (input.source != null && input.source !== "policy") return now + timing.maxMs;
2306
+ if (!timing.backoff || input.failedInARow === 0) return slot;
2307
+ const { factor, jitter } = timing.backoff;
2308
+ const longest = timing.everyMs * factor ** input.failedInARow * (1 + jitter);
2309
+ return Math.max(slot, now + Math.min(timing.maxMs, Math.ceil(longest)));
2310
+ }
2311
+ function trailingRule(timing, words, random) {
2312
+ const { everyMs } = timing;
2313
+ return {
2314
+ words,
2315
+ first: (now) => ({ nextAt: now, anchor: null }),
2316
+ window: (plannedAt) => ({ scheduledAt: plannedAt, nextAt: null, periodMs: everyMs }),
2317
+ decide(input) {
2318
+ const onSchedule = () => ({
2319
+ nextAt: input.endedAt + everyMs,
2320
+ reason: words,
2321
+ anchor: null,
2322
+ source: "policy"
2323
+ });
2324
+ return hookDecision(timing, input, onSchedule) ?? backoffDecision(timing, input, random) ?? onSchedule();
2325
+ },
2326
+ overdue: (now) => ({ nextAt: now, catchUp: true }),
2327
+ latest: (now, input) => intervalLatest(timing, now, now + everyMs, input)
2328
+ };
2329
+ }
2330
+ function leadingRule(timing, words, random) {
2331
+ const { everyMs } = timing;
2332
+ const slots = (anchor, fallback) => gridSlotAfter(anchor ?? fallback, everyMs);
2333
+ return {
2334
+ words,
2335
+ first: (now) => ({ nextAt: now, anchor: now }),
2336
+ window: (plannedAt, anchor) => ({
2337
+ scheduledAt: plannedAt,
2338
+ nextAt: slots(anchor, plannedAt)(plannedAt),
2339
+ periodMs: null
2340
+ }),
2341
+ decide(input) {
2342
+ const onSchedule = () => {
2343
+ const { plannedAt, endedAt, anchor } = input;
2344
+ const grid = anchor ?? plannedAt;
2345
+ const slot = firstFittingSlot(gridSlotAfter(grid, everyMs), plannedAt, endedAt);
2346
+ const reason = slot.missed ? `ran ${formatMs(endedAt - slot.due)} past its slot; next slot ${formatClock2(slot.nextAt)}` : words;
2347
+ return { nextAt: slot.nextAt, reason, anchor: grid, source: "policy" };
2348
+ };
2349
+ return hookDecision(timing, input, onSchedule) ?? backoffDecision(timing, input, random) ?? onSchedule();
2350
+ },
2351
+ overdue: (now) => ({ nextAt: now, catchUp: true }),
2352
+ latest: (now, input) => intervalLatest(timing, now, slots(input.anchor, now)(now), input)
2353
+ };
2354
+ }
2355
+ function cronRule(pattern, words) {
2356
+ const slotAfter = (t) => nextCronSlot(pattern, t) ?? Number.MAX_SAFE_INTEGER;
2357
+ return {
2358
+ words,
2359
+ first: (now) => ({ nextAt: slotAfter(now), anchor: null }),
2360
+ window: (plannedAt) => ({
2361
+ scheduledAt: plannedAt,
2362
+ nextAt: slotAfter(plannedAt),
2363
+ periodMs: null
2364
+ }),
2365
+ decide({ plannedAt, endedAt }) {
2366
+ const slot = firstFittingSlot(slotAfter, plannedAt, endedAt);
2367
+ const reason = slot.missed ? `ran past ${slot.missed} ${slot.missed === 1 ? "slot" : "slots"}; next ${formatClock2(slot.nextAt)}` : words;
2368
+ return { nextAt: slot.nextAt, reason, anchor: null, source: "policy" };
2369
+ },
2370
+ overdue: (now) => ({ nextAt: slotAfter(now), catchUp: false }),
2371
+ latest: (now, { source }) => source === "cli" ? null : slotAfter(now)
2372
+ };
2373
+ }
2374
+
2375
+ // src/runtime/taskSchedule.ts
2376
+ var STOP_GRACE_MS = 1e4;
2377
+ var CATCH_UP_REASON = "one catch-up run for what was missed while ngn was not running";
2378
+ var TickHandle = class {
2379
+ constructor(origin, lease) {
2380
+ this.origin = origin;
2381
+ this.lease = lease;
2382
+ this.whenClosed = new Promise((resolve2) => this.markClosed = () => resolve2(null));
2383
+ }
2384
+ controller = new AbortController();
2385
+ firstRunId = null;
2386
+ runId = null;
2387
+ /** Whether it wrote `started`, so its end has a transition to make. */
2388
+ logged = false;
2389
+ /** Closed by shutdown (or a crash): whatever it decides later is not written. */
2390
+ closed = false;
2391
+ done = Promise.resolve();
2392
+ /** Resolves when the tick is closed, so nothing waits on a run that may never end. */
2393
+ whenClosed;
2394
+ markClosed;
2395
+ close() {
2396
+ this.closed = true;
2397
+ this.markClosed();
2398
+ }
2399
+ get signal() {
2400
+ return AbortSignal.any([this.controller.signal, this.lease.signal]);
2401
+ }
2402
+ noteRun = (runId) => {
2403
+ this.firstRunId ??= runId;
2404
+ this.runId = runId;
2405
+ void this.lease.setRun(runId);
2406
+ };
2407
+ };
2408
+ function untilAborted(signal) {
2409
+ return new Promise((resolve2) => {
2410
+ if (signal.aborted) resolve2();
2411
+ else signal.addEventListener("abort", () => resolve2(), { once: true });
2412
+ });
2413
+ }
2414
+ function busyReason(taskPath, busy) {
2415
+ return busy.runId != null ? `run ${busy.runId} of ${taskPath} is in progress` : `a run of ${taskPath} is in progress`;
2416
+ }
2417
+ async function writeTransition(task, taskId, change, onProblem) {
2418
+ try {
2419
+ const result = await task.scheduling.store.transition(taskId, change);
2420
+ if (!isRefusal(result)) return result;
2421
+ onProblem(`schedule of ${task.taskPath}: refused ${result.refused}`);
1597
2422
  } catch (error) {
1598
- onError(error);
1599
- }
1600
- }
1601
- function scheduleTask(task, pattern, onError) {
1602
- invariant7(isCronPatternValid(pattern), `Invalid cron pattern: ${pattern} for ${task.taskPath}`);
1603
- task.validateExports();
1604
- return new Cron(
1605
- pattern,
1606
- CRON_OPTIONS,
1607
- (job) => tick(task, onError, {
1608
- scheduledAt: job.currentRun()?.getTime() ?? Date.now(),
1609
- nextAt: job.nextRun()?.getTime() ?? null
1610
- })
1611
- );
2423
+ onProblem(
2424
+ `schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
2425
+ );
2426
+ }
2427
+ return null;
2428
+ }
2429
+ async function writeTickEnd(task, taskId, change, onProblem) {
2430
+ for (let attempt = 0; attempt < 3; attempt++) {
2431
+ const row = await task.scheduling.store.read(taskId);
2432
+ const honoured = honourRequest(change, row);
2433
+ let result;
2434
+ try {
2435
+ result = await task.scheduling.store.transition(taskId, honoured);
2436
+ } catch (error) {
2437
+ onProblem(
2438
+ `schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
2439
+ );
2440
+ return null;
2441
+ }
2442
+ if (!isRefusal(result)) return result;
2443
+ const now = await task.scheduling.store.read(taskId);
2444
+ if (now?.request === honoured.requestIs) {
2445
+ onProblem(`schedule of ${task.taskPath}: refused ${result.refused}`);
2446
+ return null;
2447
+ }
2448
+ }
2449
+ onProblem(`schedule of ${task.taskPath}: requests kept arriving; ${change.event} not written`);
2450
+ return null;
2451
+ }
2452
+ async function startManual(deps, onLost = () => {
2453
+ }) {
2454
+ const { task, taskId, clock, onProblem } = deps;
2455
+ const lease = await TaskLease.take(task.scheduling.locks, task.taskPath, {
2456
+ ttlMs: deps.leaseTtlMs,
2457
+ onLost
2458
+ });
2459
+ if ("busy" in lease) return { busy: busyReason(task.taskPath, lease.busy) };
2460
+ const tick = new TickHandle("manual", lease);
2461
+ const outcome = (async () => {
2462
+ try {
2463
+ const row = await task.scheduling.store.read(taskId);
2464
+ if (row && canTransition2(row.state, "started")) {
2465
+ const request = row.state === "paused" ? { request: withRequest(row, { pause: true }) } : {};
2466
+ const started = await writeTransition(
2467
+ task,
2468
+ taskId,
2469
+ {
2470
+ event: "started",
2471
+ at: clock.now(),
2472
+ origin: "manual",
2473
+ set: { holder: lease.holder, ...request }
2474
+ },
2475
+ onProblem
2476
+ );
2477
+ tick.logged = started != null;
2478
+ }
2479
+ const result = await task.executeTick({
2480
+ scheduledAt: null,
2481
+ nextAt: deps.window?.nextAt ?? null,
2482
+ periodMs: deps.window?.periodMs ?? null,
2483
+ clock,
2484
+ signal: tick.signal,
2485
+ onRunStarted: tick.noteRun
2486
+ });
2487
+ if (tick.logged && !tick.closed && !lease.lost) {
2488
+ await writeTickEnd(
2489
+ task,
2490
+ taskId,
2491
+ {
2492
+ event: "ended",
2493
+ at: clock.now(),
2494
+ holder: lease.holder,
2495
+ set: { holder: null },
2496
+ firstRunId: result.firstRunId,
2497
+ runId: result.runId
2498
+ },
2499
+ onProblem
2500
+ );
2501
+ }
2502
+ return result;
2503
+ } finally {
2504
+ await lease.release();
2505
+ }
2506
+ })();
2507
+ tick.done = outcome.catch(() => {
2508
+ });
2509
+ return { tick, outcome };
2510
+ }
2511
+ async function runOnce(task, { clock, leaseTtlMs, onProblem }) {
2512
+ const taskId = await task.register();
2513
+ const started = await startManual({ task, taskId, clock, leaseTtlMs, onProblem });
2514
+ return "busy" in started ? started : started.outcome;
1612
2515
  }
2516
+ var TaskSchedule = class {
2517
+ constructor(options) {
2518
+ this.options = options;
2519
+ this.exported = options.timing;
2520
+ this.resolved = resolveTiming(options.timing, options.task.taskPath);
2521
+ }
2522
+ taskId = -1;
2523
+ row = null;
2524
+ resolved;
2525
+ exported;
2526
+ stopping = false;
2527
+ halt = new AbortController();
2528
+ ticks = /* @__PURE__ */ new Set();
2529
+ loopDone = Promise.resolve();
2530
+ get task() {
2531
+ return this.options.task;
2532
+ }
2533
+ get path() {
2534
+ return this.options.task.taskPath;
2535
+ }
2536
+ get store() {
2537
+ return this.options.task.scheduling.store;
2538
+ }
2539
+ get clock() {
2540
+ return this.options.clock;
2541
+ }
2542
+ get ttlMs() {
2543
+ return this.options.leaseTtlMs ?? TASK_LEASE_TTL_MS;
2544
+ }
2545
+ rule() {
2546
+ return timingRule(this.resolved, this.exported, this.options.random);
2547
+ }
2548
+ /**
2549
+ * The failure streak `outcome` ends, this tick included. A tick that failed
2550
+ * before it had a run is not in the history, so it is added here.
2551
+ */
2552
+ async failedInARow(outcome) {
2553
+ const streak = await this.task.failedInARow();
2554
+ return outcome?.status === "failure" && outcome.runId == null ? streak + 1 : streak;
2555
+ }
2556
+ nextAt() {
2557
+ return this.row?.next_at ?? null;
2558
+ }
2559
+ /** Register the task, bring its stored schedule up to date, and start the loop. */
2560
+ async start() {
2561
+ this.taskId = await this.task.register();
2562
+ await this.reconcile();
2563
+ this.loopDone = this.loop();
2564
+ }
2565
+ /** Transitions written by this process, so the loop can tell a step that moved things on. */
2566
+ writes = 0;
2567
+ write(change, write = writeTransition) {
2568
+ return write(this.task, this.taskId, change, this.options.onProblem).then((row) => {
2569
+ if (row) {
2570
+ this.row = row;
2571
+ this.writes++;
2572
+ }
2573
+ return row;
2574
+ });
2575
+ }
2576
+ /** Write the transition that ends a tick, honouring a pause or next run asked for during it. */
2577
+ writeEnd(change) {
2578
+ return this.write(change, writeTickEnd);
2579
+ }
2580
+ /**
2581
+ * What a start finds in the database: no schedule yet, a next run still to
2582
+ * come, one missed while ngn was not running, one further off than the
2583
+ * current timing could choose, or a tick a dead process left `running`.
2584
+ */
2585
+ async reconcile() {
2586
+ const now = this.clock.now();
2587
+ const rule = this.rule();
2588
+ const row = await this.store.read(this.taskId);
2589
+ this.row = row;
2590
+ if (!row) {
2591
+ const first = rule.first(now);
2592
+ await this.write({
2593
+ event: "scheduled",
2594
+ at: now,
2595
+ set: { next_at: first.nextAt, anchor: first.anchor, reason: "first run", source: "policy" }
2596
+ });
2597
+ return;
2598
+ }
2599
+ if (row.state === "running") {
2600
+ await this.recoverIfAbandoned();
2601
+ return;
2602
+ }
2603
+ if (row.state !== "waiting" || row.next_at == null) return;
2604
+ if (row.next_at < now - WAIT_STEP_MS) {
2605
+ const overdue = rule.overdue(now, row.anchor);
2606
+ await this.write({
2607
+ event: "overdue",
2608
+ at: now,
2609
+ set: {
2610
+ next_at: overdue.nextAt,
2611
+ reason: overdue.catchUp ? CATCH_UP_REASON : `missed while ngn was not running; ${rule.words}`,
2612
+ source: "policy"
2613
+ }
2614
+ });
2615
+ return;
2616
+ }
2617
+ const latest = rule.latest(now, {
2618
+ anchor: row.anchor,
2619
+ source: row.source,
2620
+ failedInARow: await this.failedInARow()
2621
+ });
2622
+ if (latest != null && row.next_at > latest) {
2623
+ await this.write({
2624
+ event: "reclamped",
2625
+ at: now,
2626
+ set: { next_at: latest, reason: `further off than ${rule.words} allows`, source: "policy" }
2627
+ });
2628
+ }
2629
+ }
2630
+ async loop() {
2631
+ while (!this.stopping) {
2632
+ const before = this.writes;
2633
+ try {
2634
+ if (!await this.step()) await this.pause(WAIT_STEP_MS);
2635
+ } catch (error) {
2636
+ this.options.onProblem(`schedule of ${this.path}: ${errorSummary(error)}`);
2637
+ await this.pause(WAIT_STEP_MS);
2638
+ }
2639
+ if (this.writes === before) await new Promise((resolve2) => setImmediate(resolve2));
2640
+ }
2641
+ }
2642
+ /** Wait up to one step, or less; returns early when stopping. */
2643
+ pause(ms) {
2644
+ return this.clock.sleep(Math.max(0, Math.min(ms, WAIT_STEP_MS)), this.halt.signal);
2645
+ }
2646
+ /**
2647
+ * One look at the schedule: wait while the next run is still to come (the
2648
+ * row is read again every step, so another process's changes are seen), and
2649
+ * run it once it is due. False when it did neither, so the loop waits a step.
2650
+ */
2651
+ async step() {
2652
+ const row = await this.store.read(this.taskId);
2653
+ if (!row) {
2654
+ await this.reconcile();
2655
+ return this.row != null;
2656
+ }
2657
+ this.row = row;
2658
+ if (row.state === "paused") return false;
2659
+ if (row.state === "running") {
2660
+ const own = [...this.ticks][0];
2661
+ if (own) {
2662
+ await Promise.race([own.done, untilAborted(this.halt.signal)]);
2663
+ return true;
2664
+ }
2665
+ return this.recoverIfAbandoned();
2666
+ }
2667
+ const now = this.clock.now();
2668
+ if (row.next_at == null) return false;
2669
+ if (row.next_at > now) {
2670
+ await this.pause(row.next_at - now);
2671
+ return true;
2672
+ }
2673
+ return this.runDue();
2674
+ }
2675
+ takeLease() {
2676
+ return TaskLease.take(this.task.scheduling.locks, this.path, {
2677
+ ttlMs: this.ttlMs,
2678
+ onLost: (lease) => this.leaseLost(lease)
2679
+ });
2680
+ }
2681
+ /**
2682
+ * The lease for a due run, waiting while another run holds it. The first
2683
+ * time it is busy with the run still waiting, the wait is logged as
2684
+ * `blocked`. Null when stopping, or when the row stops asking for a run now:
2685
+ * another process is running the task, which the loop then follows, or a
2686
+ * person paused it or moved its next run.
2687
+ */
2688
+ async acquire() {
2689
+ while (!this.stopping) {
2690
+ const taken = await this.takeLease();
2691
+ if (!("busy" in taken)) return taken;
2692
+ const row = await this.store.read(this.taskId);
2693
+ if (row?.state !== "waiting" && row?.state !== "blocked") return null;
2694
+ if (row.state === "waiting") {
2695
+ if (row.next_at == null || row.next_at > this.clock.now()) return null;
2696
+ await this.write({
2697
+ event: "blocked",
2698
+ at: this.clock.now(),
2699
+ set: { holder: taken.busy.holder },
2700
+ runId: taken.busy.runId
2701
+ });
2702
+ }
2703
+ await this.pause(WAIT_STEP_MS);
2704
+ }
2705
+ return null;
2706
+ }
2707
+ /**
2708
+ * Run the due run, once this process holds the task. The row is read again
2709
+ * under the lease: another process may have run it in the meantime, which is
2710
+ * what stops two of them starting the same run.
2711
+ */
2712
+ async runDue() {
2713
+ const lease = await this.acquire();
2714
+ if (!lease) return false;
2715
+ let handedOver = false;
2716
+ try {
2717
+ const row = await this.store.read(this.taskId);
2718
+ const now = this.clock.now();
2719
+ if (!row || row.state !== "waiting" && row.state !== "blocked") return false;
2720
+ if (row.next_at == null || row.next_at > now) {
2721
+ if (row.state === "blocked") {
2722
+ await this.write({ event: "superseded", at: now, set: { holder: null } });
2723
+ }
2724
+ return true;
2725
+ }
2726
+ handedOver = true;
2727
+ return await this.runTick(lease, row);
2728
+ } finally {
2729
+ if (!handedOver) await lease.release();
2730
+ }
2731
+ }
2732
+ track(tick) {
2733
+ this.ticks.add(tick);
2734
+ void tick.done.finally(() => this.ticks.delete(tick));
2735
+ }
2736
+ /**
2737
+ * A scheduled tick, from `started` to `decided`, holding `lease` throughout.
2738
+ * False when it could not start.
2739
+ */
2740
+ async runTick(lease, row) {
2741
+ const now = this.clock.now();
2742
+ const rule = this.rule();
2743
+ const catchUp = this.resolved.kind === "interval" && row.reason === CATCH_UP_REASON;
2744
+ const tick = new TickHandle(catchUp ? "catch-up" : "scheduled", lease);
2745
+ let plannedAt = catchUp ? now : row.next_at;
2746
+ const late = rule.window(plannedAt, row.anchor).nextAt;
2747
+ if (late != null && late <= now) plannedAt = now;
2748
+ let settle;
2749
+ tick.done = new Promise((resolve2) => settle = resolve2);
2750
+ this.track(tick);
2751
+ try {
2752
+ const started = await this.write({
2753
+ event: "started",
2754
+ at: now,
2755
+ origin: tick.origin,
2756
+ set: { holder: lease.holder }
2757
+ });
2758
+ if (!started) return false;
2759
+ tick.logged = true;
2760
+ const window = rule.window(plannedAt, row.anchor);
2761
+ const previousDelayMs = row.next_at != null ? Math.max(0, row.next_at - row.updated_at) : null;
2762
+ const next = this.resolved.kind === "interval" ? { previousDelayMs, timeoutMs: this.options.nextTimeoutMs } : void 0;
2763
+ const outcome = await Promise.race([this.execute(tick, window, next), tick.whenClosed]);
2764
+ if (!outcome || tick.closed || lease.lost) return true;
2765
+ this.refreshTiming();
2766
+ const decision = this.rule().decide({
2767
+ plannedAt,
2768
+ endedAt: outcome.endedAt,
2769
+ anchor: row.anchor,
2770
+ status: outcome.status,
2771
+ failedInARow: await this.failedInARow(outcome),
2772
+ choice: outcome.next
2773
+ });
2774
+ await this.writeEnd({
2775
+ event: "decided",
2776
+ at: this.clock.now(),
2777
+ holder: lease.holder,
2778
+ set: {
2779
+ next_at: decision.nextAt,
2780
+ reason: decision.reason,
2781
+ source: decision.source,
2782
+ holder: null,
2783
+ anchor: decision.anchor
2784
+ },
2785
+ firstRunId: outcome.firstRunId,
2786
+ runId: outcome.runId,
2787
+ finalRunId: outcome.runId
2788
+ });
2789
+ return true;
2790
+ } finally {
2791
+ await lease.release();
2792
+ settle();
2793
+ }
2794
+ }
2795
+ async execute(tick, window, next) {
2796
+ const outcome = await this.task.executeTick({
2797
+ next,
2798
+ scheduledAt: window.scheduledAt,
2799
+ nextAt: window.nextAt,
2800
+ periodMs: window.periodMs,
2801
+ clock: this.clock,
2802
+ signal: tick.signal,
2803
+ onRunStarted: tick.noteRun
2804
+ });
2805
+ if (outcome.status === "failure") this.options.onRunError(outcome.error);
2806
+ return outcome;
2807
+ }
2808
+ /**
2809
+ * Take up a change to the task's `timing` export, which its latest run read
2810
+ * afresh: it applies from this decision on. A switch between cron and an
2811
+ * interval needs a restart.
2812
+ */
2813
+ refreshTiming() {
2814
+ if (this.options.override) return;
2815
+ const exported = this.task.timing;
2816
+ if (exported === this.exported) return;
2817
+ try {
2818
+ const resolved = resolveTiming(exported, this.path);
2819
+ if (resolved.kind !== this.resolved.kind) {
2820
+ this.options.onProblem(
2821
+ `${this.path} changed its timing between a cron pattern and an interval; restart ngn run to apply it`
2822
+ );
2823
+ return;
2824
+ }
2825
+ this.resolved = resolved;
2826
+ this.exported = exported;
2827
+ } catch (error) {
2828
+ this.options.onProblem(`${errorSummary(error)}; keeping ${this.rule().words}`);
2829
+ }
2830
+ }
2831
+ /** Run now: one run outside the schedule, through the lease. Resolves once it is under way. */
2832
+ async runNow() {
2833
+ if (this.stopping) return { started: false, reason: "ngn is stopping" };
2834
+ const { periodMs } = this.rule().window(this.clock.now(), this.row?.anchor ?? null);
2835
+ const started = await startManual(
2836
+ {
2837
+ task: this.task,
2838
+ taskId: this.taskId,
2839
+ clock: this.clock,
2840
+ leaseTtlMs: this.ttlMs,
2841
+ window: { nextAt: periodMs == null ? this.nextAt() : null, periodMs },
2842
+ onProblem: this.options.onProblem
2843
+ },
2844
+ (lease) => this.leaseLost(lease)
2845
+ );
2846
+ if ("busy" in started) return { started: false, reason: started.busy };
2847
+ this.track(started.tick);
2848
+ void started.outcome.then((outcome) => {
2849
+ if (outcome.status === "failure") this.options.onRunError(outcome.error);
2850
+ });
2851
+ return { started: true };
2852
+ }
2853
+ /**
2854
+ * What `ngn pause`, `resume` and `next` do, asked of this process (ngnui's
2855
+ * buttons): the same transitions, which the loop then picks up as it would
2856
+ * from the CLI.
2857
+ */
2858
+ control(command) {
2859
+ return controlSchedule(
2860
+ {
2861
+ store: this.store,
2862
+ taskId: this.taskId,
2863
+ name: this.path,
2864
+ pattern: this.resolved.kind === "cron" ? this.resolved.pattern : null,
2865
+ now: this.clock.now()
2866
+ },
2867
+ command
2868
+ );
2869
+ }
2870
+ /** The lease of a tick in progress went: say so in the log while the task is still ours. */
2871
+ leaseLost(lease) {
2872
+ const tick = [...this.ticks].find((t) => t.lease === lease);
2873
+ if (!tick?.logged || tick.closed) return;
2874
+ void this.write({
2875
+ event: "lease_lost",
2876
+ at: this.clock.now(),
2877
+ holder: lease.holder,
2878
+ firstRunId: tick.firstRunId,
2879
+ runId: tick.runId
2880
+ });
2881
+ }
2882
+ /**
2883
+ * A task left `running` by a process that is gone, which its expired lease
2884
+ * says: close the tick's open runs as interrupted and decide the next run as
2885
+ * for a failed tick. False when the task is still held by a live process,
2886
+ * or the recovery could not be written, so the loop waits before looking again.
2887
+ */
2888
+ async recoverIfAbandoned() {
2889
+ const held = await this.task.scheduling.locks.holderOf(taskLeaseName(this.path));
2890
+ if (held && held.expiresAt > Date.now()) return false;
2891
+ const lease = await this.takeLease();
2892
+ if ("busy" in lease) return false;
2893
+ try {
2894
+ const row = await this.store.read(this.taskId);
2895
+ if (row?.state !== "running") return row != null;
2896
+ const at = this.clock.now();
2897
+ const closed = await this.store.closeOpenRuns(
2898
+ this.taskId,
2899
+ "the ngn process running it stopped",
2900
+ { at: Date.now() }
2901
+ );
2902
+ const decision = this.rule().decide({
2903
+ plannedAt: at,
2904
+ endedAt: at,
2905
+ anchor: row.anchor,
2906
+ status: "failure",
2907
+ failedInARow: await this.failedInARow()
2908
+ });
2909
+ const recovered = await this.writeEnd({
2910
+ event: "recovered",
2911
+ at,
2912
+ holder: row.holder,
2913
+ set: {
2914
+ next_at: decision.nextAt,
2915
+ reason: `the process running it stopped; ${decision.reason}`,
2916
+ source: "policy",
2917
+ holder: null,
2918
+ anchor: decision.anchor
2919
+ },
2920
+ firstRunId: closed[0] ?? null,
2921
+ runId: closed.at(-1) ?? null
2922
+ });
2923
+ return recovered != null;
2924
+ } finally {
2925
+ await lease.release();
2926
+ }
2927
+ }
2928
+ /**
2929
+ * Stop starting runs, ask the ones in progress to stop (their signal aborts),
2930
+ * and wait up to `graceMs` for them. A tick that ends in time is decided as
2931
+ * usual; the rest have their open runs closed as failed in `interrupted`,
2932
+ * and an `interrupted` event sets the next run to now.
2933
+ */
2934
+ async stop({ graceMs = STOP_GRACE_MS } = {}) {
2935
+ if (this.stopping) return this.loopDone;
2936
+ this.stopping = true;
2937
+ this.halt.abort();
2938
+ const ticks = [...this.ticks];
2939
+ for (const tick of ticks) tick.controller.abort(new Error("ngn is stopping"));
2940
+ if (ticks.length) {
2941
+ const grace = new AbortController();
2942
+ const settled = await Promise.race([
2943
+ Promise.all(ticks.map((tick) => tick.done)).then(() => true),
2944
+ this.clock.sleep(graceMs, grace.signal).then(() => false)
2945
+ ]);
2946
+ grace.abort();
2947
+ if (!settled) {
2948
+ for (const tick of ticks) if (this.ticks.has(tick)) await this.interrupt(tick);
2949
+ }
2950
+ }
2951
+ await this.loopDone;
2952
+ }
2953
+ async interrupt(tick) {
2954
+ tick.close();
2955
+ const at = this.clock.now();
2956
+ if (tick.firstRunId != null) {
2957
+ await this.store.closeOpenRuns(this.taskId, "ngn stopped before the run ended", {
2958
+ fromRunId: tick.firstRunId,
2959
+ at: Date.now()
2960
+ });
2961
+ }
2962
+ if (tick.logged) {
2963
+ const set = tick.origin === "manual" ? { holder: null } : {
2964
+ holder: null,
2965
+ next_at: at,
2966
+ reason: "ngn stopped before the run ended",
2967
+ source: "policy"
2968
+ };
2969
+ await this.writeEnd({
2970
+ event: "interrupted",
2971
+ at,
2972
+ holder: tick.lease.holder,
2973
+ set,
2974
+ firstRunId: tick.firstRunId,
2975
+ runId: tick.runId
2976
+ });
2977
+ }
2978
+ await tick.lease.release();
2979
+ this.ticks.delete(tick);
2980
+ }
2981
+ /**
2982
+ * Stop without cleaning up, the way a process that died would: no more
2983
+ * steps, no lease renewals, nothing written. For tests standing in for a
2984
+ * crash.
2985
+ */
2986
+ abandon() {
2987
+ this.stopping = true;
2988
+ this.halt.abort();
2989
+ for (const tick of this.ticks) {
2990
+ tick.lease.abandon();
2991
+ tick.close();
2992
+ }
2993
+ }
2994
+ };
1613
2995
 
1614
2996
  // src/runtime/taskLibraryScheduler.ts
1615
2997
  var TaskLibraryScheduler = class {
1616
2998
  taskLibrary;
1617
- onRunError;
2999
+ options;
1618
3000
  scheduledTasks = /* @__PURE__ */ new Map();
1619
3001
  constructor(options) {
1620
3002
  this.taskLibrary = options.taskLibrary;
1621
- this.onRunError = options.onRunError;
3003
+ this.options = options;
1622
3004
  }
1623
3005
  async start() {
1624
3006
  logTaskLibraryScheduler("Starting scheduler");
1625
3007
  const report = { scheduled: [], failed: [] };
1626
3008
  const scheduledTasks = this.taskLibrary.getTasks().map(async (task) => {
1627
3009
  try {
1628
- await this.scheduleTask(task);
1629
- report.scheduled.push({ path: task.taskPath, pattern: task.timingPattern ?? "" });
3010
+ const timing = await this.scheduleTask(task);
3011
+ report.scheduled.push({ path: task.taskPath, timing });
1630
3012
  } catch (error) {
1631
3013
  logTaskLibrarySchedulerError(`Error scheduling task: ${task.taskPath}`);
1632
3014
  logTaskLibrarySchedulerError(error);
@@ -1639,58 +3021,106 @@ var TaskLibraryScheduler = class {
1639
3021
  report.failed.sort(byPath);
1640
3022
  return report;
1641
3023
  }
1642
- async stop() {
3024
+ /**
3025
+ * Stop starting runs, and give the runs in progress up to `graceMs` to end
3026
+ * before closing them as interrupted.
3027
+ */
3028
+ async stop(options = {}) {
1643
3029
  logTaskLibraryScheduler("Stopping scheduler");
1644
- for (const [path, task] of this.scheduledTasks) {
1645
- logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
1646
- task.stop();
1647
- }
3030
+ await Promise.all(
3031
+ [...this.scheduledTasks].map(([path, schedule]) => {
3032
+ logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
3033
+ return schedule.stop(options);
3034
+ })
3035
+ );
1648
3036
  }
3037
+ /** Stop as a process that died would, writing nothing. For tests standing in for a crash. */
3038
+ abandon() {
3039
+ for (const schedule of this.scheduledTasks.values()) schedule.abandon();
3040
+ }
3041
+ /**
3042
+ * Check a loaded task's exports and start its schedule. The timing it is
3043
+ * scheduled on is returned; anything wrong with the exports is thrown,
3044
+ * naming the task, so it fails now rather than on the first tick.
3045
+ */
1649
3046
  async scheduleTask(task) {
1650
3047
  logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
1651
3048
  await task.loadEntry();
1652
- logTaskLibraryScheduler(`Scheduling: ${task.taskPath} with pattern: ${task.timingPattern}`);
1653
- invariant8(task.hasTimingExport(), `timing pattern is missing for ${task.taskPath}`);
1654
- invariant8(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
1655
- invariant8(
1656
- task.timingPattern && this.isCronPatternValid(task.timingPattern),
1657
- `Invalid cron pattern: ${task.timingPattern} for ${task.taskPath}`
3049
+ const timing = this.options.timing ?? task.timing;
3050
+ const resolved = resolveTiming(timing, task.taskPath);
3051
+ invariant7(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
3052
+ task.validateExports();
3053
+ invariant7(
3054
+ resolved.kind === "cron" || !task.retryPolicy.breaker,
3055
+ `retry.breaker for ${task.taskPath}: an interval task does not skip ticks; remove the breaker and use timing.backoff, which slows a failing interval task down`
1658
3056
  );
1659
- const scheduledTask = scheduleTask(task, task.timingPattern, (error) => {
1660
- logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1661
- logTaskLibrarySchedulerError(error);
1662
- this.onRunError?.(task.taskPath, error, failedRunId(error));
3057
+ const next = task.nextHook;
3058
+ invariant7(
3059
+ next == null || typeof next === "function",
3060
+ `next for ${task.taskPath}: expected a function`
3061
+ );
3062
+ invariant7(
3063
+ next == null || resolved.kind === "interval",
3064
+ `next for ${task.taskPath}: only an interval task can choose its next run; a cron task runs on its pattern`
3065
+ );
3066
+ const schedule = new TaskSchedule({
3067
+ task,
3068
+ timing,
3069
+ override: this.options.timing != null,
3070
+ clock: this.options.clock ?? systemClock,
3071
+ leaseTtlMs: this.options.leaseTtlMs,
3072
+ random: this.options.random,
3073
+ nextTimeoutMs: this.options.nextTimeoutMs,
3074
+ onRunError: (error) => {
3075
+ logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
3076
+ logTaskLibrarySchedulerError(error);
3077
+ this.options.onRunError?.(task.taskPath, error, failedRunId(error));
3078
+ },
3079
+ onProblem: (message) => {
3080
+ logTaskLibrarySchedulerError(message);
3081
+ this.options.onScheduleProblem?.(task.taskPath, message);
3082
+ }
1663
3083
  });
1664
- this.scheduledTasks.set(task.taskPath, scheduledTask);
3084
+ await schedule.start();
3085
+ this.scheduledTasks.set(task.taskPath, schedule);
1665
3086
  logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
3087
+ return timing;
1666
3088
  }
1667
- unscheduleTask(task) {
1668
- const existingTask = this.scheduledTasks.get(task.taskPath);
1669
- if (!existingTask) {
1670
- return;
3089
+ /**
3090
+ * Start one run of a scheduled task now, outside its schedule: retries,
3091
+ * budget, locks and hooks all apply, and errors go where a tick's go. It is
3092
+ * not a tick, so the breaker does not hold it back, and it leaves the next
3093
+ * run where it was. Resolves once the run is under way, not when it ends.
3094
+ *
3095
+ * A run of the task still going, in this process or another, is reported
3096
+ * rather than recorded as a skip: the person asking can simply wait.
3097
+ */
3098
+ async runNow(taskPath) {
3099
+ const schedule = this.scheduledTasks.get(taskPath);
3100
+ if (!schedule)
3101
+ return { started: false, reason: `${taskPath} is not scheduled by this ngn run` };
3102
+ return schedule.runNow();
3103
+ }
3104
+ /** Pause, resume or move a scheduled task's next run, as `ngn pause`, `resume` and `next` do. */
3105
+ async control(taskPath, command) {
3106
+ const schedule = this.scheduledTasks.get(taskPath);
3107
+ if (!schedule) {
3108
+ return {
3109
+ applied: false,
3110
+ error: true,
3111
+ message: `${taskPath} is not scheduled by this ngn run`
3112
+ };
1671
3113
  }
3114
+ return schedule.control(command);
3115
+ }
3116
+ async unscheduleTask(task) {
3117
+ const existing = this.scheduledTasks.get(task.taskPath);
3118
+ if (!existing) return;
1672
3119
  logTaskLibraryScheduler(`Unscheduling: ${task.taskPath}`);
1673
- existingTask.stop();
1674
3120
  this.scheduledTasks.delete(task.taskPath);
3121
+ await existing.stop();
1675
3122
  logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
1676
3123
  }
1677
- isCronPatternValid(cronPattern) {
1678
- return isCronPatternValid(cronPattern);
1679
- }
1680
- // startScheduledTask(path: string) {
1681
- // const task = this.scheduledTasks.get(path);
1682
- // if (!task) {
1683
- // return;
1684
- // }
1685
- // task.start();
1686
- // }
1687
- // stopScheduledTask(path: string) {
1688
- // const task = this.scheduledTasks.get(path);
1689
- // if (!task) {
1690
- // return;
1691
- // }
1692
- // task.stop();
1693
- // }
1694
3124
  };
1695
3125
  function byPath(a, b) {
1696
3126
  return a.path.localeCompare(b.path);
@@ -1701,7 +3131,7 @@ function reasonOf(error, path) {
1701
3131
  }
1702
3132
 
1703
3133
  // src/runtime/taskOnceRunner.ts
1704
- import invariant9 from "tiny-invariant";
3134
+ import invariant8 from "tiny-invariant";
1705
3135
  import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
1706
3136
  var TaskOnceRunner = class {
1707
3137
  task;
@@ -1712,7 +3142,7 @@ var TaskOnceRunner = class {
1712
3142
  logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
1713
3143
  await this.task.loadEntry();
1714
3144
  logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
1715
- invariant9(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
3145
+ invariant8(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
1716
3146
  logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
1717
3147
  try {
1718
3148
  await this.task.execute();
@@ -1731,10 +3161,10 @@ import {
1731
3161
  initDbFileIfNotExists,
1732
3162
  isDbPath
1733
3163
  } from "@apisurf/ngn-persistence";
1734
- import invariant10 from "tiny-invariant";
3164
+ import invariant9 from "tiny-invariant";
1735
3165
  var db;
1736
3166
  async function setupDbClient(dbPath) {
1737
- invariant10(isDbPath(dbPath), "Invalid db path");
3167
+ invariant9(isDbPath(dbPath), "Invalid db path");
1738
3168
  if (db) {
1739
3169
  return db;
1740
3170
  }
@@ -1742,7 +3172,7 @@ async function setupDbClient(dbPath) {
1742
3172
  return db;
1743
3173
  }
1744
3174
  function getDbClient() {
1745
- invariant10(db, "DB client not initialized");
3175
+ invariant9(db, "DB client not initialized");
1746
3176
  return db;
1747
3177
  }
1748
3178
 
@@ -1753,8 +3183,8 @@ import {
1753
3183
  logTaskConsoleInfo,
1754
3184
  logTaskConsoleWarn
1755
3185
  } from "@apisurf/ngn-os";
1756
- import invariant11 from "tiny-invariant";
1757
- import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
3186
+ import invariant10 from "tiny-invariant";
3187
+ import { errorSummary as errorSummary2, failureLine as failureLine2 } from "@apisurf/ngn-schema/describe";
1758
3188
 
1759
3189
  // src/db/services/fileTask.ts
1760
3190
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -1943,7 +3373,7 @@ var TaskRunService = class {
1943
3373
  }
1944
3374
  async skipTaskRun(taskRunId, reason = null) {
1945
3375
  await this.db.execute({
1946
- sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id",
3376
+ sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id AND ended_at IS NULL",
1947
3377
  args: { id: taskRunId, ended_at: Date.now(), reason }
1948
3378
  });
1949
3379
  }
@@ -1978,16 +3408,20 @@ var TaskRunService = class {
1978
3408
  }
1979
3409
  async startTaskRun(taskRunId) {
1980
3410
  await this.db.execute({
1981
- sql: "UPDATE task_runs SET started_at = :started_at, status = 'running' WHERE id = :id",
3411
+ sql: "UPDATE task_runs SET started_at = :started_at, status = 'running' WHERE id = :id AND ended_at IS NULL",
1982
3412
  args: {
1983
3413
  started_at: Date.now(),
1984
3414
  id: taskRunId
1985
3415
  }
1986
3416
  });
1987
3417
  }
3418
+ /**
3419
+ * Record how a run ended. False when it already had an end: ngn closed it as
3420
+ * interrupted while it was still going, and that record stands.
3421
+ */
1988
3422
  async endTaskRun(taskRunId, status, result = null) {
1989
- await this.db.execute({
1990
- sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id",
3423
+ const { rowsAffected } = await this.db.execute({
3424
+ sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id AND ended_at IS NULL",
1991
3425
  args: {
1992
3426
  ended_at: Date.now(),
1993
3427
  status,
@@ -1995,6 +3429,7 @@ var TaskRunService = class {
1995
3429
  id: taskRunId
1996
3430
  }
1997
3431
  });
3432
+ return rowsAffected > 0;
1998
3433
  }
1999
3434
  async getById(taskId) {
2000
3435
  const { rows } = await this.db.execute({
@@ -2137,6 +3572,27 @@ var LockService = class {
2137
3572
  });
2138
3573
  return rowsAffected > 0;
2139
3574
  }
3575
+ /** Who holds `name`, and until when; null when nobody has it. Expired leases included. */
3576
+ async holderOf(name) {
3577
+ const { rows } = await this.db.execute({
3578
+ sql: "SELECT holder, task_run_id, expires_at FROM locks WHERE name = ?",
3579
+ args: [name]
3580
+ });
3581
+ const row = rows[0];
3582
+ if (!row) return null;
3583
+ return {
3584
+ holder: row.holder,
3585
+ taskRunId: row.task_run_id ?? null,
3586
+ expiresAt: row.expires_at
3587
+ };
3588
+ }
3589
+ /** Record which run a held lease is working for. */
3590
+ async setRun(name, holder, taskRunId) {
3591
+ await this.db.execute({
3592
+ sql: "UPDATE locks SET task_run_id = :taskRunId WHERE name = :name AND holder = :holder",
3593
+ args: { name, holder, taskRunId }
3594
+ });
3595
+ }
2140
3596
  async release(name, holder) {
2141
3597
  await this.db.execute({
2142
3598
  sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
@@ -2150,9 +3606,10 @@ async function recordSafely(runId, write) {
2150
3606
  try {
2151
3607
  await write();
2152
3608
  } catch (error) {
2153
- logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary(error)}`);
3609
+ logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary2(error)}`);
2154
3610
  }
2155
3611
  }
3612
+ var lateEnd = (outcome) => `finished after ngn closed it as interrupted: ${outcome}`;
2156
3613
  function createControlsGenerator({
2157
3614
  dbClient,
2158
3615
  env,
@@ -2163,12 +3620,13 @@ function createControlsGenerator({
2163
3620
  const kvService = new KVService(dbClient);
2164
3621
  const timingService = new TimingService(dbClient);
2165
3622
  const lockService = new LockService(dbClient);
3623
+ const scheduling = { store: new ScheduleStore(dbClient), locks: lockService };
2166
3624
  const generateControls = ({ sourcePath, compiledPath }) => {
2167
3625
  let fileTaskId;
2168
3626
  let fileTaskVersionId;
2169
3627
  const assertMandatoryIds = () => {
2170
- invariant11(!!fileTaskId, "Task ID not found");
2171
- invariant11(!!fileTaskVersionId, "Task version ID not found");
3628
+ invariant10(!!fileTaskId, "Task ID not found");
3629
+ invariant10(!!fileTaskVersionId, "Task version ID not found");
2172
3630
  };
2173
3631
  const taskCallbacks = {
2174
3632
  async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
@@ -2212,8 +3670,12 @@ function createControlsGenerator({
2212
3670
  },
2213
3671
  async onFailure({ path, stage, error }) {
2214
3672
  await recordSafely(runId, async () => {
2215
- await taskRunService.endTaskRun(runId, "failure");
2216
- await logService.logError(taskId, runId, failureLine(stage, error));
3673
+ if (!await taskRunService.endTaskRun(runId, "failure")) {
3674
+ await logService.logError(taskId, runId, failureLine2(stage, error));
3675
+ await logService.logWarning(taskId, runId, lateEnd("failed"));
3676
+ return;
3677
+ }
3678
+ await logService.logError(taskId, runId, failureLine2(stage, error));
2217
3679
  await logService.logError(taskId, runId, "Task failed");
2218
3680
  });
2219
3681
  logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
@@ -2222,13 +3684,16 @@ function createControlsGenerator({
2222
3684
  async onHookError({ path, stage, error }) {
2223
3685
  await recordSafely(
2224
3686
  runId,
2225
- () => logService.logError(taskId, runId, failureLine(stage, error))
3687
+ () => logService.logError(taskId, runId, failureLine2(stage, error))
2226
3688
  );
2227
3689
  logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
2228
3690
  report?.({ path, runId, stage, error, fatal: false });
2229
3691
  },
2230
3692
  async onSuccess({ result }) {
2231
- await taskRunService.endTaskRun(runId, "success", serializeResult(result));
3693
+ if (!await taskRunService.endTaskRun(runId, "success", serializeResult(result))) {
3694
+ await logService.logWarning(taskId, runId, lateEnd("succeeded"));
3695
+ return;
3696
+ }
2232
3697
  await logService.logInfo(taskId, runId, "Task succeeded");
2233
3698
  logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
2234
3699
  }
@@ -2273,6 +3738,7 @@ function createControlsGenerator({
2273
3738
  return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2274
3739
  }
2275
3740
  },
3741
+ scheduling,
2276
3742
  env: env ?? {},
2277
3743
  // Scoped to this task's own folder — see createTaskSqlite
2278
3744
  sqlite: createTaskSqlite(sourcePath),
@@ -2295,11 +3761,14 @@ export {
2295
3761
  Compiler,
2296
3762
  LockBusyError,
2297
3763
  Queue,
3764
+ STOP_GRACE_MS,
3765
+ ScheduleStore,
2298
3766
  Task,
2299
3767
  TaskLibrary,
2300
3768
  TaskLibraryScheduler,
2301
3769
  TaskOnceRunner,
2302
3770
  closeTaskDatabases,
3771
+ controlSchedule,
2303
3772
  createControlsGenerator,
2304
3773
  executeEsm,
2305
3774
  failedRunId,
@@ -2307,7 +3776,10 @@ export {
2307
3776
  initDbFileIfNotExists,
2308
3777
  isCronPatternValid,
2309
3778
  releaseTaskDatabases,
3779
+ resolveTiming,
2310
3780
  runFailure,
2311
- scheduleTask,
2312
- setupDbClient
3781
+ runOnce,
3782
+ setupDbClient,
3783
+ systemClock,
3784
+ timingFromFlag
2313
3785
  };