mixdog 0.9.24 → 0.9.26

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 (84) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/explore-bench-tmp.mjs +1 -1
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/steering-fold-provenance-test.mjs +71 -0
  7. package/scripts/tool-efficiency-diag.mjs +1 -1
  8. package/scripts/webhook-smoke.mjs +46 -53
  9. package/src/defaults/cycle3-review-prompt.md +11 -4
  10. package/src/defaults/memory-promote-prompt.md +9 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/rules/lead/02-channels.md +3 -3
  13. package/src/rules/shared/01-tool.md +8 -4
  14. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  15. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  17. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  18. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  26. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  27. package/src/runtime/channels/backends/discord.mjs +10 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  30. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  31. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  32. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  33. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  34. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  35. package/src/runtime/channels/lib/webhook.mjs +98 -150
  36. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  37. package/src/runtime/memory/index.mjs +50 -25
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
  39. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  40. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  41. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  42. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  43. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  44. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  45. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  46. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  47. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  48. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  49. package/src/runtime/memory/tool-defs.mjs +1 -1
  50. package/src/runtime/shared/child-guardian.mjs +2 -2
  51. package/src/runtime/shared/open-url.mjs +2 -1
  52. package/src/runtime/shared/schedules-db.mjs +350 -0
  53. package/src/runtime/shared/service-discovery.mjs +169 -0
  54. package/src/runtime/shared/spawn-flags.mjs +27 -0
  55. package/src/runtime/shared/tool-primitives.mjs +3 -1
  56. package/src/runtime/shared/tool-surface.mjs +19 -3
  57. package/src/runtime/shared/update-checker.mjs +54 -10
  58. package/src/runtime/shared/webhooks-db.mjs +405 -0
  59. package/src/session-runtime/channel-config-api.mjs +13 -13
  60. package/src/session-runtime/lifecycle-api.mjs +14 -0
  61. package/src/session-runtime/runtime-core.mjs +39 -20
  62. package/src/standalone/agent-tool.mjs +42 -11
  63. package/src/standalone/channel-admin.mjs +173 -121
  64. package/src/standalone/channel-worker.mjs +2 -2
  65. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  66. package/src/tui/App.jsx +32 -10
  67. package/src/tui/app/channel-pickers.mjs +9 -9
  68. package/src/tui/app/clipboard.mjs +14 -0
  69. package/src/tui/app/doctor.mjs +1 -1
  70. package/src/tui/app/maintenance-pickers.mjs +17 -7
  71. package/src/tui/app/settings-picker.mjs +2 -2
  72. package/src/tui/app/transcript-window.mjs +37 -1
  73. package/src/tui/app/use-mouse-input.mjs +19 -6
  74. package/src/tui/components/ToolExecution.jsx +12 -4
  75. package/src/tui/display-width.mjs +10 -4
  76. package/src/tui/dist/index.mjs +166 -58
  77. package/src/tui/engine/agent-job-feed.mjs +42 -3
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/index.jsx +12 -2
  80. package/src/ui/statusline-segments.mjs +12 -1
  81. package/src/workflows/default/WORKFLOW.md +29 -38
  82. package/src/workflows/solo/WORKFLOW.md +15 -20
  83. package/vendor/ink/build/display-width.js +5 -4
  84. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -4,9 +4,10 @@ import { join, isAbsolute } from "path";
4
4
  import { tmpdir } from "os";
5
5
  import { randomUUID } from "crypto";
6
6
  import { DATA_DIR } from "./config.mjs";
7
- import { runScript as execScript, ensureNopluginDir } from "./executor.mjs";
7
+ import { ensureNopluginDir } from "./executor.mjs";
8
8
  import { withFileLockSync } from "../../shared/atomic-file.mjs";
9
9
  import { makeAgentDispatch } from '../../agent/orchestrator/agent-runtime/agent-dispatch.mjs';
10
+ import { markFired, markDone, setDeferred, setSkippedUntil } from "../../shared/schedules-db.mjs";
10
11
 
11
12
  const schedulerLlm = makeAgentDispatch({ taskType: 'scheduler-task', agent: 'scheduler-task', sourceType: 'scheduler' });
12
13
  const SCHEDULE_LOG = join(DATA_DIR, "schedule.log");
@@ -77,29 +78,6 @@ export function validateCronExpression(time) {
77
78
  }
