@femtomc/mu-server 26.2.120 → 26.3.2

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.
@@ -36,6 +36,39 @@ export async function heartbeatRoutes(request, url, deps, headers) {
36
36
  if ("prompt" in body && typeof body.prompt !== "string" && body.prompt !== null) {
37
37
  return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
38
38
  }
39
+ const parseNullableString = (value) => {
40
+ if (value === undefined)
41
+ return undefined;
42
+ if (value === null)
43
+ return null;
44
+ if (typeof value === "string")
45
+ return value;
46
+ return undefined;
47
+ };
48
+ const operatorProvider = parseNullableString(body.operator_provider);
49
+ if ("operator_provider" in body && operatorProvider === undefined) {
50
+ return Response.json({ error: "operator_provider must be string or null" }, { status: 400, headers });
51
+ }
52
+ const operatorModel = parseNullableString(body.operator_model);
53
+ if ("operator_model" in body && operatorModel === undefined) {
54
+ return Response.json({ error: "operator_model must be string or null" }, { status: 400, headers });
55
+ }
56
+ const operatorThinking = parseNullableString(body.operator_thinking);
57
+ if ("operator_thinking" in body && operatorThinking === undefined) {
58
+ return Response.json({ error: "operator_thinking must be string or null" }, { status: 400, headers });
59
+ }
60
+ const contextSessionId = parseNullableString(body.context_session_id);
61
+ if ("context_session_id" in body && contextSessionId === undefined) {
62
+ return Response.json({ error: "context_session_id must be string or null" }, { status: 400, headers });
63
+ }
64
+ const contextSessionFile = parseNullableString(body.context_session_file);
65
+ if ("context_session_file" in body && contextSessionFile === undefined) {
66
+ return Response.json({ error: "context_session_file must be string or null" }, { status: 400, headers });
67
+ }
68
+ const contextSessionDir = parseNullableString(body.context_session_dir);
69
+ if ("context_session_dir" in body && contextSessionDir === undefined) {
70
+ return Response.json({ error: "context_session_dir must be string or null" }, { status: 400, headers });
71
+ }
39
72
  const prompt = typeof body.prompt === "string" ? body.prompt : body.prompt === null ? null : undefined;
40
73
  const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
41
74
  ? Math.max(0, Math.trunc(body.every_ms))
@@ -52,6 +85,12 @@ export async function heartbeatRoutes(request, url, deps, headers) {
52
85
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
53
86
  ? body.metadata
54
87
  : undefined,
88
+ operatorProvider,
89
+ operatorModel,
90
+ operatorThinking,
91
+ contextSessionId,
92
+ contextSessionFile,
93
+ contextSessionDir,
55
94
  });
56
95
  return Response.json({ ok: true, program }, { status: 201, headers });
57
96
  }
@@ -75,6 +114,15 @@ export async function heartbeatRoutes(request, url, deps, headers) {
75
114
  return Response.json({ error: "program_id is required" }, { status: 400, headers });
76
115
  }
