@femtomc/mu-server 26.2.41 → 26.2.43

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.
@@ -1,6 +1,7 @@
1
- import { ApprovedCommandBroker, CommandContextResolver, MessagingOperatorRuntime, PiMessagingOperatorBackend, serveExtensionPaths, } from "@femtomc/mu-agent";
2
- import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneOutboxDispatcher, ControlPlaneRuntime, DiscordControlPlaneAdapter, getControlPlanePaths, SlackControlPlaneAdapter, TelegramControlPlaneAdapter, } from "@femtomc/mu-control-plane";
1
+ import { ApprovedCommandBroker, CommandContextResolver, MessagingOperatorRuntime, PiMessagingOperatorBackend, operatorExtensionPaths, } from "@femtomc/mu-agent";
2
+ import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneOutboxDispatcher, correlationFromCommandRecord, ControlPlaneRuntime, DiscordControlPlaneAdapter, getControlPlanePaths, SlackControlPlaneAdapter, TelegramControlPlaneAdapter, } from "@femtomc/mu-control-plane";
3
3
  import { DEFAULT_MU_CONFIG } from "./config.js";
4
+ import { ControlPlaneRunSupervisor, } from "./run_supervisor.js";
4
5
  export function detectAdapters(config) {
5
6
  const adapters = [];
6
7
  const slackSecret = config.adapters.slack.signing_secret;
@@ -22,6 +23,57 @@ export function detectAdapters(config) {
22
23
  }
23
24
  return adapters;
24
25
  }
