@apisurf/ngn-core 0.4.0 → 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 +461 -80
  2. package/dist/core.js +1627 -192
  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,18 +374,21 @@ 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
- } };
391
+ return { signal: controller.signal, dispose: detach };
387
392
  }
388
393
  let timer;
389
394
  const arm = () => {
@@ -393,7 +398,13 @@ function deadlineSignal(deadline) {
393
398
  timer.unref();
394
399
  };
395
400
  arm();
396
- return { signal: controller.signal, dispose: () => clearTimeout(timer) };
401
+ return {
402
+ signal: controller.signal,
403
+ dispose: () => {
404
+ clearTimeout(timer);
405
+ detach();
406
+ }
407
+ };
397
408
  }
398
409
  var MAX_TIMER_MS = 2 ** 31 - 1;
399
410
 
@@ -408,10 +419,14 @@ var LockBusyError = class extends Error {
408
419
  var DEFAULT_TTL_MS = 6e4;
409
420
  var MAX_POLL_MS = 1e3;
410
421
  var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
422
+ var RESERVED_LOCK_PREFIX = "ngn:";
411
423
  function assertLockName(name) {
412
424
  if (typeof name !== "string" || !name.trim()) {
413
425
  throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
414
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
+ }
415
430
  }
416
431
  function createRunLocks(service, taskRunId) {
417
432
  async function tryHold(name, ttlMs) {
@@ -484,6 +499,11 @@ function exclusiveNames(value, taskPath) {
484
499
  `Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
485
500
  );
486
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
+ }
487
507
  }
488
508
  return names;
489
509
  }
@@ -1183,8 +1203,157 @@ function breakerSkips(failedInARow, breaker) {
1183
1203
  return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
1184
1204
  }
1185
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
+
1186
1350
  // src/runtime/task.ts
1187
- 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
+ }
1188
1357
  var failedRuns = /* @__PURE__ */ new WeakMap();
1189
1358
  function failedRunId(error) {
1190
1359
  return runFailure(error)?.runId ?? null;
@@ -1200,10 +1369,9 @@ var Task = class {
1200
1369
  kv;
1201
1370
  sqlite;
1202
1371
  meta;
1372
+ schedulingServices;
1203
1373
  entry = null;
1204
1374
  entryExports = null;
1205
- /** Executions started and not yet settled. */
1206
- inFlight = 0;
1207
1375
  constructor({
1208
1376
  entryParams,
1209
1377
  buildConfigFn
@@ -1212,13 +1380,14 @@ var Task = class {
1212
1380
  this.entry = new Entry(entryParams, {
1213
1381
  onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
1214
1382
  });
1215
- const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
1383
+ const { taskCallbacks, startRun, history, env, kv, sqlite, scheduling } = buildConfigFn({
1216
1384
  sourcePath: this.entry.paths.source,
1217
1385
  compiledPath: this.entry.paths.compiled
1218
1386
  });
1219
1387
  this.taskCallbacks = taskCallbacks;
1220
1388
  this.startRun = startRun;
1221
1389
  this.history = history;
1390
+ this.schedulingServices = scheduling;
1222
1391
  this.sqlite = sqlite;
1223
1392
  this.meta = {
1224
1393
  fileTaskId: -1,
@@ -1248,48 +1417,52 @@ var Task = class {
1248
1417
  invariant5(this.entry, "Entry not initialized.");
1249
1418
  return this.entry.codeHash;
1250
1419
  }
1251
- get timingPattern() {
1420
+ /** The `timing` export, as the latest run (or `loadEntry`) read it. */
1421
+ get timing() {
1252
1422
  invariant5(this.entryExports, "Entry exports not initialized.");
1253
1423
  return this.entryExports.timing;
1254
1424
  }
1255
- /**
1256
- * The task's `overlap` export, `"skip"` when it has none. Throws on anything
1257
- * else, naming the task, so a typo fails at schedule time.
1258
- */
1259
- get overlapPolicy() {
1425
+ /** The `next` export, as loaded: unchecked, for whoever schedules the task to check. */
1426
+ get nextHook() {
1260
1427
  invariant5(this.entryExports, "Entry exports not initialized.");
1261
- const { overlap = "skip" } = this.entryExports;
1262
- invariant5(
1263
- overlap === "skip" || overlap === "allow",
1264
- `Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
1265
- );
1266
- return overlap;
1428
+ return this.entryExports.next;
1267
1429
  }
1268
1430
  /**
1269
1431
  * Check the exports that shape how the task is scheduled, so a bad value
1270
- * 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.
1271
1434
  */
1272
1435
  validateExports() {
1273
1436
  invariant5(this.entryExports, "Entry exports not initialized.");
1274
- void this.overlapPolicy;
1275
- 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
+ );
1276
1446
  if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
1277
1447
  exclusiveNames(exclusive, this.taskPath);
1278
1448
  resolveRetry(retry, this.taskPath);
1279
1449
  }
1280
- /** Whether an execution of this task is in progress. */
1281
- get isRunning() {
1282
- 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;
1283
1458
  }
1284
1459
  /**
1285
- * Record a run that ngn decided not to start, without loading or calling any
1286
- * of the task's code. Needs the task to have been registered by an earlier
1287
- * 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.
1288
1462
  */
1289
- async skip(reason) {
1290
- invariant5(this.entry, "Entry not initialized.");
1291
- const run = await this.startRun();
1292
- await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
1463
+ async register() {
1464
+ await this.loadAndRegister();
1465
+ return this.meta.fileTaskId;
1293
1466
  }
1294
1467
  hasTaskExport() {
1295
1468
  return this.entryExports?.task !== void 0;
@@ -1360,31 +1533,106 @@ var Task = class {
1360
1533
  }
1361
1534
  }
1362
1535
  /**
1363
- * Run the task for one tick, retrying as its `retry` export allows. A
1364
- * scheduled tick passes when it fired and when the next one is due, which
1365
- * `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.
1366
1547
  */
1367
1548
  async execute(schedule = {}) {
1368
- this.inFlight++;
1369
- try {
1370
- return await this.executeWithRetries(schedule);
1371
- } finally {
1372
- this.inFlight--;
1373
- }
1549
+ const outcome = await this.executeTick(schedule);
1550
+ if (outcome.status === "failure") throw outcome.error;
1551
+ return outcome.result;
1374
1552
  }
1375
1553
  async executeWithRetries(schedule) {
1376
- const tick2 = {
1554
+ const tick = {
1377
1555
  firstRunId: null,
1556
+ runId: null,
1378
1557
  retry: resolveRetry(void 0, this.taskPath),
1379
- failedContext: null
1558
+ failedContext: null,
1559
+ final: null
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
1380
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
+ });
1381
1617
  for (let attempt = 1; ; attempt++) {
1382
- tick2.failedContext = null;
1618
+ tick.failedContext = null;
1619
+ if (schedule.signal?.aborted) {
1620
+ return outcome(attempt - 1, { status: "failure", error: schedule.signal.reason });
1621
+ }
1383
1622
  try {
1384
- 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 });
1385
1625
  } catch (error) {
1386
- if (!await this.shouldRetry(error, attempt, schedule, tick2)) throw error;
1387
- 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 });
1388
1636
  }