77
116
  try {
117
+ const parseNullableString = (value) => {
118
+ if (value === undefined)
119
+ return undefined;
120
+ if (value === null)
121
+ return null;
122
+ if (typeof value === "string")
123
+ return value;
124
+ return undefined;
125
+ };
78
126
  const updateOpts = {
79
127
  programId,
80
128
  title: typeof body.title === "string" ? body.title : undefined,
@@ -98,6 +146,48 @@ export async function heartbeatRoutes(request, url, deps, headers) {
98
146
  return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
99
147
  }
100
148
  }
149
+ if ("operator_provider" in body) {
150
+ const value = parseNullableString(body.operator_provider);
151
+ if (value === undefined) {
152
+ return Response.json({ error: "operator_provider must be string or null" }, { status: 400, headers });
153
+ }
154
+ updateOpts.operatorProvider = value;
155
+ }
156
+ if ("operator_model" in body) {
157
+ const value = parseNullableString(body.operator_model);
158
+ if (value === undefined) {
159
+ return Response.json({ error: "operator_model must be string or null" }, { status: 400, headers });
160
+ }
161
+ updateOpts.operatorModel = value;
162
+ }
163
+ if ("operator_thinking" in body) {
164
+ const value = parseNullableString(body.operator_thinking);
165
+ if (value === undefined) {
166
+ return Response.json({ error: "operator_thinking must be string or null" }, { status: 400, headers });
167
+ }
168
+ updateOpts.operatorThinking = value;
169
+ }
170
+ if ("context_session_id" in body) {
171
+ const value = parseNullableString(body.context_session_id);
172
+ if (value === undefined) {
173
+ return Response.json({ error: "context_session_id must be string or null" }, { status: 400, headers });
174
+ }
175
+ updateOpts.contextSessionId = value;
176
+ }
177
+ if ("context_session_file" in body) {
178
+ const value = parseNullableString(body.context_session_file);
179
+ if (value === undefined) {
180
+ return Response.json({ error: "context_session_file must be string or null" }, { status: 400, headers });
181
+ }
182
+ updateOpts.contextSessionFile = value;
183
+ }
184
+ if ("context_session_dir" in body) {
185
+ const value = parseNullableString(body.context_session_dir);
186
+ if (value === undefined) {
187
+ return Response.json({ error: "context_session_dir must be string or null" }, { status: 400, headers });
188
+ }
189
+ updateOpts.contextSessionDir = value;
190
+ }
101
191
  const result = await deps.heartbeatPrograms.update(updateOpts);
102
192
  if (result.ok) {
103
193
  return Response.json(result, { headers });
@@ -9,6 +9,12 @@ export type HeartbeatProgramSnapshot = {
9
9
  every_ms: number;
10
10
  reason: string;
11
11
  metadata: Record<string, unknown>;
12
+ operator_provider: string | null;
13
+ operator_model: string | null;
14
+ operator_thinking: string | null;
15
+ context_session_id: string | null;
16
+ context_session_file: string | null;
17
+ context_session_dir: string | null;
12
18
  created_at_ms: number;
13
19
  updated_at_ms: number;
14
20
  last_triggered_at_ms: number | null;
@@ -67,6 +73,12 @@ export type HeartbeatProgramRegistryOpts = {
67
73
  prompt: string | null;
68
74
  reason: string;
69
75
  metadata: Record<string, unknown>;
76
+ operatorProvider: string | null;
77
+ operatorModel: string | null;
78
+ operatorThinking: string | null;
79
+ contextSessionId: string | null;
80
+ contextSessionFile: string | null;
81
+ contextSessionDir: string | null;
70
82
  triggeredAtMs: number;
71
83
  }) => Promise<HeartbeatProgramDispatchResult>;
72
84
  onTickEvent?: (event: HeartbeatProgramTickEvent) => void | Promise<void>;
@@ -88,6 +100,12 @@ export declare class HeartbeatProgramRegistry {
88
100
  reason?: string;
89
101
  enabled?: boolean;
90
102
  metadata?: Record<string, unknown>;
103
+ operatorProvider?: string | null;
104
+ operatorModel?: string | null;
105
+ operatorThinking?: string | null;
106
+ contextSessionId?: string | null;
107
+ contextSessionFile?: string | null;
108
+ contextSessionDir?: string | null;
91
109
  }): Promise<HeartbeatProgramSnapshot>;
92
110
  update(opts: {
93
111
  programId: string;
@@ -97,6 +115,12 @@ export declare class HeartbeatProgramRegistry {
97
115
  reason?: string;
98
116
  enabled?: boolean;
99
117
  metadata?: Record<string, unknown>;
118
+ operatorProvider?: string | null;
119
+ operatorModel?: string | null;
120
+ operatorThinking?: string | null;
121
+ contextSessionId?: string | null;
122
+ contextSessionFile?: string | null;
123
+ contextSessionDir?: string | null;
100
124
  }): Promise<HeartbeatProgramOperationResult>;
101
125
  remove(programId: string): Promise<HeartbeatProgramOperationResult>;
102
126
  trigger(opts: {
@@ -19,6 +19,13 @@ function normalizePrompt(value) {
19
19
  }
20
20
  return value;
21
21
  }
22
+ function normalizeOptionalString(value) {
23
+ if (typeof value !== "string") {
24
+ return null;
25
+ }
26
+ const trimmed = value.trim();
27
+ return trimmed.length > 0 ? trimmed : null;
28
+ }
22
29
  function normalizeProgram(row) {
23
30
  if (!row || typeof row !== "object" || Array.isArray(row)) {
24
31
  return null;
@@ -43,7 +50,7 @@ function normalizeProgram(row) {
43
50
  const lastResultRaw = typeof record.last_result === "string" ? record.last_result.trim().toLowerCase() : null;
44
51
  const lastResult = lastResultRaw === "ok" || lastResultRaw === "coalesced" || lastResultRaw === "failed" ? lastResultRaw : null;
45
52
  const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : "scheduled";
46
- return {
53
+ const normalized = {
47
54
  v: 1,
48
55
  program_id: programId,
49
56
  title,
@@ -52,12 +59,29 @@ function normalizeProgram(row) {
52
59
  every_ms: everyMs,
53
60
  reason,
54
61
  metadata: sanitizeMetadata(record.metadata),
62
+ operator_provider: normalizeOptionalString(record.operator_provider),
63
+ operator_model: normalizeOptionalString(record.operator_model),
64
+ operator_thinking: normalizeOptionalString(record.operator_thinking),
65
+ context_session_id: normalizeOptionalString(record.context_session_id),
66
+ context_session_file: normalizeOptionalString(record.context_session_file),
67
+ context_session_dir: normalizeOptionalString(record.context_session_dir),
55
68
  created_at_ms: createdAt,
56
69
  updated_at_ms: updatedAt,
57
70
  last_triggered_at_ms: lastTriggeredAt,
58
71
  last_result: lastResult,
59
72
  last_error: typeof record.last_error === "string" ? record.last_error : null,
60
73
  };
74
+ const hasProvider = Boolean(normalized.operator_provider);
75
+ const hasModel = Boolean(normalized.operator_model);
76
+ if (hasProvider !== hasModel) {
77
+ normalized.operator_provider = null;
78
+ normalized.operator_model = null;
79
+ normalized.operator_thinking = null;
80
+ }
81
+ else if (!hasProvider) {
82
+ normalized.operator_thinking = null;
83
+ }
84
+ return normalized;
61
85
  }
62
86
  function sortPrograms(programs) {
63
87
  return [...programs].sort((a, b) => {
@@ -67,6 +91,16 @@ function sortPrograms(programs) {
67
91
  return a.program_id.localeCompare(b.program_id);
68
92
  });
69
93
  }
94
+ function validateOperatorRouting(program) {
95
+ const hasProvider = typeof program.operator_provider === "string" && program.operator_provider.length > 0;
96
+ const hasModel = typeof program.operator_model === "string" && program.operator_model.length > 0;
97
+ if (hasProvider !== hasModel) {
98
+ throw new Error("heartbeat_program_operator_provider_model_pair_required");
99
+ }
100
+ if (program.operator_thinking && !hasProvider) {
101
+ throw new Error("heartbeat_program_operator_thinking_requires_provider_model");
102
+ }
103
+ }
70
104
  export class HeartbeatProgramRegistry {
71
105
  #store;
72
106
  #heartbeatScheduler;
@@ -75,6 +109,7 @@ export class HeartbeatProgramRegistry {
75
109
  #onLifecycleEvent;
76
110
  #nowMs;
77
111
  #programs = new Map();
112
+ #inFlightTicks = new Map();
78
113
  #loaded = null;
79
114
  constructor(opts) {
80
115
  this.#heartbeatScheduler = opts.heartbeatScheduler;
@@ -160,6 +195,19 @@ export class HeartbeatProgramRegistry {
160
195
  await this.#onLifecycleEvent(event);
161
196
  }
162
197
  async #tickProgram(programId, reason) {
198
+ const inFlight = this.#inFlightTicks.get(programId);
199
+ if (inFlight) {
200
+ return { status: "skipped", reason: "coalesced" };
201
+ }
202
+ const run = this.#tickProgramUnlocked(programId, reason).finally(() => {
203
+ if (this.#inFlightTicks.get(programId) === run) {
204
+ this.#inFlightTicks.delete(programId);
205
+ }
206
+ });
207
+ this.#inFlightTicks.set(programId, run);
208
+ return await run;
209
+ }
210
+ async #tickProgramUnlocked(programId, reason) {
163
211
  const program = this.#programs.get(programId);
164
212
  if (!program) {
165
213
  return { status: "skipped", reason: "not_found" };
@@ -182,6 +230,12 @@ export class HeartbeatProgramRegistry {
182
230
  prompt: program.prompt,
183
231
  reason: heartbeatReason,
184
232
  metadata: { ...program.metadata },
233
+ operatorProvider: program.operator_provider,
234
+ operatorModel: program.operator_model,
235
+ operatorThinking: program.operator_thinking,
236
+ contextSessionId: program.context_session_id,
237
+ contextSessionFile: program.context_session_file,
238
+ contextSessionDir: program.context_session_dir,
185
239
  triggeredAtMs: nowMs,
186
240
  });
187
241
  if (result.status === "ok") {
@@ -283,12 +337,19 @@ export class HeartbeatProgramRegistry {
283
337
  every_ms: this.#normalizeEveryMs(typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs) ? opts.everyMs : 15_000),
284
338
  reason: opts.reason?.trim() || "scheduled",
285
339
  metadata: sanitizeMetadata(opts.metadata),
340
+ operator_provider: normalizeOptionalString(opts.operatorProvider),
341
+ operator_model: normalizeOptionalString(opts.operatorModel),
342
+ operator_thinking: normalizeOptionalString(opts.operatorThinking),
343
+ context_session_id: normalizeOptionalString(opts.contextSessionId),
344
+ context_session_file: normalizeOptionalString(opts.contextSessionFile),
345
+ context_session_dir: normalizeOptionalString(opts.contextSessionDir),
286
346
  created_at_ms: nowMs,
287
347
  updated_at_ms: nowMs,
288
348
  last_triggered_at_ms: null,
289
349
  last_result: null,
290
350
  last_error: null,
291
351
  };
352
+ validateOperatorRouting(program);
292
353
  this.#programs.set(program.program_id, program);
293
354
  this.#applySchedule(program);
294
355
  await this.#persist();
@@ -332,6 +393,25 @@ export class HeartbeatProgramRegistry {
332
393
  if (opts.metadata) {
333
394
  program.metadata = sanitizeMetadata(opts.metadata);
334
395
  }
396
+ if ("operatorProvider" in opts) {
397
+ program.operator_provider = normalizeOptionalString(opts.operatorProvider);
398
+ }
399
+ if ("operatorModel" in opts) {
400
+ program.operator_model = normalizeOptionalString(opts.operatorModel);
401
+ }
402
+ if ("operatorThinking" in opts) {
403
+ program.operator_thinking = normalizeOptionalString(opts.operatorThinking);
404
+ }
405
+ if ("contextSessionId" in opts) {
406
+ program.context_session_id = normalizeOptionalString(opts.contextSessionId);
407
+ }
408
+ if ("contextSessionFile" in opts) {
409
+ program.context_session_file = normalizeOptionalString(opts.contextSessionFile);
410
+ }
411
+ if ("contextSessionDir" in opts) {
412
+ program.context_session_dir = normalizeOptionalString(opts.contextSessionDir);
413
+ }
414
+ validateOperatorRouting(program);
335
415
  const nowMs = Math.trunc(this.#nowMs());
336
416
  program.updated_at_ms = nowMs;
337
417
  this.#applySchedule(program);
@@ -396,6 +476,7 @@ export class HeartbeatProgramRegistry {
396
476
  for (const program of this.#programs.values()) {
397
477
  this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
398
478
  }
479
+ this.#inFlightTicks.clear();
399
480
  this.#programs.clear();
400
481
  }
401
482
  }
package/dist/server.js CHANGED
@@ -77,6 +77,20 @@ function extractWakeTurnReply(turnResult) {
77
77
  const compact = presented.compact.trim();
78
78
  return compact.length > 0 ? compact : null;
79
79
  }
80
+ function extractWakeTurnFailureCode(turnReply) {
81
+ if (!turnReply) {
82
+ return null;
83
+ }
84
+ const normalized = turnReply.trim();
85
+ if (!normalized.toLowerCase().startsWith("i could not complete that turn safely.")) {
86
+ return null;
87
+ }
88
+ const codeMatch = normalized.match(/(?:^|\n)\s*Code:\s*([a-z0-9_]+)/i);
89
+ if (!codeMatch || typeof codeMatch[1] !== "string") {
90
+ return null;
91
+ }
92
+ return codeMatch[1].toLowerCase();
93
+ }
80
94
  function buildWakeTurnIngressText(opts) {
81
95
  const wakeSource = stringField(opts.payload, "wake_source") ?? "unknown";
82
96
  const programId = stringField(opts.payload, "program_id") ?? "unknown";
@@ -195,16 +209,37 @@ function createServer(options = {}) {
195
209
  message: opts.message,
196
210
  payload: opts.payload,
197
211
  });
212
+ const operatorProvider = stringField(opts.payload, "operator_provider");
213
+ const operatorModel = stringField(opts.payload, "operator_model");
214
+ const operatorThinking = stringField(opts.payload, "operator_thinking");
215
+ const contextSessionId = stringField(opts.payload, "context_session_id");
216
+ const contextSessionFile = stringField(opts.payload, "context_session_file");
217
+ const contextSessionDir = stringField(opts.payload, "context_session_dir");
218
+ const defaultHeartbeatSessionId = wakeSource === "heartbeat_program" && programId ? `heartbeat-program:${programId}` : null;
219
+ const operatorSessionId = contextSessionId ?? defaultHeartbeatSessionId;
220
+ const autonomousMetadata = {
221
+ wake_id: wakeId,
222
+ wake_source: wakeSource,
223
+ program_id: programId,
224
+ source_ts_ms: sourceTsMs,
225
+ };
226
+ if (operatorProvider)
227
+ autonomousMetadata.operator_provider = operatorProvider;
228
+ if (operatorModel)
229
+ autonomousMetadata.operator_model = operatorModel;
230
+ if (operatorThinking)
231
+ autonomousMetadata.operator_thinking = operatorThinking;
232
+ if (operatorSessionId)
233
+ autonomousMetadata.operator_session_id = operatorSessionId;
234
+ if (contextSessionFile)
235
+ autonomousMetadata.operator_session_file = contextSessionFile;
236
+ if (contextSessionDir)
237
+ autonomousMetadata.operator_session_dir = contextSessionDir;
198
238
  const turnResult = await autonomousIngress({
199
239
  text: ingressText,
200
240
  repoRoot: context.repoRoot,
201
241
  requestId: turnRequestId,
202
- metadata: {
203
- wake_id: wakeId,
204
- wake_source: wakeSource,
205
- program_id: programId,
206
- source_ts_ms: sourceTsMs,
207
- },
242
+ metadata: autonomousMetadata,
208
243
  });
209
244
  if (turnResult.kind === "noop" || turnResult.kind === "invalid") {
210
245
  decision = {
@@ -229,14 +264,27 @@ function createServer(options = {}) {
229
264
  };
230
265
  }
231
266
  else {
232
- decision = {
233
- outcome: "triggered",
234
- reason: "turn_invoked",
235
- turnRequestId,
236
- turnResultKind: turnResult.kind,
237
- turnReply,
238
- error: null,
239
- };
267
+ const failureCode = extractWakeTurnFailureCode(turnReply);
268
+ if (failureCode === "operator_busy") {
269
+ decision = {
270
+ outcome: "fallback",
271
+ reason: "operator_busy",
272
+ turnRequestId,
273
+ turnResultKind: turnResult.kind,
274
+ turnReply: null,
275
+ error: null,
276
+ };
277
+ }
278
+ else {
279
+ decision = {
280
+ outcome: "triggered",
281
+ reason: "turn_invoked",
282
+ turnRequestId,
283
+ turnResultKind: turnResult.kind,
284
+ turnReply,
285
+ error: null,
286
+ };
287
+ }
240
288
  }
241
289
  }
242
290
  }
@@ -272,7 +320,7 @@ function createServer(options = {}) {
272
320
  let notifyError = null;
273
321
  let deliverySkippedReason = null;
274
322
  if (!decision.turnReply) {
275
- deliverySkippedReason = "no_turn_reply";
323
+ deliverySkippedReason = decision.reason === "operator_busy" ? "operator_busy" : "no_turn_reply";
276
324
  }
277
325
  else if (typeof controlPlaneProxy.notifyOperators !== "function") {
278
326
  deliverySkippedReason = "notify_operators_unavailable";
@@ -346,6 +394,9 @@ function createServer(options = {}) {
346
394
  delivery_error: notifyError,
347
395
  },
348
396
  });
397
+ if (decision.reason === "operator_busy") {
398
+ return { status: "coalesced", reason: decision.reason };
399
+ }
349
400
  if (decision.outcome !== "triggered") {
350
401
  return { status: "failed", reason: decision.reason };
351
402
  }
@@ -12,18 +12,31 @@ export function createServerProgramCoordination(opts) {
12
12
  heartbeatScheduler: opts.heartbeatScheduler,
13
13
  dispatchWake: async (wakeOpts) => {
14
14
  const prompt = wakeOpts.prompt && wakeOpts.prompt.trim().length > 0 ? wakeOpts.prompt : null;
15
+ const payload = {
16
+ wake_source: "heartbeat_program",
17
+ source_ts_ms: wakeOpts.triggeredAtMs,
18
+ program_id: wakeOpts.programId,
19
+ title: wakeOpts.title,
20
+ prompt,
21
+ reason: wakeOpts.reason,
22
+ metadata: wakeOpts.metadata,
23
+ };
24
+ if (wakeOpts.operatorProvider)
25
+ payload.operator_provider = wakeOpts.operatorProvider;
26
+ if (wakeOpts.operatorModel)
27
+ payload.operator_model = wakeOpts.operatorModel;
28
+ if (wakeOpts.operatorThinking)
29
+ payload.operator_thinking = wakeOpts.operatorThinking;
30
+ if (wakeOpts.contextSessionId)
31
+ payload.context_session_id = wakeOpts.contextSessionId;
32
+ if (wakeOpts.contextSessionFile)
33
+ payload.context_session_file = wakeOpts.contextSessionFile;
34
+ if (wakeOpts.contextSessionDir)
35
+ payload.context_session_dir = wakeOpts.contextSessionDir;
15
36
  const wakeResult = await opts.emitOperatorWake({
16
37
  dedupeKey: `heartbeat-program:${wakeOpts.programId}`,
17
38
  message: prompt ?? `Heartbeat wake: ${wakeOpts.title}`,
18
- payload: {
19
- wake_source: "heartbeat_program",
20
- source_ts_ms: wakeOpts.triggeredAtMs,
21
- program_id: wakeOpts.programId,
22
- title: wakeOpts.title,
23
- prompt,
24
- reason: wakeOpts.reason,
25
- metadata: wakeOpts.metadata,
26
- },
39
+ payload,
27
40
  });
28
41
  if (wakeResult.status === "coalesced") {
29
42
  return { status: "coalesced", reason: wakeResult.reason };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.120",
3
+ "version": "26.3.2",
4
4
  "description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
5
5
  "keywords": [
6
6
  "mu",
@@ -30,8 +30,8 @@
30
30
  "start": "bun run dist/cli.js"
31
31
  },
32
32
  "dependencies": {
33
- "@femtomc/mu-agent": "26.2.120",
34
- "@femtomc/mu-control-plane": "26.2.120",
35
- "@femtomc/mu-core": "26.2.120"
33
+ "@femtomc/mu-agent": "26.3.2",
34
+ "@femtomc/mu-control-plane": "26.3.2",
35
+ "@femtomc/mu-core": "26.3.2"
36
36
  }
37
37
  }