mixdog 0.9.25 → 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 +1 -1
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/skills/setup/SKILL.md +6 -1
- 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/lib/config.mjs +13 -11
- 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 +3 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- 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 +129 -55
- 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/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PG-backed schedules store — the single source of truth for registered
|
|
3
|
+
* schedules (schema `scheduler`, table `scheduler.schedules`).
|
|
4
|
+
*
|
|
5
|
+
* All schedule readers/writers (scheduler.mjs, config.mjs, channel-admin.mjs)
|
|
6
|
+
* go through this module. Legacy `<dataDir>/schedules/<name>/SCHEDULE.md`
|
|
7
|
+
* files are imported once by the migration hook in getDb and the directory is
|
|
8
|
+
* renamed to `schedules.migrated`; the old file-based schedules-store.mjs has
|
|
9
|
+
* been removed.
|
|
10
|
+
*
|
|
11
|
+
* DDL is idempotent and runs once on the first call per process. All queries
|
|
12
|
+
* fully-qualify `scheduler.schedules` so they are correct regardless of the
|
|
13
|
+
* connection search_path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
|
|
17
|
+
import { resolvePluginData } from './plugin-paths.mjs';
|
|
18
|
+
import { readdirSync, readFileSync, renameSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { readMarkdownDocument } from './markdown-frontmatter.mjs';
|
|
21
|
+
|
|
22
|
+
const SCHEMA = 'scheduler';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Lazy connection + one-shot idempotent DDL (keyed per resolved dataDir).
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const _ready = new Map(); // dataDir → Promise<db>
|
|
29
|
+
|
|
30
|
+
const DDL = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS scheduler.schedules (
|
|
32
|
+
name text PRIMARY KEY,
|
|
33
|
+
description text NOT NULL DEFAULT '',
|
|
34
|
+
when_at timestamptz,
|
|
35
|
+
when_cron text,
|
|
36
|
+
timezone text,
|
|
37
|
+
target text NOT NULL CHECK (target IN ('channel','session')),
|
|
38
|
+
channel_id text,
|
|
39
|
+
model text,
|
|
40
|
+
prompt text NOT NULL,
|
|
41
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
42
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
|
|
43
|
+
last_fired_at timestamptz,
|
|
44
|
+
next_fire_at timestamptz,
|
|
45
|
+
deferred_until timestamptz,
|
|
46
|
+
skipped_until timestamptz,
|
|
47
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
48
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
49
|
+
CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
|
|
50
|
+
);
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
async function getDb(dataDir = resolvePluginData()) {
|
|
54
|
+
if (_ready.has(dataDir)) return _ready.get(dataDir);
|
|
55
|
+
const p = (async () => {
|
|
56
|
+
const { db, pool } = await ensurePgInstance(dataDir, { schema: SCHEMA });
|
|
57
|
+
// Serialize the CREATE TABLE across concurrent first-boot processes on the
|
|
58
|
+
// same cluster-global advisory lock the adapter uses for schema bootstrap,
|
|
59
|
+
// so racing first calls can't run the DDL simultaneously.
|
|
60
|
+
await withSchemaBootstrapLock(pool, () => db.exec(DDL));
|
|
61
|
+
await migrateLegacySchedules(db, dataDir);
|
|
62
|
+
return db;
|
|
63
|
+
})();
|
|
64
|
+
_ready.set(dataDir, p);
|
|
65
|
+
try {
|
|
66
|
+
return await p;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
_ready.delete(dataDir); // let the next call retry DDL after a transient failure
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// One-time legacy SCHEDULE.md migration (additive; runs once per dataDir on
|
|
75
|
+
// first getDb, right after DDL). Imports every `<dataDir>/schedules/<name>/
|
|
76
|
+
// SCHEDULE.md` not already present in the table (by name), using the same
|
|
77
|
+
// days->cron folding as the admin write path, then renames the directory to
|
|
78
|
+
// `schedules.migrated` (never deletes user data). Per-entry failures are
|
|
79
|
+
// isolated and never block store readiness.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
// Day-name / keyword -> cron day-of-week number (Sun=0 .. Sat=6).
|
|
83
|
+
const MIGRATE_DAY_TO_DOW = {
|
|
84
|
+
sun: 0, sunday: 0,
|
|
85
|
+
mon: 1, monday: 1,
|
|
86
|
+
tue: 2, tues: 2, tuesday: 2,
|
|
87
|
+
wed: 3, weds: 3, wednesday: 3,
|
|
88
|
+
thu: 4, thur: 4, thurs: 4, thursday: 4,
|
|
89
|
+
fri: 5, friday: 5,
|
|
90
|
+
sat: 6, saturday: 6,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function foldLegacyDaysIntoCron(cron, days) {
|
|
94
|
+
const parts = String(cron || '').trim().split(/\s+/).filter(Boolean);
|
|
95
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
96
|
+
throw new Error(`invalid cron "${cron}"`);
|
|
97
|
+
}
|
|
98
|
+
const raw = String(days || '').trim().toLowerCase();
|
|
99
|
+
const dowIndex = parts.length - 1;
|
|
100
|
+
// days absent -> keep the cron's own day-of-week field ('0 9 * * 1' stays
|
|
101
|
+
// Monday-only). Only an explicit selector rewrites the dow field.
|
|
102
|
+
if (!raw) return parts.join(' ');
|
|
103
|
+
let dow;
|
|
104
|
+
if (raw === 'daily' || raw === 'everyday' || raw === 'every day') dow = '*';
|
|
105
|
+
else if (raw === 'weekday' || raw === 'weekdays') dow = '1-5';
|
|
106
|
+
else if (raw === 'weekend' || raw === 'weekends') dow = '0,6';
|
|
107
|
+
else {
|
|
108
|
+
const nums = raw.split(/[\s,]+/).filter(Boolean).map((t) => (
|
|
109
|
+
/^[0-6]$/.test(t) ? Number(t) : MIGRATE_DAY_TO_DOW[t]
|
|
110
|
+
));
|
|
111
|
+
if (nums.some((n) => n === undefined)) {
|
|
112
|
+
throw new Error(`days "${days}" is not a recognizable day selector`);
|
|
113
|
+
}
|
|
114
|
+
dow = nums.join(',');
|
|
115
|
+
}
|
|
116
|
+
parts[dowIndex] = dow;
|
|
117
|
+
return parts.join(' ');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function migrateLegacySchedules(db, dataDir) {
|
|
121
|
+
const dir = join(dataDir, 'schedules');
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return; // no legacy schedules/ dir -> nothing to migrate
|
|
127
|
+
}
|
|
128
|
+
let imported = 0;
|
|
129
|
+
let skipped = 0;
|
|
130
|
+
const failed = [];
|
|
131
|
+
for (const ent of entries) {
|
|
132
|
+
if (!ent.isDirectory()) continue;
|
|
133
|
+
const name = ent.name;
|
|
134
|
+
try {
|
|
135
|
+
const { rows } = await db.query('SELECT 1 FROM scheduler.schedules WHERE name = $1', [name]);
|
|
136
|
+
if (rows.length) { skipped++; continue; }
|
|
137
|
+
let md;
|
|
138
|
+
try { md = readFileSync(join(dir, name, 'SCHEDULE.md'), 'utf8'); }
|
|
139
|
+
catch { skipped++; continue; }
|
|
140
|
+
const { frontmatter, body } = readMarkdownDocument(md);
|
|
141
|
+
const cron = foldLegacyDaysIntoCron(frontmatter.time, frontmatter.days);
|
|
142
|
+
const channel = String(frontmatter.channel || '').trim();
|
|
143
|
+
const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
|
|
144
|
+
await db.query(
|
|
145
|
+
`INSERT INTO scheduler.schedules
|
|
146
|
+
(name, description, when_cron, timezone, target, channel_id, model, prompt, enabled)
|
|
147
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
148
|
+
ON CONFLICT (name) DO NOTHING`,
|
|
149
|
+
[
|
|
150
|
+
name,
|
|
151
|
+
String(frontmatter.description || '').trim(),
|
|
152
|
+
cron,
|
|
153
|
+
frontmatter.timezone ? String(frontmatter.timezone).trim() : null,
|
|
154
|
+
channel ? 'channel' : 'session',
|
|
155
|
+
channel || null,
|
|
156
|
+
frontmatter.model ? String(frontmatter.model).trim() : null,
|
|
157
|
+
String(body || '').trim(),
|
|
158
|
+
enabled,
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
imported++;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
failed.push(name);
|
|
164
|
+
console.error(`[schedules] migration failed for "${name}": ${err?.message || err}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (failed.length) {
|
|
168
|
+
// Some entries failed to import. Leave schedules/ in place (do NOT rename)
|
|
169
|
+
// so the next boot retries them; already-imported entries are skipped by
|
|
170
|
+
// the name check above, so retry is idempotent.
|
|
171
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s), ${failed.length} failed (${failed.join(', ')}); leaving schedules/ for retry`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
let target = `${dir}.migrated`;
|
|
176
|
+
if (existsSync(target)) target = `${dir}.migrated-${Date.now()}`;
|
|
177
|
+
renameSync(dir, target);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s) but could not rename schedules/: ${err?.message || err}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
console.error(`[schedules] migrated ${imported} legacy schedule(s) (${skipped} skipped); renamed schedules/ -> schedules.migrated`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Row <-> def mapping
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function rowToDef(row) {
|
|
190
|
+
if (!row) return null;
|
|
191
|
+
return {
|
|
192
|
+
name: row.name,
|
|
193
|
+
description: row.description,
|
|
194
|
+
whenAt: row.when_at,
|
|
195
|
+
whenCron: row.when_cron,
|
|
196
|
+
timezone: row.timezone,
|
|
197
|
+
target: row.target,
|
|
198
|
+
channelId: row.channel_id,
|
|
199
|
+
model: row.model,
|
|
200
|
+
prompt: row.prompt,
|
|
201
|
+
enabled: row.enabled,
|
|
202
|
+
status: row.status,
|
|
203
|
+
lastFiredAt: row.last_fired_at,
|
|
204
|
+
nextFireAt: row.next_fire_at,
|
|
205
|
+
deferredUntil: row.deferred_until,
|
|
206
|
+
skippedUntil: row.skipped_until,
|
|
207
|
+
createdAt: row.created_at,
|
|
208
|
+
updatedAt: row.updated_at,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Public API
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export async function listSchedules({ dataDir } = {}) {
|
|
219
|
+
const db = await getDb(dataDir);
|
|
220
|
+
const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules ORDER BY name`);
|
|
221
|
+
return rows.map(rowToDef);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function getSchedule(name, { dataDir } = {}) {
|
|
225
|
+
const db = await getDb(dataDir);
|
|
226
|
+
const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules WHERE name = $1`, [name]);
|
|
227
|
+
return rowToDef(rows[0]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Insert-or-replace a schedule by name. Exactly one of `whenAt`/`whenCron`
|
|
232
|
+
* must be provided (enforced by the table's XOR CHECK constraint).
|
|
233
|
+
*/
|
|
234
|
+
export async function upsertSchedule(def, { dataDir } = {}) {
|
|
235
|
+
if (!def || !def.name) throw new Error('upsertSchedule: def.name is required');
|
|
236
|
+
if (!def.prompt) throw new Error('upsertSchedule: def.prompt is required');
|
|
237
|
+
const db = await getDb(dataDir);
|
|
238
|
+
const params = [
|
|
239
|
+
def.name,
|
|
240
|
+
def.description ?? '',
|
|
241
|
+
def.whenAt ?? null,
|
|
242
|
+
def.whenCron ?? null,
|
|
243
|
+
def.timezone ?? null,
|
|
244
|
+
def.target,
|
|
245
|
+
def.channelId ?? null,
|
|
246
|
+
def.model ?? null,
|
|
247
|
+
def.prompt,
|
|
248
|
+
def.enabled ?? true,
|
|
249
|
+
def.status ?? 'active',
|
|
250
|
+
def.nextFireAt ?? null,
|
|
251
|
+
];
|
|
252
|
+
const { rows } = await db.query(
|
|
253
|
+
`INSERT INTO scheduler.schedules
|
|
254
|
+
(name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, next_fire_at)
|
|
255
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
|
256
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
257
|
+
description = EXCLUDED.description,
|
|
258
|
+
when_at = EXCLUDED.when_at,
|
|
259
|
+
when_cron = EXCLUDED.when_cron,
|
|
260
|
+
timezone = EXCLUDED.timezone,
|
|
261
|
+
target = EXCLUDED.target,
|
|
262
|
+
channel_id = EXCLUDED.channel_id,
|
|
263
|
+
model = EXCLUDED.model,
|
|
264
|
+
prompt = EXCLUDED.prompt,
|
|
265
|
+
enabled = EXCLUDED.enabled,
|
|
266
|
+
next_fire_at = EXCLUDED.next_fire_at,
|
|
267
|
+
-- Redefinition clears stale runtime state and reactivates so the
|
|
268
|
+
-- re-registered schedule is due again in listDue.
|
|
269
|
+
status = 'active',
|
|
270
|
+
deferred_until = NULL,
|
|
271
|
+
skipped_until = NULL,
|
|
272
|
+
last_fired_at = NULL,
|
|
273
|
+
updated_at = now()
|
|
274
|
+
RETURNING ${COLS}`,
|
|
275
|
+
params,
|
|
276
|
+
);
|
|
277
|
+
return rowToDef(rows[0]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function deleteSchedule(name, { dataDir } = {}) {
|
|
281
|
+
const db = await getDb(dataDir);
|
|
282
|
+
const { rowCount } = await db.query(`DELETE FROM scheduler.schedules WHERE name = $1`, [name]);
|
|
283
|
+
return rowCount > 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function setEnabled(name, enabled, { dataDir } = {}) {
|
|
287
|
+
const db = await getDb(dataDir);
|
|
288
|
+
const { rows } = await db.query(
|
|
289
|
+
`UPDATE scheduler.schedules SET enabled = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
290
|
+
[name, !!enabled],
|
|
291
|
+
);
|
|
292
|
+
return rowToDef(rows[0]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function markFired(name, ts = new Date(), { dataDir } = {}) {
|
|
296
|
+
const db = await getDb(dataDir);
|
|
297
|
+
const { rows } = await db.query(
|
|
298
|
+
`UPDATE scheduler.schedules SET last_fired_at = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
299
|
+
[name, ts],
|
|
300
|
+
);
|
|
301
|
+
return rowToDef(rows[0]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function markDone(name, { dataDir } = {}) {
|
|
305
|
+
const db = await getDb(dataDir);
|
|
306
|
+
const { rows } = await db.query(
|
|
307
|
+
`UPDATE scheduler.schedules SET status = 'done', next_fire_at = NULL, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
308
|
+
[name],
|
|
309
|
+
);
|
|
310
|
+
return rowToDef(rows[0]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function setDeferred(name, untilTs, { dataDir } = {}) {
|
|
314
|
+
const db = await getDb(dataDir);
|
|
315
|
+
const { rows } = await db.query(
|
|
316
|
+
`UPDATE scheduler.schedules SET deferred_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
317
|
+
[name, untilTs ?? null],
|
|
318
|
+
);
|
|
319
|
+
return rowToDef(rows[0]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function setSkippedUntil(name, ts, { dataDir } = {}) {
|
|
323
|
+
const db = await getDb(dataDir);
|
|
324
|
+
const { rows } = await db.query(
|
|
325
|
+
`UPDATE scheduler.schedules SET skipped_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
|
|
326
|
+
[name, ts ?? null],
|
|
327
|
+
);
|
|
328
|
+
return rowToDef(rows[0]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Schedules that are eligible to fire at `now`: active, enabled, with a
|
|
333
|
+
* next_fire_at at/before `now`, and not currently deferred or skipped past
|
|
334
|
+
* `now`.
|
|
335
|
+
*/
|
|
336
|
+
export async function listDue(now = new Date(), { dataDir } = {}) {
|
|
337
|
+
const db = await getDb(dataDir);
|
|
338
|
+
const { rows } = await db.query(
|
|
339
|
+
`SELECT ${COLS} FROM scheduler.schedules
|
|
340
|
+
WHERE status = 'active'
|
|
341
|
+
AND enabled = true
|
|
342
|
+
AND next_fire_at IS NOT NULL
|
|
343
|
+
AND next_fire_at <= $1
|
|
344
|
+
AND (deferred_until IS NULL OR deferred_until <= $1)
|
|
345
|
+
AND (skipped_until IS NULL OR skipped_until <= $1)
|
|
346
|
+
ORDER BY next_fire_at`,
|
|
347
|
+
[now],
|
|
348
|
+
);
|
|
349
|
+
return rows.map(rowToDef);
|
|
350
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Per-service, SINGLE-WRITER port-advert files under RUNTIME_ROOT/discovery/.
|
|
2
|
+
//
|
|
3
|
+
// Motivation: service port discovery (memory_port, pg_port, ...) used to ride
|
|
4
|
+
// inside the shared active-instance.json, whose write path takes a .lock. With
|
|
5
|
+
// multiple terminals heartbeating and slow Windows renames, that lock could be
|
|
6
|
+
// held long enough to starve a port advertise for an hour+. Splitting each
|
|
7
|
+
// service's advert into its own file, written by its single owner via a plain
|
|
8
|
+
// atomic rename (NO .lock), removes the shared-lock contention entirely.
|
|
9
|
+
//
|
|
10
|
+
// Each advert is stamped with the owner pid and updatedAt. Readers prefer the
|
|
11
|
+
// discovery file (validating the owner pid is still alive) and callers may fall
|
|
12
|
+
// back to the legacy active-instance fields for cross-version compat.
|
|
13
|
+
import { readFileSync, unlinkSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { writeJsonAtomicSync } from './atomic-file.mjs'
|
|
17
|
+
|
|
18
|
+
export function discoveryRoot() {
|
|
19
|
+
const root = process.env.MIXDOG_RUNTIME_ROOT
|
|
20
|
+
? resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
21
|
+
: join(tmpdir(), 'mixdog')
|
|
22
|
+
return join(root, 'discovery')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function discoveryPath(service) {
|
|
26
|
+
return join(discoveryRoot(), `${service}.json`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsePid(value) {
|
|
30
|
+
const pid = Number(value)
|
|
31
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isPidAlive(value) {
|
|
35
|
+
const pid = parsePid(value)
|
|
36
|
+
if (!pid) return false
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0)
|
|
39
|
+
return true
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// EPERM: process exists but we lack permission → alive. ESRCH → dead.
|
|
42
|
+
return e?.code === 'EPERM'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Per-process "unreachable port" distrust set: service → { port, updatedAt,
|
|
47
|
+
// pid, ts } a consumer WITHOUT its own health probe proved dead this process
|
|
48
|
+
// (connect-failure). pid-liveness alone cannot catch a recycled owner pid: the
|
|
49
|
+
// advert's pid can be alive as an UNRELATED process while its advertised port
|
|
50
|
+
// is dead, so a pid-validated advert would keep routing consumers to a corpse
|
|
51
|
+
// port and suppress the legacy fallback. Consumers call markServiceUnreachable()
|
|
52
|
+
// on a CONNECTION-level failure (see isConnRefuseError); readLiveServiceAdvert
|
|
53
|
+
// then skips THAT port so the caller falls back to legacy/buffer.
|
|
54
|
+
//
|
|
55
|
+
// The entry records the advert IDENTITY at mark time (updatedAt + pid). A
|
|
56
|
+
// daemon restarted on the SAME port re-stamps updatedAt / gets a new pid, so
|
|
57
|
+
// readLiveServiceAdvert detects the change and clears the distrust — the old
|
|
58
|
+
// port-only key otherwise stayed distrusted for the whole process lifetime. A
|
|
59
|
+
// short TTL is a second self-heal so any residual false positive expires.
|
|
60
|
+
const _DISTRUST_TTL_MS = 30_000
|
|
61
|
+
const _unreachablePorts = new Map()
|
|
62
|
+
|
|
63
|
+
// Connection-level failure classifier for distrust decisions: only "nothing is
|
|
64
|
+
// listening / socket died on connect" justifies distrusting an advert port.
|
|
65
|
+
// Timeouts (slow-but-alive daemon) and HTTP/handshake status errors must NOT —
|
|
66
|
+
// they would false-distrust a healthy service.
|
|
67
|
+
const _CONN_REFUSE_CODES = new Set([
|
|
68
|
+
'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH',
|
|
69
|
+
])
|
|
70
|
+
export function isConnRefuseError(err) {
|
|
71
|
+
if (!err) return false
|
|
72
|
+
const code = String(err.code || err.cause?.code || '')
|
|
73
|
+
if (_CONN_REFUSE_CODES.has(code)) return true
|
|
74
|
+
const msg = String(err.message || err.cause?.message || '')
|
|
75
|
+
return /socket hang up|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|ENETUNREACH/i.test(msg)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function markServiceUnreachable(service, port) {
|
|
79
|
+
const p = Number(port)
|
|
80
|
+
if (!Number.isInteger(p) || p <= 0) return
|
|
81
|
+
// Snapshot the CURRENT advert identity so a later restart on the same port is
|
|
82
|
+
// detected (newer updatedAt / different pid) and the distrust auto-clears.
|
|
83
|
+
const raw = readServiceAdvert(service)
|
|
84
|
+
const same = raw && Number(raw.port) === p
|
|
85
|
+
_unreachablePorts.set(service, {
|
|
86
|
+
port: p,
|
|
87
|
+
updatedAt: same ? (Number(raw.updatedAt) || 0) : 0,
|
|
88
|
+
pid: same && raw.pid != null ? Number(raw.pid) : null,
|
|
89
|
+
ts: Date.now(),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Raw read of a service advert (no validation). Returns the parsed object or
|
|
94
|
+
// null when absent/unreadable/partial (mid-rename).
|
|
95
|
+
export function readServiceAdvert(service) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(readFileSync(discoveryPath(service), 'utf8'))
|
|
98
|
+
return raw && typeof raw === 'object' ? raw : null
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validated read: returns the advert only when it carries a live port whose
|
|
105
|
+
// owner pid (field `pid`) is still alive. A dead owner → null (stale advert).
|
|
106
|
+
export function readLiveServiceAdvert(service, { requirePid = true } = {}) {
|
|
107
|
+
const raw = readServiceAdvert(service)
|
|
108
|
+
if (!raw) return null
|
|
109
|
+
const port = Number(raw.port)
|
|
110
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null
|
|
111
|
+
// A port a consumer already proved unreachable this process is treated as an
|
|
112
|
+
// invalid advert (recycled-pid corpse guard) → fall back to legacy/buffer.
|
|
113
|
+
// Self-heal: a restart on the same port re-stamps updatedAt / changes pid,
|
|
114
|
+
// and a stale entry ages out — either clears the distrust and re-trusts.
|
|
115
|
+
const distrust = _unreachablePorts.get(service)
|
|
116
|
+
if (distrust && distrust.port === port) {
|
|
117
|
+
const curUpdatedAt = Number(raw.updatedAt) || 0
|
|
118
|
+
const curPid = raw.pid != null ? Number(raw.pid) : null
|
|
119
|
+
const restarted = curUpdatedAt > distrust.updatedAt || curPid !== distrust.pid
|
|
120
|
+
const expired = Date.now() - distrust.ts > _DISTRUST_TTL_MS
|
|
121
|
+
if (restarted || expired) _unreachablePorts.delete(service)
|
|
122
|
+
else return null
|
|
123
|
+
}
|
|
124
|
+
if (raw.pid != null) {
|
|
125
|
+
if (!isPidAlive(raw.pid)) return null
|
|
126
|
+
} else if (requirePid) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
return raw
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Convenience: live port only (or null).
|
|
133
|
+
export function readServicePort(service, opts) {
|
|
134
|
+
const raw = readLiveServiceAdvert(service, opts)
|
|
135
|
+
return raw ? Number(raw.port) : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Single-writer full-replace advert. Plain atomic rename, NO .lock. Always
|
|
139
|
+
// stamps updatedAt; callers pass port/pid + any extra metadata fields.
|
|
140
|
+
export function writeServiceAdvert(service, fields = {}) {
|
|
141
|
+
const file = discoveryPath(service)
|
|
142
|
+
writeJsonAtomicSync(
|
|
143
|
+
file,
|
|
144
|
+
{ ...fields, updatedAt: Date.now() },
|
|
145
|
+
{ compact: true, fsyncDir: true, renameFallback: 'truncate' },
|
|
146
|
+
)
|
|
147
|
+
return file
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Single-writer merge patch: read current advert, apply fields (null/undefined
|
|
151
|
+
// value ⇒ delete key), re-stamp updatedAt. When the merged result no longer
|
|
152
|
+
// advertises a port, the file is removed (clean shutdown). Read-modify-write is
|
|
153
|
+
// race-free enough for a genuine single owner (only the service's supervisor
|
|
154
|
+
// writes its own file).
|
|
155
|
+
export function patchServiceAdvert(service, fields = {}) {
|
|
156
|
+
const cur = readServiceAdvert(service) ?? {}
|
|
157
|
+
const merged = { ...cur }
|
|
158
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
159
|
+
if (v == null) delete merged[k]
|
|
160
|
+
else merged[k] = v
|
|
161
|
+
}
|
|
162
|
+
const port = Number(merged.port)
|
|
163
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
164
|
+
// No live port left → drop the advert entirely rather than leaving a husk.
|
|
165
|
+
try { unlinkSync(discoveryPath(service)) } catch {}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
return writeServiceAdvert(service, merged)
|
|
169
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Centralized child_process spawn/fork flag presets (win32 console-leak fix).
|
|
2
|
+
//
|
|
3
|
+
// win32 rule: DETACHED_PROCESS (Node `detached:true`) makes the OS IGNORE
|
|
4
|
+
// CREATE_NO_WINDOW (Node `windowsHide:true`), so every descendant allocates
|
|
5
|
+
// its own VISIBLE console window. On win32 we therefore NEVER set
|
|
6
|
+
// `detached:true` for hidden background spawns — `windowsHide` alone gives the
|
|
7
|
+
// child a hidden own console that descendants inherit; it survives the parent
|
|
8
|
+
// console closing, and Windows does not kill children on parent exit anyway.
|
|
9
|
+
//
|
|
10
|
+
// POSIX has no console concept, so `windowsHide` is a no-op there and
|
|
11
|
+
// `detached:true` stays REQUIRED: it makes the child a process-group leader so
|
|
12
|
+
// the whole tree can be signalled / tree-killed.
|
|
13
|
+
//
|
|
14
|
+
// Modeled on hermes-agent's _subprocess_compat two-mode split.
|
|
15
|
+
const isWin = process.platform === 'win32';
|
|
16
|
+
|
|
17
|
+
// Background daemon / process-tree spawns: hidden window everywhere, plus
|
|
18
|
+
// process-group detachment on POSIX only (dropped on win32 per the rule above).
|
|
19
|
+
export const detachedSpawnOpts = Object.freeze({
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
...(isWin ? {} : { detached: true }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Short-lived helper spawns that only need the console window hidden on win32.
|
|
25
|
+
export const hiddenSpawnOpts = Object.freeze({
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
@@ -54,7 +54,9 @@ export function titleCaseMcpServer(server) {
|
|
|
54
54
|
return String(server || '')
|
|
55
55
|
.split(/[_\s-]+/)
|
|
56
56
|
.filter(Boolean)
|
|
57
|
-
.map((part) =>
|
|
57
|
+
.map((part) => (part === part.toLowerCase()
|
|
58
|
+
? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`
|
|
59
|
+
: part))
|
|
58
60
|
.join(' ');
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -492,8 +492,14 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
492
492
|
case 'web_search_call':
|
|
493
493
|
return unitDescriptor('Web Research', { count: queryCount(a, 'query', 'queries', 'keywords') || 1, noun: 'query', pluralNoun: 'queries' });
|
|
494
494
|
case 'web_fetch':
|
|
495
|
-
case 'fetch':
|
|
496
495
|
return unitDescriptor('Web Research', { count: queryCount(a, 'url', 'urls', 'uri', 'uris') || 1, active: 'Fetching', done: 'Fetched', noun: 'URL', pluralNoun: 'URLs' });
|
|
496
|
+
case 'fetch': {
|
|
497
|
+
const fetchLimit = Number(a.limit ?? a.messages);
|
|
498
|
+
const fetchCount = Number.isFinite(fetchLimit) && fetchLimit > 0
|
|
499
|
+
? Math.floor(fetchLimit)
|
|
500
|
+
: queryCount(a, 'messages') || 1;
|
|
501
|
+
return unitDescriptor('Web Research', { count: fetchCount, active: 'Fetching', done: 'Fetched', noun: 'message' });
|
|
502
|
+
}
|
|
497
503
|
case 'recall':
|
|
498
504
|
case 'recall_memory':
|
|
499
505
|
case 'search_memories':
|
|
@@ -501,8 +507,15 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
501
507
|
case 'remember':
|
|
502
508
|
case 'save_memory':
|
|
503
509
|
case 'update_memory':
|
|
504
|
-
case 'memory':
|
|
505
510
|
return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Writing', done: 'Wrote', noun: 'memory item' });
|
|
511
|
+
case 'memory': {
|
|
512
|
+
const action = String(a.action || '').toLowerCase();
|
|
513
|
+
const op = String(a.op || '').toLowerCase();
|
|
514
|
+
const isMutation = op === 'add' || op === 'edit' || op === 'delete' || op === 'promote' || op === 'dismiss'
|
|
515
|
+
|| (action === 'core' && !op);
|
|
516
|
+
if (isMutation) return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Writing', done: 'Wrote', noun: 'memory item' });
|
|
517
|
+
return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Checking', done: 'Checked', noun: 'memory item' });
|
|
518
|
+
}
|
|
506
519
|
case 'explore':
|
|
507
520
|
return unitDescriptor('Explore', { count: queryCount(a, 'query', 'queries', 'prompt', 'task', 'goal') || 1, noun: 'query', pluralNoun: 'queries' });
|
|
508
521
|
case 'shell':
|
|
@@ -514,8 +527,11 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
514
527
|
case 'agent':
|
|
515
528
|
case 'bridge':
|
|
516
529
|
return unitDescriptor('Agent', { count: queryCount(a, 'agents', 'roles', 'role', 'tag', 'task_id', 'sessionId') || 1, noun: 'agent' });
|
|
517
|
-
case 'task':
|
|
530
|
+
case 'task': {
|
|
531
|
+
const action = String(a.action || '').toLowerCase();
|
|
532
|
+
if (action === 'cancel') return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, active: 'Cancelling', done: 'Cancelled', noun: 'task' });
|
|
518
533
|
return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, noun: 'task' });
|
|
534
|
+
}
|
|
519
535
|
case 'skill':
|
|
520
536
|
case 'skill_execute':
|
|
521
537
|
case 'skill_view':
|