26
+ function sha256Hex(input) {
27
+ const hasher = new Bun.CryptoHasher("sha256");
28
+ hasher.update(input);
29
+ return hasher.digest("hex");
30
+ }
31
+ function outboxKindForRunEvent(kind) {
32
+ switch (kind) {
33
+ case "run_completed":
34
+ return "result";
35
+ case "run_failed":
36
+ return "error";
37
+ default:
38
+ return "lifecycle";
39
+ }
40
+ }
41
+ async function enqueueRunEventOutbox(opts) {
42
+ const command = opts.event.command;
43
+ if (!command) {
44
+ return null;
45
+ }
46
+ const baseCorrelation = correlationFromCommandRecord(command);
47
+ const correlation = {
48
+ ...baseCorrelation,
49
+ run_root_id: opts.event.run.root_issue_id ?? baseCorrelation.run_root_id,
50
+ };
51
+ const envelope = {
52
+ v: 1,
53
+ ts_ms: opts.nowMs,
54
+ channel: command.channel,
55
+ channel_tenant_id: command.channel_tenant_id,
56
+ channel_conversation_id: command.channel_conversation_id,
57
+ request_id: command.request_id,
58
+ response_id: `resp-${sha256Hex(`run-event:${opts.event.run.job_id}:${opts.event.seq}:${opts.nowMs}`).slice(0, 20)}`,
59
+ kind: outboxKindForRunEvent(opts.event.kind),
60
+ body: opts.event.message,
61
+ correlation,
62
+ metadata: {
63
+ async_run: true,
64
+ run_event_kind: opts.event.kind,
65
+ run_event_seq: opts.event.seq,
66
+ run: opts.event.run,
67
+ },
68
+ };
69
+ const decision = await opts.outbox.enqueue({
70
+ dedupeKey: `run-event:${opts.event.run.job_id}:${opts.event.seq}`,
71
+ envelope,
72
+ nowMs: opts.nowMs,
73
+ maxAttempts: 6,
74
+ });
75
+ return decision.record;
76
+ }
25
77
  /**
26
78
  * Telegram supports a markdown dialect that uses single markers for emphasis.
27
79
  * Normalize the most common LLM/GitHub-style markers (`**bold**`, `__italic__`, headings)
@@ -51,8 +103,20 @@ export function renderTelegramMarkdown(text) {
51
103
  }
52
104
  return out.join("\n");
53
105
  }
106
+ const TELEGRAM_MATH_PATTERNS = [
107
+ /\$\$[\s\S]+?\$\$/m,
108
+ /(^|[^\\])\$[^$\n]+\$/m,
109
+ /\\\([\s\S]+?\\\)/m,
110
+ /\\\[[\s\S]+?\\\]/m,
111
+ ];
112
+ export function containsTelegramMathNotation(text) {
113
+ if (text.trim().length === 0) {
114
+ return false;
115
+ }
116
+ return TELEGRAM_MATH_PATTERNS.some((pattern) => pattern.test(text));
117
+ }
54
118
  export function buildTelegramSendMessagePayload(opts) {
55
- if (!opts.richFormatting) {
119
+ if (!opts.richFormatting || containsTelegramMathNotation(opts.text)) {
56
120
  return {
57
121
  chat_id: opts.chatId,
58
122
  text: opts.text,
@@ -72,6 +136,7 @@ async function postTelegramMessage(botToken, payload) {
72
136
  body: JSON.stringify(payload),
73
137
  });
74
138
  }
139
+ const OUTBOX_DRAIN_INTERVAL_MS = 500;
75
140
  function buildMessagingOperatorRuntime(opts) {
76
141
  if (!opts.config.operator.enabled) {
77
142
  return null;
@@ -80,7 +145,7 @@ function buildMessagingOperatorRuntime(opts) {
80
145
  new PiMessagingOperatorBackend({
81
146
  provider: opts.config.operator.provider ?? undefined,
82
147
  model: opts.config.operator.model ?? undefined,
83
- extensionPaths: serveExtensionPaths,
148
+ extensionPaths: operatorExtensionPaths,
84
149
  });
85
150
  return new MessagingOperatorRuntime({
86
151
  backend,
@@ -100,7 +165,9 @@ export async function bootstrapControlPlane(opts) {
100
165
  const paths = getControlPlanePaths(opts.repoRoot);
101
166
  const runtime = new ControlPlaneRuntime({ repoRoot: opts.repoRoot });
102
167
  let pipeline = null;
168
+ let runSupervisor = null;
103
169
  let drainInterval = null;
170
+ const adapterMap = new Map();
104
171
  try {
105
172
  await runtime.start();
106
173
  const operator = opts.operatorRuntime !== undefined
@@ -110,12 +177,123 @@ export async function bootstrapControlPlane(opts) {
110
177
  config: controlPlaneConfig,
111
178
  backend: opts.operatorBackend,
112
179
  });
113
- pipeline = new ControlPlaneCommandPipeline({ runtime, operator });
114
- await pipeline.start();
115
180
  const outbox = new ControlPlaneOutbox(paths.outboxPath);
116
181
  await outbox.load();
182
+ let scheduleOutboxDrainRef = null;
183
+ runSupervisor = new ControlPlaneRunSupervisor({
184
+ repoRoot: opts.repoRoot,
185
+ heartbeatScheduler: opts.heartbeatScheduler,
186
+ onEvent: async (event) => {
187
+ const outboxRecord = await enqueueRunEventOutbox({
188
+ outbox,
189
+ event,
190
+ nowMs: Math.trunc(Date.now()),
191
+ });
192
+ if (outboxRecord) {
193
+ scheduleOutboxDrainRef?.();
194
+ }
195
+ },
196
+ });
197
+ pipeline = new ControlPlaneCommandPipeline({
198
+ runtime,
199
+ operator,
200
+ mutationExecutor: async (record) => {
201
+ if (record.target_type === "run start" || record.target_type === "run resume") {
202
+ try {
203
+ const launched = await runSupervisor?.startFromCommand(record);
204
+ if (!launched) {
205
+ return null;
206
+ }
207
+ return {
208
+ terminalState: "completed",
209
+ result: {
210
+ ok: true,
211
+ async_run: true,
212
+ run_job_id: launched.job_id,
213
+ run_root_id: launched.root_issue_id,
214
+ run_status: launched.status,
215
+ run_mode: launched.mode,
216
+ run_source: launched.source,
217
+ },
218
+ trace: {
219
+ cliCommandKind: launched.mode,
220
+ runRootId: launched.root_issue_id,
221
+ },
222
+ mutatingEvents: [
223
+ {
224
+ eventType: "run.supervisor.start",
225
+ payload: {
226
+ run_job_id: launched.job_id,
227
+ run_mode: launched.mode,
228
+ run_root_id: launched.root_issue_id,
229
+ run_source: launched.source,
230
+ },
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ catch (err) {
236
+ return {
237
+ terminalState: "failed",
238
+ errorCode: err instanceof Error && err.message ? err.message : "run_supervisor_start_failed",
239
+ trace: {
240
+ cliCommandKind: record.target_type.replaceAll(" ", "_"),
241
+ runRootId: record.target_id,
242
+ },
243
+ };
244
+ }
245
+ }
246
+ if (record.target_type === "run interrupt") {
247
+ const result = runSupervisor?.interrupt({
248
+ rootIssueId: record.target_id,
249
+ }) ?? { ok: false, reason: "not_found", run: null };
250
+ if (!result.ok) {
251
+ return {
252
+ terminalState: "failed",
253
+ errorCode: result.reason ?? "run_interrupt_failed",
254
+ trace: {
255
+ cliCommandKind: "run_interrupt",
256
+ runRootId: result.run?.root_issue_id ?? record.target_id,
257
+ },
258
+ mutatingEvents: [
259
+ {
260
+ eventType: "run.supervisor.interrupt.failed",
261
+ payload: {
262
+ reason: result.reason,
263
+ target: record.target_id,
264
+ },
265
+ },
266
+ ],
267
+ };
268
+ }
269
+ return {
270
+ terminalState: "completed",
271
+ result: {
272
+ ok: true,
273
+ async_run: true,
274
+ interrupted: true,
275
+ run: result.run,
276
+ },
277
+ trace: {
278
+ cliCommandKind: "run_interrupt",
279
+ runRootId: result.run?.root_issue_id ?? record.target_id,
280
+ },
281
+ mutatingEvents: [
282
+ {
283
+ eventType: "run.supervisor.interrupt",
284
+ payload: {
285
+ target: record.target_id,
286
+ run: result.run,
287
+ },
288
+ },
289
+ ],
290
+ };
291
+ }
292
+ return null;
293
+ },
294
+ });
295
+ await pipeline.start();
117
296
  let telegramBotToken = null;
118
- const adapterMap = new Map();
119
297
  for (const d of detected) {
120
298
  let adapter;
121
299
  switch (d.name) {
@@ -139,6 +317,10 @@ export async function bootstrapControlPlane(opts) {
139
317
  outbox,
140
318
  webhookSecret: d.webhookSecret,
141
319
  botUsername: d.botUsername ?? undefined,
320
+ deferredIngress: true,
321
+ onOutboxEnqueued: () => {
322
+ scheduleOutboxDrainRef?.();
323
+ },
142
324
  });
143
325
  if (d.botToken) {
144
326
  telegramBotToken = d.botToken;
@@ -199,14 +381,37 @@ export async function bootstrapControlPlane(opts) {
199
381
  return undefined;
200
382
  };
201
383
  const dispatcher = new ControlPlaneOutboxDispatcher({ outbox, deliver });
202
- drainInterval = setInterval(async () => {
384
+ let drainingOutbox = false;
385
+ let drainRequested = false;
386
+ const drainOutboxNow = async () => {
387
+ if (drainingOutbox) {
388
+ drainRequested = true;
389
+ return;
390
+ }
391
+ drainingOutbox = true;
203
392
  try {
204
- await dispatcher.drainDue();
393
+ do {
394
+ drainRequested = false;
395
+ await dispatcher.drainDue();
396
+ } while (drainRequested);
205
397
  }
206
398
  catch {
207
- // Swallow errors — the dispatcher already handles retries internally.
399
+ // Swallow errors — the dispatcher handles retries internally.
400
+ }
401
+ finally {
402
+ drainingOutbox = false;
208
403
  }
209
- }, 2_000);
404
+ };
405
+ const scheduleOutboxDrain = () => {
406
+ queueMicrotask(() => {
407
+ void drainOutboxNow();
408
+ });
409
+ };
410
+ scheduleOutboxDrainRef = scheduleOutboxDrain;
411
+ drainInterval = setInterval(() => {
412
+ scheduleOutboxDrain();
413
+ }, OUTBOX_DRAIN_INTERVAL_MS);
414
+ scheduleOutboxDrain();
210
415
  return {
211
416
  activeAdapters: [...adapterMap.values()].map((v) => v.info),
212
417
  async handleWebhook(path, req) {
@@ -214,13 +419,65 @@ export async function bootstrapControlPlane(opts) {
214
419
  if (!entry)
215
420
  return null;
216
421
  const result = await entry.adapter.ingest(req);
422
+ if (result.outboxRecord) {
423
+ scheduleOutboxDrain();
424
+ }
217
425
  return result.response;
218
426
  },
427
+ async listRuns(opts = {}) {
428
+ return (runSupervisor?.list({
429
+ status: opts.status,
430
+ limit: opts.limit,
431
+ }) ?? []);
432
+ },
433
+ async getRun(idOrRoot) {
434
+ return runSupervisor?.get(idOrRoot) ?? null;
435
+ },
436
+ async startRun(startOpts) {
437
+ const run = await runSupervisor?.launchStart({
438
+ prompt: startOpts.prompt,
439
+ maxSteps: startOpts.maxSteps,
440
+ source: "api",
441
+ });
442
+ if (!run) {
443
+ throw new Error("run_supervisor_unavailable");
444
+ }
445
+ return run;
446
+ },
447
+ async resumeRun(resumeOpts) {
448
+ const run = await runSupervisor?.launchResume({
449
+ rootIssueId: resumeOpts.rootIssueId,
450
+ maxSteps: resumeOpts.maxSteps,
451
+ source: "api",
452
+ });
453
+ if (!run) {
454
+ throw new Error("run_supervisor_unavailable");
455
+ }
456
+ return run;
457
+ },
458
+ async interruptRun(interruptOpts) {
459
+ return runSupervisor?.interrupt(interruptOpts) ?? { ok: false, reason: "not_found", run: null };
460
+ },
461
+ async heartbeatRun(heartbeatOpts) {
462
+ return runSupervisor?.heartbeat(heartbeatOpts) ?? { ok: false, reason: "not_found", run: null };
463
+ },
464
+ async traceRun(traceOpts) {
465
+ return (await runSupervisor?.trace(traceOpts.idOrRoot, { limit: traceOpts.limit })) ?? null;
466
+ },
219
467
  async stop() {
220
468
  if (drainInterval) {
221
469
  clearInterval(drainInterval);
222
470
  drainInterval = null;
223
471
  }
472
+ for (const { adapter } of adapterMap.values()) {
473
+ try {
474
+ await adapter.stop?.();
475
+ }
476
+ catch {
477
+ // Best effort adapter cleanup.
478
+ }
479
+ }
480
+ await runSupervisor?.stop();
224
481
  try {
225
482
  await pipeline?.stop();
226
483
  }
@@ -235,6 +492,20 @@ export async function bootstrapControlPlane(opts) {
235
492
  clearInterval(drainInterval);
236
493
  drainInterval = null;
237
494
  }
495
+ for (const { adapter } of adapterMap.values()) {
496
+ try {
497
+ await adapter.stop?.();
498
+ }
499
+ catch {
500
+ // Best effort cleanup.
501
+ }
502
+ }
503
+ try {
504
+ await runSupervisor?.stop();
505
+ }
506
+ catch {
507
+ // Best effort cleanup.
508
+ }
238
509
  try {
239
510
  await pipeline?.stop();
240
511
  }
@@ -0,0 +1,93 @@
1
+ import type { JsonlStore } from "@femtomc/mu-core";
2
+ import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
3
+ export type HeartbeatProgramTarget = {
4
+ kind: "run";
5
+ job_id: string | null;
6
+ root_issue_id: string | null;
7
+ } | {
8
+ kind: "activity";
9
+ activity_id: string;
10
+ };
11
+ export type HeartbeatProgramSnapshot = {
12
+ v: 1;
13
+ program_id: string;
14
+ title: string;
15
+ enabled: boolean;
16
+ every_ms: number;
17
+ reason: string;
18
+ target: HeartbeatProgramTarget;
19
+ metadata: Record<string, unknown>;
20
+ created_at_ms: number;
21
+ updated_at_ms: number;
22
+ last_triggered_at_ms: number | null;
23
+ last_result: "ok" | "not_found" | "not_running" | "failed" | null;
24
+ last_error: string | null;
25
+ };
26
+ export type HeartbeatProgramOperationResult = {
27
+ ok: boolean;
28
+ reason: "not_found" | "missing_target" | "invalid_target" | "not_running" | "failed" | null;
29
+ program: HeartbeatProgramSnapshot | null;
30
+ };
31
+ export type HeartbeatProgramTickEvent = {
32
+ ts_ms: number;
33
+ program_id: string;
34
+ message: string;
35
+ status: "ok" | "not_found" | "not_running" | "failed";
36
+ reason: string | null;
37
+ program: HeartbeatProgramSnapshot;
38
+ };
39
+ export type HeartbeatProgramRegistryOpts = {
40
+ repoRoot: string;
41
+ heartbeatScheduler: ActivityHeartbeatScheduler;
42
+ nowMs?: () => number;
43
+ store?: JsonlStore<HeartbeatProgramSnapshot>;
44
+ runHeartbeat: (opts: {
45
+ jobId?: string | null;
46
+ rootIssueId?: string | null;
47
+ reason?: string | null;
48
+ }) => Promise<{
49
+ ok: boolean;
50
+ reason: "not_found" | "not_running" | "missing_target" | null;
51
+ }>;
52
+ activityHeartbeat: (opts: {
53
+ activityId?: string | null;
54
+ reason?: string | null;
55
+ }) => Promise<{
56
+ ok: boolean;
57
+ reason: "not_found" | "not_running" | "missing_target" | null;
58
+ }>;
59
+ onTickEvent?: (event: HeartbeatProgramTickEvent) => void | Promise<void>;
60
+ };
61
+ export declare class HeartbeatProgramRegistry {
62
+ #private;
63
+ constructor(opts: HeartbeatProgramRegistryOpts);
64
+ list(opts?: {
65
+ enabled?: boolean;
66
+ targetKind?: "run" | "activity";
67
+ limit?: number;
68
+ }): Promise<HeartbeatProgramSnapshot[]>;
69
+ get(programId: string): Promise<HeartbeatProgramSnapshot | null>;
70
+ create(opts: {
71
+ title: string;
72
+ target: HeartbeatProgramTarget;
73
+ everyMs?: number;
74
+ reason?: string;
75
+ enabled?: boolean;
76
+ metadata?: Record<string, unknown>;
77
+ }): Promise<HeartbeatProgramSnapshot>;
78
+ update(opts: {
79
+ programId: string;
80
+ title?: string;
81
+ everyMs?: number;
82
+ reason?: string;
83
+ enabled?: boolean;
84
+ target?: HeartbeatProgramTarget;
85
+ metadata?: Record<string, unknown>;
86
+ }): Promise<HeartbeatProgramOperationResult>;
87
+ remove(programId: string): Promise<HeartbeatProgramOperationResult>;
88
+ trigger(opts: {
89
+ programId?: string | null;
90
+ reason?: string | null;
91
+ }): Promise<HeartbeatProgramOperationResult>;
92
+ stop(): void;
93
+ }