78
79
  if (!valid) throw new Error(`invalid cron expression "${time}": failed node-cron validation. Legacy formats (HH:MM, everyNm, hourly, daily) are no longer supported — use a cron expression instead.`);
79
80
  }
80
- // Build a {hhmm, dateStr, dow} snapshot in the given IANA TZ. Falls
81
- // back to local Date math when tz is absent.
82
- function tzSnapshot(now, tz) {
83
- if (!tz) {
84
- return {
85
- hhmm: `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`,
86
- dateStr: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`,
87
- dow: now.getDay(),
88
- };
89
- }
90
- const parts = new Intl.DateTimeFormat("en-US", {
91
- hour12: false, timeZone: tz,
92
- year: "numeric", month: "2-digit", day: "2-digit",
93
- hour: "2-digit", minute: "2-digit", weekday: "short",
94
- }).formatToParts(now).reduce((acc, p) => { acc[p.type] = p.value; return acc; }, {});
95
- const hour = parts.hour === "24" ? "00" : parts.hour;
96
- const dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
97
- return {
98
- hhmm: `${hour}:${parts.minute}`,
99
- dateStr: `${parts.year}-${parts.month}-${parts.day}`,
100
- dow: dowMap[parts.weekday] ?? now.getDay(),
101
- };
102
- }
103
81
  class Scheduler {
104
82
  nonInteractive;
105
83
  interactive;
@@ -117,12 +95,10 @@ class Scheduler {
117
95
  // timestamp of last inbound message
118
96
  deferred = /* @__PURE__ */ new Map();
119
97
  // name -> deferred-until timestamp
120
- skippedToday = /* @__PURE__ */ new Set();
121
- // names skipped for today
122
- skippedTodayDate = "";
123
- // "YYYY-MM-DD" local date the skippedToday set belongs to
124
98
  cronJobs = /* @__PURE__ */ new Map();
125
99
  // name -> node-cron ScheduledTask for cron-expression entries
100
+ oneShotTimers = /* @__PURE__ */ new Map();
101
+ // name -> setTimeout handle for when_at one-shot entries
126
102
  //
127
103
  // `channelId` is the single resolved main-channel id used when a schedule's
128
104
  // `channel` flag is set (post-to-channel); absent flag → inject into session.
@@ -131,6 +107,7 @@ class Scheduler {
131
107
  this.interactive = interactive.filter((s) => s.enabled !== false);
132
108
  this.channelId = channelId ?? "";
133
109
  this.promptsDir = join(DATA_DIR, "prompts");
110
+ this.refreshSkipCache();
134
111
  }
135
112
  setInjectHandler(fn) {
136
113
  this.injectFn = fn;
@@ -144,42 +121,70 @@ class Scheduler {
144
121
  noteActivity() {
145
122
  this.lastActivity = Date.now();
146
123
  }
147
- /** Defer a schedule by N minutes from now */
148
- defer(name, minutes) {
124
+ /** Find a loaded schedule def by name (either routing bucket). */
125
+ findSchedule(name) {
126
+ return [...this.nonInteractive, ...this.interactive].find((s) => s.name === name) ?? null;
127
+ }
128
+ /** Rebuild the in-memory deferred cache (used by the status snapshot) from
129
+ * the loaded rows' deferred_until values. Called on construct + reload so
130
+ * persisted defer/skip state survives a config reload. */
131
+ refreshSkipCache() {
132
+ this.deferred.clear();
133
+ const now = Date.now();
134
+ for (const s of [...this.nonInteractive, ...this.interactive]) {
135
+ const until = s.deferredUntil ? new Date(s.deferredUntil).getTime() : 0;
136
+ if (until > now) this.deferred.set(s.name, until);
137
+ }
138
+ }
139
+ /** Defer a schedule by N minutes from now (persisted to deferred_until). */
140
+ async defer(name, minutes) {
149
141
  const mins = Number(minutes);
150
142
  if (!Number.isFinite(mins) || mins <= 0) {
151
143
  throw new Error(`defer: minutes must be a positive number, got ${JSON.stringify(minutes)}`);
152
144
  }
153
- const allSchedules = [...this.nonInteractive, ...this.interactive];
154
- const exists = allSchedules.some(s => s.name === name);
155
- if (!exists) throw new Error(`defer: unknown schedule "${name}" — use schedule_status to list valid names`);
156
- this.deferred.set(name, Date.now() + mins * 6e4);
157
- }
158
- /** Skip a schedule for the rest of today */
159
- skipToday(name) {
160
- const allSchedules = [...this.nonInteractive, ...this.interactive];
161
- const exists = allSchedules.some(s => s.name === name);
162
- if (!exists) throw new Error(`skip_today: unknown schedule "${name}" — use schedule_status to list valid names`);
163
- this.rolloverSkippedTodayIfNeeded();
164
- this.skippedToday.add(name);
165
- }
166
- /** Roll the skippedToday bucket over when the local date has changed */
167
- rolloverSkippedTodayIfNeeded() {
168
- const today = new Date().toLocaleDateString('sv-SE');
169
- if (this.skippedTodayDate !== today) {
170
- this.skippedToday.clear();
171
- this.skippedTodayDate = today;
172
- }
173
- }
174
- /** Check if a schedule should be skipped (deferred or skipped today) */
145
+ const s = this.findSchedule(name);
146
+ if (!s) throw new Error(`defer: unknown schedule "${name}" use schedule_status to list valid names`);
147
+ const until = new Date(Date.now() + mins * 6e4);
148
+ await setDeferred(name, until);
149
+ s.deferredUntil = until.toISOString();
150
+ this.deferred.set(name, until.getTime());
151
+ }
152
+ /** Skip a schedule for the rest of today (persisted to skipped_until = end
153
+ * of the local day). */
154
+ async skipToday(name) {
155
+ const s = this.findSchedule(name);
156
+ if (!s) throw new Error(`skip_today: unknown schedule "${name}" — use schedule_status to list valid names`);
157
+ const eod = new Date();
158
+ eod.setHours(23, 59, 59, 999);
159
+ await setSkippedUntil(name, eod);
160
+ s.skippedUntil = eod.toISOString();
161
+ }
162
+ /** Check if a schedule should be skipped, reading deferred_until /
163
+ * skipped_until from the loaded row (in-memory cache, refreshed on reload). */
175
164
  shouldSkip(name) {
176
- this.rolloverSkippedTodayIfNeeded();
177
- if (this.skippedToday.has(name)) return true;
178
- const until = this.deferred.get(name);
179
- if (until && Date.now() < until) return true;
180
- if (until && Date.now() >= until) this.deferred.delete(name);
165
+ const s = this.findSchedule(name);
166
+ if (!s) return false;
167
+ const now = Date.now();
168
+ const deferredUntil = s.deferredUntil ? new Date(s.deferredUntil).getTime() : 0;
169
+ if (deferredUntil && now < deferredUntil) return true;
170
+ const skippedUntil = s.skippedUntil ? new Date(s.skippedUntil).getTime() : 0;
171
+ if (skippedUntil && now < skippedUntil) return true;
181
172
  return false;
182
173
  }
174
+ /** Timestamp (ms) until which `name` is currently skipped — the later of a
175
+ * still-future deferred_until / skipped_until, or 0 if not skipped. Used to
176
+ * re-arm a deferred when_at one-shot for the moment the skip expires. */
177
+ skipUntil(name) {
178
+ const s = this.findSchedule(name);
179
+ if (!s) return 0;
180
+ const now = Date.now();
181
+ let until = 0;
182
+ const deferredUntil = s.deferredUntil ? new Date(s.deferredUntil).getTime() : 0;
183
+ if (deferredUntil && now < deferredUntil) until = Math.max(until, deferredUntil);
184
+ const skippedUntil = s.skippedUntil ? new Date(s.skippedUntil).getTime() : 0;
185
+ if (skippedUntil && now < skippedUntil) until = Math.max(until, skippedUntil);
186
+ return until;
187
+ }
183
188
  /** Get current session activity state.
184
189
  * Returns { lastActivityMs, pendingWork } — callers apply their own
185
190
  * thresholds. pendingWork is true when pendingCheck() reports work in
@@ -312,44 +317,48 @@ ${Scheduler.INSTANCE_UUID}`;
312
317
  this.tick();
313
318
  this.tickTimer = setInterval(() => this.tick(), TICK_INTERVAL);
314
319
  }
315
- /** Register cron-expression entries with node-cron. All schedule entries
316
- * must use cron expressions. Entries that fail cron validation are skipped
317
- * with a logged error. */
320
+ /** Register schedule entries. `when_cron` entries bind to node-cron (dow
321
+ * field covers the day guard); `when_at` entries arm a one-shot timer. */
318
322
  registerCronJobs() {
319
323
  const all = [
320
324
  ...this.nonInteractive.map((s) => ({ schedule: s, type: "non-interactive" })),
321
325
  ...this.interactive.map((s) => ({ schedule: s, type: "interactive" })),
322
326
  ];
323
327
  for (const { schedule: s, type } of all) {
324
- if (!isCronExpression(s.time)) continue;
325
- try {
326
- const task = cron.schedule(s.time, () => this.onCronFire(s, type), {
327
- timezone: s.timezone || undefined,
328
- name: s.name,
329
- });
330
- this.cronJobs.set(s.name, task);
331
- logSchedule(`registered cron "${s.name}" = "${s.time}"${s.timezone ? ` tz=${s.timezone}` : ""}\n`);
332
- } catch (err) {
333
- process.stderr.write(`mixdog scheduler: failed to register cron "${s.name}" (${s.time}): ${err}\n`);
328
+ if (s.whenCron) {
329
+ if (!isCronExpression(s.whenCron)) {
330
+ process.stderr.write(`mixdog scheduler: invalid cron "${s.name}" (${s.whenCron}) skipped\n`);
331
+ continue;
332
+ }
333
+ try {
334
+ const task = cron.schedule(s.whenCron, () => this.onCronFire(s, type), {
335
+ timezone: s.timezone || undefined,
336
+ name: s.name,
337
+ });
338
+ this.cronJobs.set(s.name, task);
339
+ logSchedule(`registered cron "${s.name}" = "${s.whenCron}"${s.timezone ? ` tz=${s.timezone}` : ""}\n`);
340
+ } catch (err) {
341
+ process.stderr.write(`mixdog scheduler: failed to register cron "${s.name}" (${s.whenCron}): ${err}\n`);
342
+ }
343
+ } else if (s.whenAt) {
344
+ this.armOneShot(s, type);
334
345
  }
335
346
  }
336
347
  }
337
- /** Fire path for a cron-triggered entry. Applies day guards against
338
- * the schedule's TZ (or local when absent). */
348
+ /** Fire path for a cron-triggered entry. node-cron's dow field covers the
349
+ * day guard, so there is no separate days filter. Persists last_fired_at. */
339
350
  async onCronFire(schedule, type) {
340
351
  const now = /* @__PURE__ */ new Date();
341
- const tz = schedule.timezone || null;
342
- const snap = tzSnapshot(now, tz);
343
- const isWeekend = snap.dow === 0 || snap.dow === 6;
344
- const days = schedule.days ?? "daily";
345
- if (!this.matchesDays(days, snap.dow, isWeekend)) return;
346
352
  if (this.shouldSkip(schedule.name)) return;
347
353
  // Record lastFired only when the fire actually proceeds past
348
354
  // fireTimed's running/precondition guards (it resolves truthy on a
349
355
  // real fire), so failed/skipped fires no longer display as fired.
350
- this.fireTimed(schedule, type).then(
351
- (fired) => { if (fired) this.lastFired.set(schedule.name, now.toISOString()); }
352
- ).catch(
356
+ this.fireTimed(schedule, type).then(async (fired) => {
357
+ if (!fired) return;
358
+ this.lastFired.set(schedule.name, now.toISOString());
359
+ try { await markFired(schedule.name, now); }
360
+ catch (err) { process.stderr.write(`mixdog scheduler: ${schedule.name} markFired failed: ${err}\n`); }
361
+ }).catch(
353
362
  (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} failed: ${err}\n`)
354
363
  );
355
364
  }
@@ -376,6 +385,96 @@ ${Scheduler.INSTANCE_UUID}`;
376
385
  try { task.destroy(); } catch {}
377
386
  }
378
387
  this.cronJobs.clear();
388
+ for (const [, timer] of this.oneShotTimers) {
389
+ try { clearTimeout(timer); } catch {}
390
+ }
391
+ this.oneShotTimers.clear();
392
+ }
393
+ /** Arm a when_at one-shot: schedule a timer for the instant. Past-due
394
+ * entries (misfire recovery at start) fire immediately. setTimeout's
395
+ * ~24.8-day (2^31-1 ms) ceiling is handled by re-arming for far-future
396
+ * instants instead of firing early. */
397
+ armOneShot(schedule, type) {
398
+ const fireAt = new Date(schedule.whenAt).getTime();
399
+ if (!Number.isFinite(fireAt)) {
400
+ process.stderr.write(`mixdog scheduler: invalid when_at for "${schedule.name}" (${schedule.whenAt}) — skipped\n`);
401
+ return;
402
+ }
403
+ const delay = fireAt - Date.now();
404
+ const MAX = 2 ** 31 - 1;
405
+ if (delay > MAX) {
406
+ const timer = setTimeout(() => this.armOneShot(schedule, type), MAX);
407
+ this.oneShotTimers.set(schedule.name, timer);
408
+ return;
409
+ }
410
+ const timer = setTimeout(() => this.fireOneShot(schedule, type), Math.max(0, delay));
411
+ this.oneShotTimers.set(schedule.name, timer);
412
+ logSchedule(`armed one-shot "${schedule.name}" at ${new Date(fireAt).toISOString()}${delay <= 0 ? " (misfire → immediate)" : ""}\n`);
413
+ }
414
+ /** markDone with bounded retry. A one-shot that fired but failed to persist
415
+ * status='done' would otherwise stay 'active' with a past when_at and
416
+ * re-arm (duplicate fire) on the next restart/reload, so recovering the
417
+ * done-marker matters — retry a few times with a logged failure. */
418
+ async markDoneWithRetry(name, attempts = 3) {
419
+ for (let i = 0; i < attempts; i++) {
420
+ try { await markDone(name); return true; }
421
+ catch (err) {
422
+ process.stderr.write(`mixdog scheduler: ${name} markDone failed (attempt ${i + 1}/${attempts}): ${err}\n`);
423
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 250));
424
+ }
425
+ }
426
+ return false;
427
+ }
428
+ /** Fire a one-shot exactly once, then mark it done so it never re-arms.
429
+ * Idempotent: if last_fired_at is already set (a prior fire whose markDone
430
+ * failed left the row 'active'), do NOT fire again — only retry markDone so
431
+ * the stuck-active entry stops re-arming/misfiring. */
432
+ async fireOneShot(schedule, type) {
433
+ this.oneShotTimers.delete(schedule.name);
434
+ const now = /* @__PURE__ */ new Date();
435
+ if (schedule.lastFiredAt) {
436
+ logSchedule(`one-shot "${schedule.name}" already fired (last_fired_at set) — retrying markDone only\n`);
437
+ await this.markDoneWithRetry(schedule.name);
438
+ return;
439
+ }
440
+ // when_at one-shots must honor deferred_until / skipped_until just like
441
+ // cron fires (onCronFire) do. Unlike a recurring cron, a one-shot's timer
442
+ // already elapsed and tickAsync is a no-op, so simply returning would mean
443
+ // it never fires until a restart. Re-arm for the deferral expiry (the
444
+ // later of when_at and deferred/skipped-until) so it fires on its own.
445
+ if (this.shouldSkip(schedule.name)) {
446
+ const until = this.skipUntil(schedule.name);
447
+ const fireAt = new Date(schedule.whenAt).getTime();
448
+ const target = Math.max(Number.isFinite(fireAt) ? fireAt : 0, until);
449
+ const MAX = 2 ** 31 - 1;
450
+ let delay = target - Date.now();
451
+ delay = delay > MAX ? MAX : Math.max(delay, 1);
452
+ logSchedule(`one-shot "${schedule.name}" deferred/skipped — re-arming for ${new Date(target).toISOString()}\n`);
453
+ const timer = setTimeout(() => this.fireOneShot(schedule, type), delay);
454
+ this.oneShotTimers.set(schedule.name, timer);
455
+ return;
456
+ }
457
+ // Mark done ONLY after a successful fire. A false/throwing fireTimed
458
+ // (missing prompt/model, running guard, dispatch error) must leave the
459
+ // one-shot pending so a reload/restart can retry it — never retire it.
460
+ try {
461
+ // awaitDispatch: for a one-shot we must wait for the actual LLM/relay
462
+ // dispatch outcome — a truthy fireTimed alone (cron's fire-and-forget
463
+ // contract) would retire the entry even when schedulerLlm() later
464
+ // rejects. Only a resolved dispatch counts as a real fire here.
465
+ const fired = await this.fireTimed(schedule, type, { awaitDispatch: true });
466
+ if (!fired) {
467
+ logSchedule(`one-shot "${schedule.name}" did not fire (skipped/guarded) — leaving pending for retry\n`);
468
+ return;
469
+ }
470
+ this.lastFired.set(schedule.name, now.toISOString());
471
+ schedule.lastFiredAt = now.toISOString();
472
+ try { await markFired(schedule.name, now); }
473
+ catch (err) { process.stderr.write(`mixdog scheduler: ${schedule.name} markFired failed: ${err}\n`); }
474
+ await this.markDoneWithRetry(schedule.name);
475
+ } catch (err) {
476
+ process.stderr.write(`mixdog scheduler: ${schedule.name} one-shot failed: ${err} — leaving pending for retry\n`);
477
+ }
379
478
  }
380
479
  restart() {
381
480
  if (this.tickTimer) {
@@ -397,12 +496,9 @@ ${Scheduler.INSTANCE_UUID}`;
397
496
  this.interactive = interactive.filter((s) => s.enabled !== false);
398
497
  this.channelId = channelId ?? "";
399
498
  this.promptsDir = join(DATA_DIR, "prompts");
400
- if (this.deferred.size > 0 || this.skippedToday.size > 0) {
401
- process.stderr.write(`mixdog scheduler: reload clearing ${this.deferred.size} deferred, ${this.skippedToday.size} skipped
402
- `);
403
- }
404
- this.deferred.clear();
405
- this.skippedToday.clear();
499
+ // Defer/skip state is persisted (deferred_until / skipped_until) and
500
+ // re-read from the reloaded rows, so a reload no longer drops it.
501
+ this.refreshSkipCache();
406
502
  if (options.restart === false) {
407
503
  // Caller owns lifecycle; still drop stale cron bindings so they don't fire against old config.
408
504
  this.destroyCronJobs();
@@ -411,28 +507,17 @@ ${Scheduler.INSTANCE_UUID}`;
411
507
  this.restart();
412
508
  }
413
509
  getStatus() {
414
- const result = [];
415
- for (const s of this.nonInteractive) {
416
- result.push({
417
- name: s.name,
418
- time: s.time,
419
- days: s.days ?? "daily",
420
- type: "non-interactive",
421
- running: false,
422
- lastFired: this.lastFired.get(s.name) ?? null
423
- });
424
- }
425
- for (const s of this.interactive) {
426
- result.push({
427
- name: s.name,
428
- time: s.time,
429
- days: s.days ?? "daily",
430
- type: "interactive",
431
- running: false,
432
- lastFired: this.lastFired.get(s.name) ?? null
433
- });
434
- }
435
- return result;
510
+ const rows = [
511
+ ...this.nonInteractive.map((s) => ({ s, type: "non-interactive" })),
512
+ ...this.interactive.map((s) => ({ s, type: "interactive" })),
513
+ ];
514
+ return rows.map(({ s, type }) => ({
515
+ name: s.name,
516
+ time: s.whenCron ?? s.whenAt ?? null,
517
+ type,
518
+ running: false,
519
+ lastFired: this.lastFired.get(s.name) ?? null,
520
+ }));
436
521
  }
437
522
  async triggerManual(name) {
438
523
  const timed = [...this.nonInteractive, ...this.interactive].find((e) => e.name === name);
@@ -466,86 +551,24 @@ ${Scheduler.INSTANCE_UUID}`;
466
551
  // (registerCronJobs). tick() no longer does any per-day work; the
467
552
  // interval is kept for future cadence-driven work and lifecycle parity.
468
553
  }
469
- /** Day abbreviation → JS day number (0=Sun...6=Sat) */
470
- static DAY_ABBRS = {
471
- sun: 0,
472
- mon: 1,
473
- tue: 2,
474
- wed: 3,
475
- thu: 4,
476
- fri: 5,
477
- sat: 6
478
- };
479
- /** Check if today matches the schedule's days setting */
480
- matchesDays(days, dow, isWeekend) {
481
- if (days === "daily") return true;
482
- if (days === "weekday") return !isWeekend;
483
- if (days === "weekend") return isWeekend;
484
- const dayList = days.split(",").map((d) => d.trim().toLowerCase());
485
- return dayList.some((d) => Scheduler.DAY_ABBRS[d] === dow);
486
- }
487
554
  // ── Fire timed schedule ─────────────────────────────────────────────
488
- async fireTimed(schedule, type) {
489
- const execMode = schedule.exec ?? "prompt";
490
- if (execMode === "script" || execMode === "script+prompt") {
491
- if (!schedule.script) {
492
- process.stderr.write(`mixdog scheduler: no script specified for "${schedule.name}"
493
- `);
494
- return false;
495
- }
496
- if (this.running.has(schedule.name)) return false;
497
- this.running.add(schedule.name);
498
- const channelId2 = this.resolveChannel(schedule.channel);
499
- logSchedule(`firing ${schedule.name} (${type}, exec=${execMode})
500
- `);
501
- try {
502
- const scriptResult = await this.runScript(schedule.script);
503
- if (execMode === "script") {
504
- this.running.delete(schedule.name);
505
- if (scriptResult && this.sendFn) {
506
- await this.sendFn(channelId2, scriptResult).catch(
507
- (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} relay failed: ${err}
508
- `)
509
- );
510
- }
511
- process.stderr.write(`mixdog scheduler: ${schedule.name} script done
512
- `);
513
- return true;
514
- }
515
- const prompt2 = this.loadPrompt(schedule.prompt ?? `${schedule.name}.md`);
516
- if (!prompt2) {
517
- this.running.delete(schedule.name);
518
- process.stderr.write(`mixdog scheduler: prompt not found for "${schedule.name}"
519
- `);
520
- return false;
521
- }
522
- const combinedPrompt = `${prompt2}
523
-
524
- ---
525
- ## Script Output
526
- \`\`\`
527
- ${scriptResult}
528
- \`\`\``;
529
- this.running.delete(schedule.name);
530
- return await this.fireTimedPrompt(schedule, type, combinedPrompt, channelId2);
531
- } catch (err) {
532
- this.running.delete(schedule.name);
533
- process.stderr.write(`mixdog scheduler: ${schedule.name} script error: ${err}
534
- `);
535
- return false;
536
- }
537
- }
555
+ async fireTimed(schedule, type, opts = {}) {
538
556
  const prompt = this.resolvePrompt(schedule);
539
557
  if (!prompt) {
540
558
  process.stderr.write(`mixdog scheduler: prompt not found for "${schedule.name}"
541
559
  `);
542
560
  return false;
543
561
  }
544
- const channelId = this.resolveChannel(schedule.channel);
545
- return await this.fireTimedPrompt(schedule, type, prompt, channelId);
562
+ // target 'channel' (non-interactive) → dispatch to the schedule's
563
+ // channel_id, falling back to the resolved main channel. target 'session'
564
+ // (interactive) → inject into the Lead session with no channel id.
565
+ const channelId = type === "non-interactive"
566
+ ? this.resolveChannel(schedule.channelId)
567
+ : "";
568
+ return await this.fireTimedPrompt(schedule, type, prompt, channelId, opts);
546
569
  }
