@dcrays/scheduled-task 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cordis.patch.yml CHANGED
@@ -8,7 +8,6 @@
8
8
  - webServer
9
9
  - tools
10
10
  config:
11
- runtimeEnvironment: development
12
11
  legacyDatabaseEnvironment: production
13
12
  maxPromptChars: 16384
14
13
  listPushIntervalSeconds: 30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/scheduled-task",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Persistent client-facing cron automation service for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "plugin/index.js",
package/plugin/index.d.ts CHANGED
@@ -9,7 +9,6 @@ export declare const inject: string[];
9
9
  export interface Config {
10
10
  databaseFile?: string;
11
11
  openclawSqlite?: string;
12
- runtimeEnvironment?: string;
13
12
  legacyDatabaseEnvironment?: string;
14
13
  maxPromptChars: number;
15
14
  listPushIntervalSeconds: number;
package/plugin/index.js CHANGED
@@ -34,7 +34,7 @@ function parseInteger(value, label) {
34
34
  }
35
35
  function parseField(raw, min, max, label, normalize) {
36
36
  const values = /* @__PURE__ */ new Set();
37
- const wildcard = raw === "*" || raw.startsWith("*/");
37
+ const wildcard = raw === "*";
38
38
  if (raw.length === 0)
39
39
  throw new CronExpressionError(`${label} is empty`);
40
40
  for (const segment of raw.split(",")) {
@@ -634,6 +634,24 @@ var CronAutomationRepository = class {
634
634
  return [];
635
635
  return this.database.prepare("SELECT source, job_id FROM cron_imported_jobs").all().map((row) => ({ source: row.source, jobId: row.job_id }));
636
636
  }
637
+ /** Only a verified complete source snapshot may authorize these deletions. */
638
+ async removeMissingImportedJobs(source, presentIds) {
639
+ this.assertWritable();
640
+ if (!source.trim())
641
+ throw new Error("cron import source must be non-empty");
642
+ return this.writeTransaction(() => {
643
+ const rows = this.database.prepare(`
644
+ SELECT jobs.job_id FROM cron_jobs AS jobs
645
+ INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
646
+ WHERE jobs.store_key = ? AND imported.source = ?
647
+ `).all(STORE_KEY, source);
648
+ const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
649
+ const statement = this.database.prepare("DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?");
650
+ for (const id of removed)
651
+ statement.run(STORE_KEY, id);
652
+ return removed;
653
+ });
654
+ }
637
655
  async migrateLegacy(migrationId, incoming, importedJobs) {
638
656
  this.assertWritable();
639
657
  const decoded = uniqueJobs(incoming);
@@ -716,6 +734,7 @@ var CronAutomationRepository = class {
716
734
 
717
735
  // dist-npm/src/manager.js
718
736
  var MAX_TIMER_DELAY_MS = 2147483647;
737
+ var DELIVERY_RETRY_DELAY_MS = 3e4;
719
738
  function automationId(agentId, requestId) {
720
739
  return `cron-${createHash("sha256").update(`${agentId}\0${requestId}`).digest("hex").slice(0, 24)}`;
721
740
  }
@@ -802,15 +821,25 @@ var CronAutomationManager = class {
802
821
  options;
803
822
  jobs = /* @__PURE__ */ new Map();
804
823
  blockedAgents = /* @__PURE__ */ new Set();
824
+ blockedUntil = /* @__PURE__ */ new Map();
825
+ activeJobs = /* @__PURE__ */ new Set();
826
+ inflight = /* @__PURE__ */ new Set();
805
827
  now;
806
828
  started;
807
829
  tail = Promise.resolve();
830
+ dispatchLoop = Promise.resolve();
808
831
  timer;
809
832
  stopping = false;
833
+ schedulingPaused;
810
834
  constructor(repository, options) {
811
835
  this.repository = repository;
812
836
  this.options = options;
813
837
  this.now = options.now ?? Date.now;
838
+ this.schedulingPaused = options.startPaused ?? false;
839
+ }
840
+ resumeScheduling() {
841
+ this.schedulingPaused = false;
842
+ this.arm();
814
843
  }
815
844
  start() {
816
845
  return this.started ??= this.initialize();
@@ -842,6 +871,8 @@ var CronAutomationManager = class {
842
871
  clearTimeout(this.timer);
843
872
  this.timer = void 0;
844
873
  try {
874
+ await this.dispatchLoop.catch(() => void 0);
875
+ await Promise.all([...this.inflight]);
845
876
  await this.tail;
846
877
  } finally {
847
878
  this.repository.close();
@@ -1012,31 +1043,12 @@ var CronAutomationManager = class {
1012
1043
  }
1013
1044
  async runOnceStatus(jobId) {
1014
1045
  await this.start();
1015
- return this.enqueue(async () => {
1016
- const job = this.jobs.get(jobId);
1017
- if (!job)
1018
- return "not_found";
1019
- if (job.state.runningAtMs !== void 0)
1020
- return "already_running";
1021
- if (!hasDshSession(job))
1022
- return "delivery_unavailable";
1023
- const occurrenceAtMs = this.now();
1024
- job.state.runningAtMs = occurrenceAtMs;
1025
- job.updatedAtMs = occurrenceAtMs;
1026
- await this.repository.upsert([job]);
1027
- const delivered = await this.deliver(job, occurrenceAtMs);
1028
- delete job.state.runningAtMs;
1029
- job.state.lastRunAtMs = occurrenceAtMs;
1030
- job.state.lastRunStatus = delivered ? "ok" : "skipped";
1031
- job.updatedAtMs = this.now();
1032
- await this.repository.upsert([job]);
1033
- if (delivered) {
1034
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1035
- return "dispatched";
1036
- }
1037
- this.options.onChanged?.(cloneJob(job));
1038
- return "delivery_unavailable";
1039
- });
1046
+ const claimed = await this.enqueue(() => this.claimManualRun(jobId));
1047
+ if (claimed.kind === "status")
1048
+ return claimed.status;
1049
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
1050
+ await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
1051
+ return delivered ? "dispatched" : "delivery_unavailable";
1040
1052
  }
1041
1053
  async runOnce(jobId) {
1042
1054
  return await this.runOnceStatus(jobId) === "dispatched";
@@ -1047,10 +1059,25 @@ var CronAutomationManager = class {
1047
1059
  return [...this.jobs.values()].filter((job) => agentId === void 0 || dshAgentIdFor(job) === agentId).sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id)).map(cloneJob);
1048
1060
  }
1049
1061
  notifyAgentAvailable(agentId) {
1050
- if (!this.blockedAgents.delete(agentId))
1051
- return;
1062
+ this.blockedAgents.delete(agentId);
1063
+ this.blockedUntil.delete(agentId);
1052
1064
  this.requestDispatch();
1053
1065
  }
1066
+ /** Reflect deletions only for ids recorded in this source's import ledger. */
1067
+ async reconcileImportedDeletions(source, presentIds) {
1068
+ await this.start();
1069
+ return this.enqueue(async () => {
1070
+ const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
1071
+ for (const id of removed) {
1072
+ const job = this.jobs.get(id);
1073
+ this.jobs.delete(id);
1074
+ if (job)
1075
+ this.options.onChanged?.(cloneJob(job));
1076
+ }
1077
+ this.arm();
1078
+ return removed;
1079
+ });
1080
+ }
1054
1081
  /** Import each source job once. Existing ids win so DSH bindings stay intact. */
1055
1082
  async importFrom(source, incoming) {
1056
1083
  await this.start();
@@ -1107,22 +1134,124 @@ var CronAutomationManager = class {
1107
1134
  return false;
1108
1135
  }
1109
1136
  }
1137
+ async deliverTracked(job, occurrenceAtMs) {
1138
+ const work = this.deliver(job, occurrenceAtMs);
1139
+ this.inflight.add(work);
1140
+ try {
1141
+ return await work;
1142
+ } finally {
1143
+ this.inflight.delete(work);
1144
+ }
1145
+ }
1146
+ async claimManualRun(jobId) {
1147
+ if (this.stopping)
1148
+ return { kind: "status", status: "delivery_unavailable" };
1149
+ if (this.activeJobs.has(jobId))
1150
+ return { kind: "status", status: "already_running" };
1151
+ const job = this.jobs.get(jobId);
1152
+ if (!job)
1153
+ return { kind: "status", status: "not_found" };
1154
+ if (job.state.runningAtMs !== void 0)
1155
+ return { kind: "status", status: "already_running" };
1156
+ if (!hasDshSession(job))
1157
+ return { kind: "status", status: "delivery_unavailable" };
1158
+ const occurrenceAtMs = this.now();
1159
+ this.activeJobs.add(jobId);
1160
+ job.state.runningAtMs = occurrenceAtMs;
1161
+ job.updatedAtMs = occurrenceAtMs;
1162
+ try {
1163
+ await this.repository.upsert([job]);
1164
+ } catch (error) {
1165
+ this.activeJobs.delete(jobId);
1166
+ delete job.state.runningAtMs;
1167
+ throw error;
1168
+ }
1169
+ return { kind: "ready", job: cloneJob(job), occurrenceAtMs };
1170
+ }
1171
+ async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
1172
+ this.activeJobs.delete(jobId);
1173
+ const job = this.jobs.get(jobId);
1174
+ if (!job)
1175
+ return;
1176
+ delete job.state.runningAtMs;
1177
+ job.state.lastRunAtMs = occurrenceAtMs;
1178
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
1179
+ job.updatedAtMs = this.now();
1180
+ await this.repository.upsert([job]);
1181
+ if (delivered)
1182
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1183
+ else
1184
+ this.options.onChanged?.(cloneJob(job));
1185
+ }
1186
+ async claimNextDueJob() {
1187
+ if (this.stopping || this.schedulingPaused)
1188
+ return void 0;
1189
+ const now = this.now();
1190
+ const job = [...this.jobs.values()].filter((candidate) => candidate.enabled && hasDshSession(candidate) && candidate.state.runningAtMs === void 0 && !this.activeJobs.has(candidate.id) && candidate.state.nextRunAtMs !== void 0 && candidate.state.nextRunAtMs <= now && (!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now)).sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
1191
+ if (!job)
1192
+ return void 0;
1193
+ const occurrenceAtMs = job.state.nextRunAtMs ?? now;
1194
+ this.activeJobs.add(job.id);
1195
+ job.state.runningAtMs = now;
1196
+ job.updatedAtMs = now;
1197
+ try {
1198
+ await this.repository.upsert([job]);
1199
+ } catch (error) {
1200
+ this.activeJobs.delete(job.id);
1201
+ delete job.state.runningAtMs;
1202
+ throw error;
1203
+ }
1204
+ return { job: cloneJob(job), occurrenceAtMs };
1205
+ }
1206
+ async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
1207
+ this.activeJobs.delete(jobId);
1208
+ const job = this.jobs.get(jobId);
1209
+ if (!job)
1210
+ return;
1211
+ const now = this.now();
1212
+ delete job.state.runningAtMs;
1213
+ job.state.lastRunAtMs = now;
1214
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
1215
+ job.updatedAtMs = now;
1216
+ if (!delivered) {
1217
+ this.blockedAgents.add(dshAgentIdFor(job));
1218
+ this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
1219
+ await this.repository.upsert([job]);
1220
+ this.options.onChanged?.(cloneJob(job));
1221
+ return;
1222
+ }
1223
+ this.blockedAgents.delete(dshAgentIdFor(job));
1224
+ this.blockedUntil.delete(dshAgentIdFor(job));
1225
+ if (job.deleteAfterRun === true || job.schedule.kind === "at") {
1226
+ this.jobs.delete(jobId);
1227
+ await this.repository.delete(jobId);
1228
+ } else {
1229
+ if (job.enabled)
1230
+ setNextOccurrence(job, nextOccurrence(now, job.schedule));
1231
+ await this.repository.upsert([job]);
1232
+ }
1233
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1234
+ }
1110
1235
  enqueue(operation) {
1111
1236
  const run = this.tail.then(operation);
1112
1237
  this.tail = run.then(() => void 0, () => void 0);
1113
1238
  return run;
1114
1239
  }
1115
1240
  requestDispatch() {
1116
- void this.enqueue(() => this.dispatchDue()).catch((error) => this.options.onError?.(error));
1241
+ this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
1242
+ void this.dispatchLoop.catch((error) => this.options.onError?.(error));
1117
1243
  }
1118
1244
  arm() {
1119
- if (this.stopping)
1245
+ if (this.stopping || this.schedulingPaused)
1120
1246
  return;
1121
1247
  if (this.timer)
1122
1248
  clearTimeout(this.timer);
1123
1249
  this.timer = void 0;
1124
1250
  const now = this.now();
1125
- const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && !this.blockedAgents.has(dshAgentIdFor(job))).map((job) => job.state.nextRunAtMs).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
1251
+ const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && (!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now)).map((job) => {
1252
+ const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
1253
+ return retryAt !== void 0 && retryAt > now ? retryAt : job.state.nextRunAtMs;
1254
+ }).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
1126
1255
  if (target === void 0)
1127
1256
  return;
1128
1257
  const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
@@ -1133,36 +1262,18 @@ var CronAutomationManager = class {
1133
1262
  this.timer.unref();
1134
1263
  }
1135
1264
  async dispatchDue() {
1136
- if (this.stopping)
1265
+ if (this.stopping || this.schedulingPaused)
1137
1266
  return;
1138
- const now = this.now();
1139
- const due = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.nextRunAtMs !== void 0 && job.state.nextRunAtMs <= now).sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs);
1140
- for (const job of due) {
1141
- const occurrenceAtMs = job.state.nextRunAtMs ?? now;
1142
- job.state.runningAtMs = now;
1143
- job.updatedAtMs = now;
1144
- await this.repository.upsert([job]);
1145
- const delivered = await this.deliver(job, occurrenceAtMs);
1146
- delete job.state.runningAtMs;
1147
- job.state.lastRunAtMs = now;
1148
- job.state.lastRunStatus = delivered ? "ok" : "skipped";
1149
- job.updatedAtMs = now;
1150
- if (!delivered) {
1151
- this.blockedAgents.add(dshAgentIdFor(job));
1152
- await this.repository.upsert([job]);
1153
- this.options.onChanged?.(cloneJob(job));
1154
- continue;
1155
- }
1156
- if (job.deleteAfterRun === true || job.schedule.kind === "at") {
1157
- this.jobs.delete(job.id);
1158
- await this.repository.delete(job.id);
1159
- } else {
1160
- setNextOccurrence(job, nextOccurrence(now, job.schedule));
1161
- await this.repository.upsert([job]);
1162
- }
1163
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1267
+ for (; ; ) {
1268
+ const claimed = await this.enqueue(() => this.claimNextDueJob());
1269
+ if (!claimed)
1270
+ break;
1271
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
1272
+ await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
1164
1273
  }
1165
- this.arm();
1274
+ await this.enqueue(async () => {
1275
+ this.arm();
1276
+ });
1166
1277
  }
1167
1278
  };
1168
1279
 
@@ -1268,12 +1379,12 @@ function jobFromOpenClawRow(row) {
1268
1379
  }
1269
1380
  function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
1270
1381
  if (!existsSync(sqlitePath))
1271
- return { jobs: [], skipped: [] };
1382
+ return { jobs: [], skipped: [], complete: false };
1272
1383
  const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
1273
1384
  try {
1274
1385
  const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
1275
1386
  if (!table)
1276
- return { jobs: [], skipped: [] };
1387
+ return { jobs: [], skipped: [], complete: false };
1277
1388
  const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
1278
1389
  const jobs = /* @__PURE__ */ new Map();
1279
1390
  const skipped = [];
@@ -1290,7 +1401,7 @@ function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
1290
1401
  });
1291
1402
  }
1292
1403
  }