1389
1637
  }
1390
1638
  }
@@ -1393,10 +1641,19 @@ var Task = class {
1393
1641
  * fails the same way every time. Then it has to fit before the next tick,
1394
1642
  * and the task's `shouldRetry` gets the last word.
1395
1643
  */
1396
- async shouldRetry(error, attempt, { nextAt = null }, tick2) {
1397
- 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;
1398
1647
  const { shouldRetry } = this.entryExports ?? {};
1399
- 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));
1400
1657
  }
1401
1658
  /**
1402
1659
  * Whether the breaker holds this tick back: after `after` failed ticks in a
@@ -1406,11 +1663,7 @@ var Task = class {
1406
1663
  async breakerOpen(retry) {
1407
1664
  if (!retry.breaker) return false;
1408
1665
  const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
1409
- const failedInARow = outcomes.findIndex((tick2) => tick2.outcome !== "failure");
1410
- const wanted = breakerSkips(
1411
- failedInARow === -1 ? outcomes.length : failedInARow,
1412
- retry.breaker
1413
- );
1666
+ const wanted = breakerSkips(streakOf(outcomes), retry.breaker);
1414
1667
  if (wanted === 0) return false;
1415
1668
  return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
1416
1669
  }
@@ -1450,14 +1703,17 @@ var Task = class {
1450
1703
  * Load the task file, open the attempt's `task_runs` row, then evaluate the
1451
1704
  * file's code; what that throws is recorded on the row as failed in `load`.
1452
1705
  */