547
570
  /** Fire a timed schedule with the given prompt content */
548
- async fireTimedPrompt(schedule, type, prompt, channelId) {
571
+ async fireTimedPrompt(schedule, type, prompt, channelId, { awaitDispatch = false } = {}) {
549
572
  logSchedule(`firing ${schedule.name} (${type})
550
573
  `);
551
574
  if (type === "interactive") {
@@ -566,7 +589,11 @@ ${scriptResult}
566
589
  logSchedule(`${schedule.name}: missing required "model" in schedule config — dispatch rejected\n`);
567
590
  return false;
568
591
  }
569
- schedulerLlm({ prompt, preset: presetId, sourceName: schedule.name })
592
+ // Cron (recurring) keeps the fire-and-forget contract: return truthy now
593
+ // and swallow async failures. A one-shot (awaitDispatch) instead awaits
594
+ // the dispatch so a rejected schedulerLlm() propagates and the caller
595
+ // leaves the entry pending for retry instead of retiring it.
596
+ const dispatch = schedulerLlm({ prompt, preset: presetId, sourceName: schedule.name })
570
597
  .then((result) => {
571
598
  this.running.delete(schedule.name);
572
599
  if (result && this.sendFn) {
@@ -575,42 +602,20 @@ ${scriptResult}
575
602
  );
576
603
  }
577
604
  logSchedule(`${schedule.name} done\n`);
605
+ return true;
578
606
  })
579
607
  .catch((err) => {
580
608
  this.running.delete(schedule.name);
581
609
  logSchedule(`${schedule.name} LLM error: ${err.message}\n`);
610
+ if (awaitDispatch) throw err;
611
+ return false;
582
612
  });
613
+ if (awaitDispatch) return await dispatch;
583
614
  return true;
584
615
  }
