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.
- package/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- 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 {
|
|
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
|
-
/**
|
|
148
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
177
|
-
if (
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
if (
|
|
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
|
|
316
|
-
*
|
|
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 (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
338
|
-
*
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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) {
|