1453
- async openRun(tick2) {
1706
+ async openRun(tick, onRunStarted) {
1454
1707
  invariant5(this.entry, "Entry not initialized.");
1708
+ tick.final = null;
1455
1709
  await this.loadAndRegister();
1456
1710
  const meta = { ...this.meta };
1457
1711
  const path = this.entry.paths.relativeEntry;
1458
- const run = await this.startRun({ retryOf: tick2.firstRunId });
1459
- const firstAttempt = tick2.firstRunId == null;
1460
- 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);
1461
1717
  try {
1462
1718
  return { run, meta, path, firstAttempt, ...await this.readExports(path) };
1463
1719
  } catch (error) {
@@ -1471,12 +1727,20 @@ var Task = class {
1471
1727
  * cannot be loaded at all, or a run row that cannot be written, fails before
1472
1728
  * there is a row to record it on.
1473
1729
  */
1474
- async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
1475
- const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick2);
1476
- tick2.retry = retry;
1477
- const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
1478
- 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);
1479
1742
  const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
1743
+ tick.final = { run, path, context: taskContext, exports };
1480
1744
  let stage = "start";
1481
1745
  let exclusiveHeld = null;
1482
1746
  try {
@@ -1488,25 +1752,25 @@ var Task = class {
1488
1752
  });
1489
1753
  if ("reason" in gate) {
1490
1754
  await run.callbacks.onSkip?.({ path, reason: gate.reason });
1491
- return;
1755
+ return { status: "skipped", reason: gate.reason };
1492
1756
  }
1493
1757
  exclusiveHeld = gate.held;
1494
1758
  stage = "shouldSkip";
1495
1759
  if (await exports.shouldSkip?.(taskContext)) {
1496
1760
  await run.callbacks.onSkip?.({ path });
1497
- return;
1761
+ return { status: "skipped", reason: null };
1498
1762
  }
1499
1763
  stage = "start";
1500
1764
  await run.callbacks.onStart?.({ path });
1501
1765
  stage = "task";
1502
- tick2.failedContext = taskContext;
1766
+ tick.failedContext = taskContext;
1503
1767
  const value = await task(taskContext);
1504
- tick2.failedContext = null;
1768
+ tick.failedContext = null;
1505
1769
  stage = "record";
1506
1770
  await run.callbacks.onSuccess?.({ path, result: value });
1507
1771
  await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
1508
1772
  await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
1509
- return value;
1773
+ return { status: "success", result: value };
1510
1774
  } catch (error) {
1511
1775
  await this.failRun(run, stage, error);
1512
1776
  if (stage === "task" || stage === "record") {
@@ -1579,61 +1843,1172 @@ var TaskLibrary = class {
1579
1843
  };
1580
1844
 
1581
1845
  // src/runtime/taskLibraryScheduler.ts
1582
- import invariant8 from "tiny-invariant";
1846
+ import invariant7 from "tiny-invariant";
1583
1847
  import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
1584
1848
 
1585
- // src/runtime/schedule.ts
1586
- import { Cron } from "croner";
1587
- import invariant7 from "tiny-invariant";
1588
- var CRON_OPTIONS = { mode: "5-or-6-parts" };
1589
- function isCronPatternValid(pattern) {
1590
- try {
1591
- new Cron(pattern, { ...CRON_OPTIONS, paused: true }).stop();
1592
- return true;
1593
- } catch {
1594
- 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;
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);
1595
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;
1596
2017
  }
1597
- async function tick(task, onError, schedule = {}) {
1598
- try {
1599
- if (task.isRunning && task.overlapPolicy === "skip") {
1600
- await task.skip("previous run still in progress");
1601
- 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
+ };
1602
2119
  }
1603
- await task.execute(schedule);
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 };
2125
+ }
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}`);
1604
2422
  } catch (error) {
1605
- onError(error);
1606
- }
1607
- }
1608
- function scheduleTask(task, pattern, onError) {
1609
- invariant7(isCronPatternValid(pattern), `Invalid cron pattern: ${pattern} for ${task.taskPath}`);
1610
- task.validateExports();
1611
- return new Cron(
1612
- pattern,
1613
- CRON_OPTIONS,
1614
- (job) => tick(task, onError, {
1615
- scheduledAt: job.currentRun()?.getTime() ?? Date.now(),
1616
- nextAt: job.nextRun()?.getTime() ?? null
1617
- })
1618
- );
2423
+ onProblem(
2424
+ `schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
2425
+ );
2426
+ }
2427
+ return null;
1619
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;
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
+ };
1620
2995
 