585
- // ── Script execution (delegates to shared executor) ────────────────
586
- runScript(scriptName) {
587
- return new Promise((resolve, reject) => {
588
- execScript(`schedule:${scriptName}`, scriptName, (result, code) => {
589
- if (code !== 0 && code !== null) {
590
- reject(new Error(`script exited with code ${code}`));
591
- } else {
592
- resolve(result);
593
- }
594
- });
595
- });
596
- }
597
616
  // ── Helpers ─────────────────────────────────────────────────────────
598
- /**
599
- * Map a schedule's `channel` flag to a target channel id. The flag is
600
- * boolean-ish: any non-empty value except "false" (including legacy labels
601
- * like "main") means "post to the main channel"; absent/false → "" (inject
602
- * into the Lead session). No label resolution. A pure-digit / snowflake
603
- * value is honored verbatim as an explicit id override (matches
604
- * resolveWebhookChannelId in index.mjs).
605
- */
606
- resolveChannel(flag) {
607
- if (flag == null) return "";
608
- const v = String(flag).trim();
609
- if (v === "" || v.toLowerCase() === "false") return "";
610
- if (/^-?\d+$/.test(v)) return v;
611
- return this.channelId ?? "";
612
- }
613
- /** Resolve prompt: try file first, fall back to inline text */
617
+ /** Resolve prompt: inline text from the row, with a prompts-dir file
618
+ * fallback for legacy `<name>.md` references. */
614
619
  resolvePrompt(schedule) {
615
620
  const ref = schedule.prompt ?? `${schedule.name}.md`;
616
621
  const fromFile = this.loadPrompt(ref);
@@ -622,6 +627,19 @@ ${scriptResult}
622
627
  const full = isAbsolute(nameOrPath) ? nameOrPath : join(this.promptsDir, nameOrPath);
623
628
  return tryRead(full);
624
629
  }