1293
- return { jobs: [...jobs.values()], skipped };
1404
+ return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
1294
1405
  } finally {
1295
1406
  database.close();
1296
1407
  }
@@ -1383,7 +1494,6 @@ var Schema = z;
1383
1494
  var Config = Schema.object({
1384
1495
  databaseFile: Schema.string().required(false),
1385
1496
  openclawSqlite: Schema.string().required(false),
1386
- runtimeEnvironment: Schema.string().required(false),
1387
1497
  legacyDatabaseEnvironment: Schema.string().required(false),
1388
1498
  maxPromptChars: Schema.natural().min(1).default(16384),
1389
1499
  listPushIntervalSeconds: Schema.natural().min(1).default(30)
@@ -1590,11 +1700,12 @@ var CronAutomationService = class extends Service {
1590
1700
  super(owner, "cronAutomations");
1591
1701
  this.owner = owner;
1592
1702
  this.config = config;
1593
- this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment() ?? config.runtimeEnvironment);
1703
+ this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment());
1594
1704
  const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
1595
1705
  const logger = owner.logger("dsh-cron-automation");
1596
1706
  this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
1597
1707
  maxPromptChars: config.maxPromptChars,
1708
+ startPaused: true,
1598
1709
  deliver: async (cron, occurrenceAt) => {
1599
1710
  if (!cron.dshSessionId) {
1600
1711
  logger.warn("cron %s is waiting for an MBHChat DSH session binding", cron.id);
@@ -1612,6 +1723,7 @@ var CronAutomationService = class extends Service {
1612
1723
  return false;
1613
1724
  }
1614
1725
  agent.followup(dueMessage(cron, occurrenceAt));
1726
+ await agent.whenIdle();
1615
1727
  return true;
1616
1728
  },
1617
1729
  onError: (error) => logger.warn("cron automation runtime failed: %s", error instanceof Error ? error.message : String(error)),
@@ -1636,6 +1748,7 @@ var CronAutomationService = class extends Service {
1636
1748
  await this.manager.start();
1637
1749
  await this.migrateLegacyCronDatabase();
1638
1750
  await this.importOpenClawJobs();
1751
+ this.manager.resumeScheduling();
1639
1752
  await this.publishList("startup");
1640
1753
  if (this.stopping)
1641
1754
  return;
@@ -1700,11 +1813,18 @@ var CronAutomationService = class extends Service {
1700
1813
  this.owner.logger("dsh-cron-automation").warn("skipped %s invalid OpenClaw cron job(s) from %s: %s", result.skipped.length, sqlitePath, details);
1701
1814
  }
1702
1815
  const incoming = result.jobs;
1816
+ const source = explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource();
1817
+ if (result.complete) {
1818
+ const removed = await this.manager.reconcileImportedDeletions(source, new Set(incoming.map((job) => job.id)));
1819
+ if (removed.length > 0) {
1820
+ this.owner.logger("dsh-cron-automation").info("removed %s imported cron job(s) deleted in OpenClaw", removed.length);
1821
+ }
1822
+ }
1703
1823
  if (incoming.length === 0) {
1704
1824
  this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration found no importable jobs in %s", sqlitePath);
1705
1825
  return;
1706
1826
  }
1707
- const added = await this.manager.importFrom(explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource(), incoming);
1827
+ const added = await this.manager.importFrom(source, incoming);
1708
1828
  this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration completed from %s: %s imported, %s already recorded", sqlitePath, added.length, incoming.length - added.length);
1709
1829
  }
1710
1830
  async stop() {
@@ -1751,6 +1871,7 @@ var CronAutomationService = class extends Service {
1751
1871
  };
1752
1872
  }
1753
1873
  async handleHttp(req, res) {
1874
+ await this.start();
1754
1875
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1755
1876
  if (pathname === API_PREFIX && req.method === "GET") {
1756
1877
  sendJson(res, 200, await this.snapshot("subscribe"));
@@ -1830,6 +1951,7 @@ var CronAutomationService = class extends Service {
1830
1951
  return;
1831
1952
  this.pushQueued = true;
1832
1953
  this.pushTail = this.pushTail.then(async () => {
1954
+ await this.start();
1833
1955
  while (this.pendingPushReason) {
1834
1956
  const pendingReason = this.pendingPushReason;
1835
1957
  this.pendingPushReason = void 0;
package/src/cron.js CHANGED
@@ -24,7 +24,9 @@ function parseInteger(value, label) {
24
24
  }
25
25
  function parseField(raw, min, max, label, normalize) {
26
26
  const values = new Set();
27
- const wildcard = raw === "*" || raw.startsWith("*/");
27
+ // A stepped field such as */2 selects a subset of values. It must remain
28
+ // restricted for cron's day-of-month/day-of-week matching rule.
29
+ const wildcard = raw === "*";
28
30
  if (raw.length === 0)
29
31
  throw new CronExpressionError(`${label} is empty`);
30
32
  for (const segment of raw.split(",")) {
package/src/manager.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { CronCreateEnvelope } from './protocol.js';
2
2
  import { CronAutomationRepository, type CronImportedJob, type StoredCronAutomation } from './repository.js';
3
3
  export interface CronAutomationManagerOptions {
4
4
  maxPromptChars: number;
5
+ startPaused?: boolean;
5
6
  now?: () => number;
6
7
  deliver(job: StoredCronAutomation, occurrenceAt: string): boolean | Promise<boolean>;
7
8
  onError?(error: unknown): void;
@@ -25,12 +26,18 @@ export declare class CronAutomationManager {
25
26
  private readonly options;
26
27
  private readonly jobs;
27
28
  private readonly blockedAgents;
29
+ private readonly blockedUntil;
30
+ private readonly activeJobs;
31
+ private readonly inflight;
28
32
  private readonly now;
29
33
  private started?;
30
34
  private tail;
35
+ private dispatchLoop;
31
36
  private timer;
32
37
  private stopping;
38
+ private schedulingPaused;
33
39
  constructor(repository: CronAutomationRepository, options: CronAutomationManagerOptions);
40
+ resumeScheduling(): void;
34
41
  start(): Promise<void>;
35
42
  private initialize;
36
43
  stop(): Promise<void>;
@@ -41,6 +48,8 @@ export declare class CronAutomationManager {
41
48
  runOnce(jobId: string): Promise<boolean>;
42
49
  list(agentId?: string): Promise<StoredCronAutomation[]>;
43
50
  notifyAgentAvailable(agentId: string): void;
51
+ /** Reflect deletions only for ids recorded in this source's import ledger. */
52
+ reconcileImportedDeletions(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
44
53
  /** Import each source job once. Existing ids win so DSH bindings stay intact. */
45
54
  importFrom(source: string, incoming: readonly StoredCronAutomation[]): Promise<StoredCronAutomation[]>;
46
55
  migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<{
@@ -49,6 +58,11 @@ export declare class CronAutomationManager {
49
58
  }>;
50
59
  private prepareImportedJob;
51
60
  private deliver;
61
+ private deliverTracked;
62
+ private claimManualRun;
63
+ private finalizeManualRun;
64
+ private claimNextDueJob;
65
+ private finalizeScheduledRun;
52
66
  private enqueue;
53
67
  private requestDispatch;
54
68
  private arm;
package/src/manager.js CHANGED
@@ -3,6 +3,7 @@ import { assertMinimumCronInterval, CronExpressionError, MIN_CRON_INTERVAL_SECON
3
3
  import { CronAutomationInputError } from './protocol.js';
4
4
  import { CronAutomationRepository } from './repository.js';
5
5
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
6
+ const DELIVERY_RETRY_DELAY_MS = 30_000;
6
7
  function automationId(agentId, requestId) {
7
8
  return `cron-${createHash('sha256').update(`${agentId}\0${requestId}`).digest('hex').slice(0, 24)}`;
8
9
  }
@@ -96,15 +97,25 @@ export class CronAutomationManager {
96
97
  options;
97
98
  jobs = new Map();
98
99
  blockedAgents = new Set();
100
+ blockedUntil = new Map();
101
+ activeJobs = new Set();
102
+ inflight = new Set();
99
103
  now;
100
104
  started;
101
105
  tail = Promise.resolve();
106
+ dispatchLoop = Promise.resolve();
102
107
  timer;
103
108
  stopping = false;
109
+ schedulingPaused;
104
110
  constructor(repository, options) {
105
111
  this.repository = repository;
106
112
  this.options = options;
107
113
  this.now = options.now ?? Date.now;
114
+ this.schedulingPaused = options.startPaused ?? false;
115
+ }
116
+ resumeScheduling() {
117
+ this.schedulingPaused = false;
118
+ this.arm();
108
119
  }
109
120
  start() {
110
121
  return (this.started ??= this.initialize());
@@ -136,6 +147,8 @@ export class CronAutomationManager {
136
147
  clearTimeout(this.timer);
137
148
  this.timer = undefined;
138
149
  try {
150
+ await this.dispatchLoop.catch(() => undefined);
151
+ await Promise.all([...this.inflight]);
139
152
  await this.tail;
140
153
  }
141
154
  finally {
@@ -314,33 +327,12 @@ export class CronAutomationManager {
314
327
  }
315
328
  async runOnceStatus(jobId) {
316
329
  await this.start();
317
- return this.enqueue(async () => {
318
- const job = this.jobs.get(jobId);
319
- if (!job)
320
- return 'not_found';
321
- if (job.state.runningAtMs !== undefined)
322
- return 'already_running';
323
- // Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
324
- // They must wait until mbh-chat supplies a verified DSH session binding.
325
- if (!hasDshSession(job))
326
- return 'delivery_unavailable';
327
- const occurrenceAtMs = this.now();
328
- job.state.runningAtMs = occurrenceAtMs;
329
- job.updatedAtMs = occurrenceAtMs;
330
- await this.repository.upsert([job]);
331
- const delivered = await this.deliver(job, occurrenceAtMs);
332
- delete job.state.runningAtMs;
333
- job.state.lastRunAtMs = occurrenceAtMs;
334
- job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
335
- job.updatedAtMs = this.now();
336
- await this.repository.upsert([job]);
337
- if (delivered) {
338
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
339
- return 'dispatched';
340
- }
341
- this.options.onChanged?.(cloneJob(job));
342
- return 'delivery_unavailable';
343
- });
330
+ const claimed = await this.enqueue(() => this.claimManualRun(jobId));
331
+ if (claimed.kind === 'status')
332
+ return claimed.status;
333
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
334
+ await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
335
+ return delivered ? 'dispatched' : 'delivery_unavailable';
344
336
  }
345
337
  async runOnce(jobId) {
346
338
  return (await this.runOnceStatus(jobId)) === 'dispatched';
@@ -354,10 +346,25 @@ export class CronAutomationManager {
354
346
  .map(cloneJob);
355
347
  }
356
348
  notifyAgentAvailable(agentId) {
357
- if (!this.blockedAgents.delete(agentId))
358
- return;
349
+ this.blockedAgents.delete(agentId);
350
+ this.blockedUntil.delete(agentId);
359
351
  this.requestDispatch();
360
352
  }
353
+ /** Reflect deletions only for ids recorded in this source's import ledger. */
354
+ async reconcileImportedDeletions(source, presentIds) {
355
+ await this.start();
356
+ return this.enqueue(async () => {
357
+ const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
358
+ for (const id of removed) {
359
+ const job = this.jobs.get(id);
360
+ this.jobs.delete(id);
361
+ if (job)
362
+ this.options.onChanged?.(cloneJob(job));
363
+ }
364
+ this.arm();
365
+ return removed;
366
+ });
367
+ }
361
368
  /** Import each source job once. Existing ids win so DSH bindings stay intact. */
362
369
  async importFrom(source, incoming) {
363
370
  await this.start();
@@ -415,16 +422,129 @@ export class CronAutomationManager {
415
422
  return false;
416
423
  }
417
424
  }
425
+ async deliverTracked(job, occurrenceAtMs) {
426
+ const work = this.deliver(job, occurrenceAtMs);
427
+ this.inflight.add(work);
428
+ try {
429
+ return await work;
430
+ }
431
+ finally {
432
+ this.inflight.delete(work);
433
+ }
434
+ }
435
+ async claimManualRun(jobId) {
436
+ if (this.stopping)
437
+ return { kind: 'status', status: 'delivery_unavailable' };
438
+ if (this.activeJobs.has(jobId))
439
+ return { kind: 'status', status: 'already_running' };
440
+ const job = this.jobs.get(jobId);
441
+ if (!job)
442
+ return { kind: 'status', status: 'not_found' };
443
+ if (job.state.runningAtMs !== undefined)
444
+ return { kind: 'status', status: 'already_running' };
445
+ // Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
446
+ // They must wait until mbh-chat supplies a verified DSH session binding.
447
+ if (!hasDshSession(job))
448
+ return { kind: 'status', status: 'delivery_unavailable' };
449
+ const occurrenceAtMs = this.now();
450
+ this.activeJobs.add(jobId);
451
+ job.state.runningAtMs = occurrenceAtMs;
452
+ job.updatedAtMs = occurrenceAtMs;
453
+ try {
454
+ await this.repository.upsert([job]);
455
+ }
456
+ catch (error) {
457
+ this.activeJobs.delete(jobId);
458
+ delete job.state.runningAtMs;
459
+ throw error;
460
+ }
461
+ return { kind: 'ready', job: cloneJob(job), occurrenceAtMs };
462
+ }
463
+ async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
464
+ this.activeJobs.delete(jobId);
465
+ const job = this.jobs.get(jobId);
466
+ if (!job)
467
+ return;
468
+ delete job.state.runningAtMs;
469
+ job.state.lastRunAtMs = occurrenceAtMs;
470
+ job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
471
+ job.updatedAtMs = this.now();
472
+ await this.repository.upsert([job]);
473
+ if (delivered)
474
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
475
+ else
476
+ this.options.onChanged?.(cloneJob(job));
477
+ }
478
+ async claimNextDueJob() {
479
+ if (this.stopping || this.schedulingPaused)
480
+ return undefined;
481
+ const now = this.now();
482
+ const job = [...this.jobs.values()]
483
+ .filter((candidate) => candidate.enabled &&
484
+ hasDshSession(candidate) &&
485
+ candidate.state.runningAtMs === undefined &&
486
+ !this.activeJobs.has(candidate.id) &&
487
+ candidate.state.nextRunAtMs !== undefined &&
488
+ candidate.state.nextRunAtMs <= now &&
489
+ (!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now))
490
+ .sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
491
+ if (!job)
492
+ return undefined;
493
+ const occurrenceAtMs = job.state.nextRunAtMs ?? now;
494
+ this.activeJobs.add(job.id);
495
+ job.state.runningAtMs = now;
496
+ job.updatedAtMs = now;
497
+ try {
498
+ await this.repository.upsert([job]);
499
+ }
500
+ catch (error) {
501
+ this.activeJobs.delete(job.id);
502
+ delete job.state.runningAtMs;
503
+ throw error;
504
+ }
505
+ return { job: cloneJob(job), occurrenceAtMs };
506
+ }
507
+ async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
508
+ this.activeJobs.delete(jobId);
509
+ const job = this.jobs.get(jobId);
510
+ if (!job)
511
+ return;
512
+ const now = this.now();
513
+ delete job.state.runningAtMs;
514
+ job.state.lastRunAtMs = now;
515
+ job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
516
+ job.updatedAtMs = now;
517
+ if (!delivered) {
518
+ this.blockedAgents.add(dshAgentIdFor(job));
519
+ this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
520
+ await this.repository.upsert([job]);
521
+ this.options.onChanged?.(cloneJob(job));
522
+ return;
523
+ }
524
+ this.blockedAgents.delete(dshAgentIdFor(job));
525
+ this.blockedUntil.delete(dshAgentIdFor(job));
526
+ if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
527
+ this.jobs.delete(jobId);
528
+ await this.repository.delete(jobId);
529
+ }
530
+ else {
531
+ if (job.enabled)
532
+ setNextOccurrence(job, nextOccurrence(now, job.schedule));
533
+ await this.repository.upsert([job]);
534
+ }
535
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
536
+ }
418
537
  enqueue(operation) {
419
538
  const run = this.tail.then(operation);
420
539
  this.tail = run.then(() => undefined, () => undefined);
421
540
  return run;
422
541
  }
423
542
  requestDispatch() {
424
- void this.enqueue(() => this.dispatchDue()).catch((error) => this.options.onError?.(error));
543
+ this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
544
+ void this.dispatchLoop.catch((error) => this.options.onError?.(error));
425
545
  }
426
546
  arm() {
427
- if (this.stopping)
547
+ if (this.stopping || this.schedulingPaused)
428
548
  return;
429
549
  if (this.timer)
430
550
  clearTimeout(this.timer);
@@ -434,8 +554,11 @@ export class CronAutomationManager {
434
554
  .filter((job) => job.enabled &&
435
555
  hasDshSession(job) &&
436
556
  job.state.runningAtMs === undefined &&
437
- !this.blockedAgents.has(dshAgentIdFor(job)))
438
- .map((job) => job.state.nextRunAtMs)
557
+ (!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now))
558
+ .map((job) => {
559
+ const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
560
+ return retryAt !== undefined && retryAt > now ? retryAt : job.state.nextRunAtMs;
561
+ })
439
562
  .filter((value) => value !== undefined)
440
563
  .reduce((earliest, candidate) => (earliest === undefined || candidate < earliest ? candidate : earliest), undefined);
441
564
  if (target === undefined)
@@ -448,38 +571,17 @@ export class CronAutomationManager {
448
571
  this.timer.unref();
449
572
  }
450
573
  async dispatchDue() {
451
- if (this.stopping)
574
+ if (this.stopping || this.schedulingPaused)
452
575
  return;
453
- const now = this.now();
454
- const due = [...this.jobs.values()]
455
- .filter((job) => job.enabled && hasDshSession(job) && job.state.nextRunAtMs !== undefined && job.state.nextRunAtMs <= now)
456
- .sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs);
457
- for (const job of due) {
458
- const occurrenceAtMs = job.state.nextRunAtMs ?? now;
459
- job.state.runningAtMs = now;
460
- job.updatedAtMs = now;
461
- await this.repository.upsert([job]);
462
- const delivered = await this.deliver(job, occurrenceAtMs);
463
- delete job.state.runningAtMs;
464
- job.state.lastRunAtMs = now;
465
- job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
466
- job.updatedAtMs = now;
467
- if (!delivered) {
468
- this.blockedAgents.add(dshAgentIdFor(job));
469
- await this.repository.upsert([job]);
470
- this.options.onChanged?.(cloneJob(job));
471
- continue;
472
- }
473
- if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
474
- this.jobs.delete(job.id);
475
- await this.repository.delete(job.id);
476
- }
477
- else {
478
- setNextOccurrence(job, nextOccurrence(now, job.schedule));
479
- await this.repository.upsert([job]);
480
- }
481
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
576
+ for (;;) {
577
+ const claimed = await this.enqueue(() => this.claimNextDueJob());
578
+ if (!claimed)
579
+ break;
580
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
581
+ await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
482
582
  }
483
- this.arm();
583
+ await this.enqueue(async () => {
584
+ this.arm();
585
+ });
484
586
  }
485
587
  }
@@ -5,6 +5,8 @@ export interface OpenClawSqliteImportResult {
5
5
  jobId: string;
6
6
  reason: string;
7
7
  }>;
8
+ /** False for a missing source/table or an unreadable row; never infer deletions then. */
9
+ complete: boolean;
8
10
  }
9
11
  interface CronJobRow {
10
12
  job_id: string;
@@ -110,12 +110,12 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
110
110
  }
111
111
  export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
112
112
  if (!existsSync(sqlitePath))
113
- return { jobs: [], skipped: [] };
113
+ return { jobs: [], skipped: [], complete: false };
114
114
  const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
115
115
  try {
116
116
  const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
117
117
  if (!table)
118
- return { jobs: [], skipped: [] };
118
+ return { jobs: [], skipped: [], complete: false };
119
119
  // SELECT * deliberately tolerates OpenClaw schema additions and older schemas
120
120
  // that omit newer nullable projection columns. job_json remains authoritative.
121
121
  const rows = database
@@ -139,7 +139,7 @@ export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
139
139
  });
140
140
  }
141
141
  }
142
- return { jobs: [...jobs.values()], skipped };
142
+ return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
143
143
  }
144
144
  finally {
145
145
  database.close();
@@ -83,6 +83,8 @@ export declare class CronAutomationRepository {
83
83
  previewImport(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
84
84
  import(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
85
85
  listImportedJobs(): Promise<CronImportedJob[]>;
86
+ /** Only a verified complete source snapshot may authorize these deletions. */
87
+ removeMissingImportedJobs(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
86
88
  migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<CronLegacyMigrationResult>;
87
89
  close(): void;
88
90
  private planImport;
package/src/repository.js CHANGED
@@ -344,6 +344,27 @@ export class CronAutomationRepository {
344
344
  return [];
345
345
  return this.database.prepare('SELECT source, job_id FROM cron_imported_jobs').all().map((row) => ({ source: row.source, jobId: row.job_id }));
346
346
  }
347
+ /** Only a verified complete source snapshot may authorize these deletions. */
348
+ async removeMissingImportedJobs(source, presentIds) {
349
+ this.assertWritable();
350
+ if (!source.trim())
351
+ throw new Error('cron import source must be non-empty');
352
+ return this.writeTransaction(() => {
353
+ const rows = this.database
354
+ .prepare(`
355
+ SELECT jobs.job_id FROM cron_jobs AS jobs
356
+ INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
357
+ WHERE jobs.store_key = ? AND imported.source = ?
358
+ `)
359
+ .all(STORE_KEY, source);
360
+ const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
361
+ const statement = this.database.prepare('DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?');
362
+ for (const id of removed)
363
+ statement.run(STORE_KEY, id);
364
+ // Keep the import ledger: an old snapshot must not resurrect a deletion.
365
+ return removed;
366
+ });
367
+ }
347
368
  async migrateLegacy(migrationId, incoming, importedJobs) {
348
369
  this.assertWritable();
349
370
  const decoded = uniqueJobs(incoming);