1621
2996
  // src/runtime/taskLibraryScheduler.ts
1622
2997
  var TaskLibraryScheduler = class {
1623
2998
  taskLibrary;
1624
- onRunError;
2999
+ options;
1625
3000
  scheduledTasks = /* @__PURE__ */ new Map();
1626
3001
  constructor(options) {
1627
3002
  this.taskLibrary = options.taskLibrary;
1628
- this.onRunError = options.onRunError;
3003
+ this.options = options;
1629
3004
  }
1630
3005
  async start() {
1631
3006
  logTaskLibraryScheduler("Starting scheduler");
1632
3007
  const report = { scheduled: [], failed: [] };
1633
3008
  const scheduledTasks = this.taskLibrary.getTasks().map(async (task) => {
1634
3009
  try {
1635
- await this.scheduleTask(task);
1636
- report.scheduled.push({ path: task.taskPath, pattern: task.timingPattern ?? "" });
3010
+ const timing = await this.scheduleTask(task);
3011
+ report.scheduled.push({ path: task.taskPath, timing });
1637
3012
  } catch (error) {
1638
3013
  logTaskLibrarySchedulerError(`Error scheduling task: ${task.taskPath}`);
1639
3014
  logTaskLibrarySchedulerError(error);
@@ -1646,88 +3021,106 @@ var TaskLibraryScheduler = class {
1646
3021
  report.failed.sort(byPath);
1647
3022
  return report;
1648
3023
  }
1649
- 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 = {}) {
1650
3029
  logTaskLibraryScheduler("Stopping scheduler");
1651
- for (const [path, task] of this.scheduledTasks) {
1652
- logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
1653
- task.stop();
1654
- }
3030
+ await Promise.all(
3031
+ [...this.scheduledTasks].map(([path, schedule]) => {
3032
+ logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
3033
+ return schedule.stop(options);
3034
+ })
3035
+ );
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();
1655
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
+ */
1656
3046
  async scheduleTask(task) {
1657
3047
  logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
1658
3048
  await task.loadEntry();
1659
- logTaskLibraryScheduler(`Scheduling: ${task.taskPath} with pattern: ${task.timingPattern}`);
1660
- invariant8(task.hasTimingExport(), `timing pattern is missing for ${task.taskPath}`);
1661
- invariant8(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
1662
- invariant8(
1663
- task.timingPattern && this.isCronPatternValid(task.timingPattern),
1664
- `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`
3056
+ );
3057
+ const next = task.nextHook;
3058
+ invariant7(
3059
+ next == null || typeof next === "function",
3060
+ `next for ${task.taskPath}: expected a function`
1665
3061
  );
1666
- const scheduledTask = scheduleTask(task, task.timingPattern, (error) => {
1667
- logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1668
- logTaskLibrarySchedulerError(error);
1669
- this.onRunError?.(task.taskPath, error, failedRunId(error));
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
+ }
1670
3083
  });
1671
- this.scheduledTasks.set(task.taskPath, scheduledTask);
3084
+ await schedule.start();
3085
+ this.scheduledTasks.set(task.taskPath, schedule);
1672
3086
  logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
3087
+ return timing;
1673
3088
  }
1674
3089
  /**
1675
- * Start one run of a scheduled task now, outside its schedule, the way a tick
1676
- * would: retries, budget, locks and hooks all apply, and errors go where a
1677
- * tick's go. It is not a tick, so the breaker does not hold it back. Resolves
1678
- * once the run is under way, not when it ends: a run can take longer than
1679
- * anyone waits on a button.
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.
1680
3094
  *
1681
- * A run still going is reported rather than recorded as a skip, since nothing
1682
- * was due: the person asking can simply wait.
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.
1683
3097
  */
1684
- runNow(taskPath) {
1685
- const task = this.taskLibrary.getTasks().find((t) => t.taskPath === taskPath);
1686
- const cron = this.scheduledTasks.get(taskPath);
1687
- if (!task || !cron) {
3098
+ async runNow(taskPath) {
3099
+ const schedule = this.scheduledTasks.get(taskPath);
3100
+ if (!schedule)
1688
3101
  return { started: false, reason: `${taskPath} is not scheduled by this ngn run` };
1689
- }
1690
- if (task.isRunning && task.overlapPolicy === "skip") {
1691
- return { started: false, reason: "a run of it is still going" };
1692
- }
1693
- void tick(
1694
- task,
1695
- (error) => {
1696
- logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
1697
- logTaskLibrarySchedulerError(error);
1698
- this.onRunError?.(task.taskPath, error, failedRunId(error));
1699
- },
1700
- { scheduledAt: null, nextAt: cron.nextRun()?.getTime() ?? null }
1701
- );
1702
- return { started: true };
3102
+ return schedule.runNow();
1703
3103
  }
1704
- unscheduleTask(task) {
1705
- const existingTask = this.scheduledTasks.get(task.taskPath);
1706
- if (!existingTask) {
1707
- return;
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
+ };
1708
3113
  }
3114
+ return schedule.control(command);
3115
+ }
3116
+ async unscheduleTask(task) {
3117
+ const existing = this.scheduledTasks.get(task.taskPath);
3118
+ if (!existing) return;
1709
3119
  logTaskLibraryScheduler(`Unscheduling: ${task.taskPath}`);
1710
- existingTask.stop();
1711
3120
  this.scheduledTasks.delete(task.taskPath);
3121
+ await existing.stop();
1712
3122
  logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
1713
3123
  }
1714
- isCronPatternValid(cronPattern) {
1715
- return isCronPatternValid(cronPattern);
1716
- }
1717
- // startScheduledTask(path: string) {
1718
- // const task = this.scheduledTasks.get(path);
1719
- // if (!task) {
1720
- // return;
1721
- // }
1722
- // task.start();
1723
- // }
1724
- // stopScheduledTask(path: string) {
1725
- // const task = this.scheduledTasks.get(path);
1726
- // if (!task) {
1727
- // return;
1728
- // }
1729
- // task.stop();
1730
- // }
1731
3124
  };
1732
3125
  function byPath(a, b) {
1733
3126
  return a.path.localeCompare(b.path);
@@ -1738,7 +3131,7 @@ function reasonOf(error, path) {
1738
3131
  }
1739
3132
 
1740
3133
  // src/runtime/taskOnceRunner.ts
1741
- import invariant9 from "tiny-invariant";
3134
+ import invariant8 from "tiny-invariant";
1742
3135
  import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
1743
3136
  var TaskOnceRunner = class {
1744
3137
  task;
@@ -1749,7 +3142,7 @@ var TaskOnceRunner = class {
1749
3142
  logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
1750
3143
  await this.task.loadEntry();
1751
3144
  logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
1752
- 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}`);
1753
3146
  logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
1754
3147
  try {
1755
3148
  await this.task.execute();
@@ -1768,10 +3161,10 @@ import {
1768
3161
  initDbFileIfNotExists,
1769
3162
  isDbPath
1770
3163
  } from "@apisurf/ngn-persistence";
1771
- import invariant10 from "tiny-invariant";
3164
+ import invariant9 from "tiny-invariant";
1772
3165
  var db;
1773
3166
  async function setupDbClient(dbPath) {
1774
- invariant10(isDbPath(dbPath), "Invalid db path");
3167
+ invariant9(isDbPath(dbPath), "Invalid db path");
1775
3168
  if (db) {
1776
3169
  return db;
1777
3170
  }
@@ -1779,7 +3172,7 @@ async function setupDbClient(dbPath) {
1779
3172
  return db;
1780
3173
  }
1781
3174
  function getDbClient() {
1782
- invariant10(db, "DB client not initialized");
3175
+ invariant9(db, "DB client not initialized");
1783
3176
  return db;
1784
3177
  }
1785
3178
 
@@ -1790,8 +3183,8 @@ import {
1790
3183
  logTaskConsoleInfo,
1791
3184
  logTaskConsoleWarn
1792
3185
  } from "@apisurf/ngn-os";
1793
- import invariant11 from "tiny-invariant";
1794
- 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";
1795
3188
 
1796
3189
  // src/db/services/fileTask.ts
1797
3190
  import { $fileTaskInsert } from "@apisurf/ngn-schema";
@@ -1980,7 +3373,7 @@ var TaskRunService = class {
1980
3373
  }
1981
3374
  async skipTaskRun(taskRunId, reason = null) {
1982
3375
  await this.db.execute({
1983
- 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",
1984
3377
  args: { id: taskRunId, ended_at: Date.now(), reason }
1985
3378
  });
1986
3379
  }
@@ -2015,16 +3408,20 @@ var TaskRunService = class {
2015
3408
  }
2016
3409
  async startTaskRun(taskRunId) {
2017
3410
  await this.db.execute({
2018
- 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",
2019
3412
  args: {
2020
3413
  started_at: Date.now(),
2021
3414
  id: taskRunId
2022
3415
  }
2023
3416
  });
2024
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
+ */
2025
3422
  async endTaskRun(taskRunId, status, result = null) {
2026
- await this.db.execute({
2027
- 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",
2028
3425
  args: {
2029
3426
  ended_at: Date.now(),
2030
3427
  status,
@@ -2032,6 +3429,7 @@ var TaskRunService = class {
2032
3429
  id: taskRunId
2033
3430
  }
2034
3431
  });
3432
+ return rowsAffected > 0;
2035
3433
  }
2036
3434
  async getById(taskId) {
2037
3435
  const { rows } = await this.db.execute({
@@ -2174,6 +3572,27 @@ var LockService = class {
2174
3572
  });
2175
3573
  return rowsAffected > 0;
2176
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
+ }
2177
3596
  async release(name, holder) {
2178
3597
  await this.db.execute({
2179
3598
  sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
@@ -2187,9 +3606,10 @@ async function recordSafely(runId, write) {
2187
3606
  try {
2188
3607
  await write();
2189
3608
  } catch (error) {
2190
- logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary(error)}`);
3609
+ logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary2(error)}`);
2191
3610
  }
2192
3611
  }
3612
+ var lateEnd = (outcome) => `finished after ngn closed it as interrupted: ${outcome}`;
2193
3613
  function createControlsGenerator({
2194
3614
  dbClient,
2195
3615
  env,
@@ -2200,12 +3620,13 @@ function createControlsGenerator({
2200
3620
  const kvService = new KVService(dbClient);
2201
3621
  const timingService = new TimingService(dbClient);
2202
3622
  const lockService = new LockService(dbClient);
3623
+ const scheduling = { store: new ScheduleStore(dbClient), locks: lockService };
2203
3624
  const generateControls = ({ sourcePath, compiledPath }) => {
2204
3625
  let fileTaskId;
2205
3626
  let fileTaskVersionId;
2206
3627
  const assertMandatoryIds = () => {
2207
- invariant11(!!fileTaskId, "Task ID not found");
2208
- invariant11(!!fileTaskVersionId, "Task version ID not found");
3628
+ invariant10(!!fileTaskId, "Task ID not found");
3629
+ invariant10(!!fileTaskVersionId, "Task version ID not found");
2209
3630
  };
2210
3631
  const taskCallbacks = {
2211
3632
  async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
@@ -2249,8 +3670,12 @@ function createControlsGenerator({
2249
3670
  },
2250
3671
  async onFailure({ path, stage, error }) {
2251
3672
  await recordSafely(runId, async () => {
2252
- await taskRunService.endTaskRun(runId, "failure");
2253
- 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));
2254
3679
  await logService.logError(taskId, runId, "Task failed");
2255
3680
  });
2256
3681
  logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
@@ -2259,13 +3684,16 @@ function createControlsGenerator({
2259
3684
  async onHookError({ path, stage, error }) {
2260
3685
  await recordSafely(
2261
3686
  runId,
2262
- () => logService.logError(taskId, runId, failureLine(stage, error))
3687
+ () => logService.logError(taskId, runId, failureLine2(stage, error))
2263
3688
  );
2264
3689
  logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
2265
3690
  report?.({ path, runId, stage, error, fatal: false });
2266
3691
  },
2267
3692
  async onSuccess({ result }) {
2268
- 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
+ }
2269
3697
  await logService.logInfo(taskId, runId, "Task succeeded");
2270
3698
  logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
2271
3699
  }
@@ -2310,6 +3738,7 @@ function createControlsGenerator({
2310
3738
  return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
2311
3739
  }
2312
3740
  },
3741
+ scheduling,
2313
3742
  env: env ?? {},
2314
3743
  // Scoped to this task's own folder — see createTaskSqlite
2315
3744
  sqlite: createTaskSqlite(sourcePath),
@@ -2332,11 +3761,14 @@ export {
2332
3761
  Compiler,
2333
3762
  LockBusyError,
2334
3763
  Queue,
3764
+ STOP_GRACE_MS,
3765
+ ScheduleStore,
2335
3766
  Task,
2336
3767
  TaskLibrary,
2337
3768
  TaskLibraryScheduler,
2338
3769
  TaskOnceRunner,
2339
3770
  closeTaskDatabases,
3771
+ controlSchedule,
2340
3772
  createControlsGenerator,
2341
3773
  executeEsm,
2342
3774
  failedRunId,
@@ -2344,7 +3776,10 @@ export {
2344
3776
  initDbFileIfNotExists,
2345
3777
  isCronPatternValid,
2346
3778
  releaseTaskDatabases,
3779
+ resolveTiming,
2347
3780
  runFailure,
2348
- scheduleTask,
2349
- setupDbClient
3781
+ runOnce,
3782
+ setupDbClient,
3783
+ systemClock,
3784
+ timingFromFlag
2350
3785
  };