630
+ /** Resolve a schedule's channel flag to a channel id (pre-refactor
631
+ * semantics): absent/empty/"false" → "" (inject into session); a
632
+ * pure-digit / snowflake value is honored verbatim as an explicit id
633
+ * override; any other value — including legacy labels like "main" —
634
+ * resolves to the configured main channel. */
635
+ resolveChannel(flag) {
636
+ if (flag == null) return this.channelId ?? "";
637
+ const v = String(flag).trim();
638
+ if (v.toLowerCase() === "false") return "";
639
+ if (/^-?\d+$/.test(v)) return v;
640
+ // empty or a legacy label ("main") → configured main channel
641
+ return this.channelId ?? "";
642
+ }
625
643
  }
626
644
  export {
627
645
  Scheduler
@@ -127,6 +127,22 @@ async function computeSnapshot(scheduler) {
127
127
  }
128
128
  }
129
129
 
130
+ // Armed when_at one-shots: next-fire is the entry's whenAt instant. The
131
+ // timer handle carries no fireAt, so read it from the loaded schedule def.
132
+ if (scheduler.oneShotTimers && scheduler.oneShotTimers.size > 0) {
133
+ const defs = [...(scheduler.nonInteractive || []), ...(scheduler.interactive || [])];
134
+ for (const name of scheduler.oneShotTimers.keys()) {
135
+ if (scheduler.shouldSkip && scheduler.shouldSkip(name)) continue;
136
+ const def = defs.find((s) => s.name === name);
137
+ if (!def || !def.whenAt) continue;
138
+ const fireAt = new Date(def.whenAt).getTime();
139
+ if (!isFinite(fireAt)) continue;
140
+ if (!nextSchedule || fireAt < nextSchedule.fireAt) {
141
+ nextSchedule = { name, fireAt, kind: 'one-shot' };
142
+ }
143
+ }
144
+ }
145
+
130
146
  // Deferred entries
131
147
  if (scheduler.deferred) {
132
148
  for (const [name, until] of scheduler.deferred) {