@femtomc/mu-server 26.2.56 → 26.2.57

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.
@@ -34,6 +34,13 @@ function normalizeTarget(input) {
34
34
  }
35
35
  return null;
36
36
  }
37
+ function normalizeWakeMode(value) {
38
+ if (typeof value !== "string") {
39
+ return "immediate";
40
+ }
41
+ const normalized = value.trim().toLowerCase().replaceAll("-", "_");
42
+ return normalized === "next_heartbeat" ? "next_heartbeat" : "immediate";
43
+ }
37
44
  function sanitizeMetadata(value) {
38
45
  if (!value || typeof value !== "object" || Array.isArray(value)) {
39
46
  return {};
@@ -52,9 +59,7 @@ function normalizeProgram(row) {
52
59
  return null;
53
60
  }
54
61
  const everyMsRaw = record.every_ms;
55
- const everyMs = typeof everyMsRaw === "number" && Number.isFinite(everyMsRaw)
56
- ? Math.max(0, Math.trunc(everyMsRaw))
57
- : 0;
62
+ const everyMs = typeof everyMsRaw === "number" && Number.isFinite(everyMsRaw) ? Math.max(0, Math.trunc(everyMsRaw)) : 0;
58
63
  const createdAt = typeof record.created_at_ms === "number" && Number.isFinite(record.created_at_ms)
59
64
  ? Math.trunc(record.created_at_ms)
60
65
  : defaultNowMs();
@@ -72,6 +77,7 @@ function normalizeProgram(row) {
72
77
  ? lastResultRaw
73
78
  : null;
74
79
  const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : "scheduled";
80
+ const wakeMode = normalizeWakeMode(record.wake_mode);
75
81
  return {
76
82
  v: 1,
77
83
  program_id: programId,
@@ -79,6 +85,7 @@ function normalizeProgram(row) {
79
85
  enabled: record.enabled !== false,
80
86
  every_ms: everyMs,
81
87
  reason,
88
+ wake_mode: wakeMode,
82
89
  target,
83
90
  metadata: sanitizeMetadata(record.metadata),
84
91
  created_at_ms: createdAt,
@@ -180,12 +187,17 @@ export class HeartbeatProgramRegistry {
180
187
  const nowMs = Math.trunc(this.#nowMs());
181
188
  program.last_triggered_at_ms = nowMs;
182
189
  program.updated_at_ms = nowMs;
190
+ let heartbeatResult;
191
+ let eventStatus = "ok";
192
+ let eventReason = heartbeatReason;
193
+ let eventMessage = `heartbeat program tick: ${program.title}`;
183
194
  try {
184
195
  const result = program.target.kind === "run"
185
196
  ? await this.#runHeartbeat({
186
197
  jobId: program.target.job_id,
187
198
  rootIssueId: program.target.root_issue_id,
188
199
  reason: heartbeatReason,
200
+ wakeMode: program.wake_mode,
189
201
  })
190
202
  : await this.#activityHeartbeat({
191
203
  activityId: program.target.activity_id,
@@ -194,92 +206,69 @@ export class HeartbeatProgramRegistry {
194
206
  if (result.ok) {
195
207
  program.last_result = "ok";
196
208
  program.last_error = null;
197
- void this.#persist().catch(() => {
198
- // Best effort persistence on background ticks.
199
- });
200
- void this.#emitTickEvent({
201
- ts_ms: nowMs,
202
- program_id: program.program_id,
203
- message: `heartbeat program tick: ${program.title}`,
204
- status: "ok",
205
- reason: heartbeatReason,
206
- program: this.#snapshot(program),
207
- }).catch(() => {
208
- // best effort only
209
- });
210
- return { status: "ran" };
209
+ heartbeatResult = { status: "ran" };
211
210
  }
212
- if (result.reason === "not_running") {
211
+ else if (result.reason === "not_running") {
213
212
  program.last_result = "not_running";
214
213
  program.last_error = null;
215
- void this.#persist().catch(() => {
216
- // Best effort persistence on background ticks.
217
- });
218
- void this.#emitTickEvent({
219
- ts_ms: nowMs,
220
- program_id: program.program_id,
221
- message: `heartbeat program skipped (not running): ${program.title}`,
222
- status: "not_running",
223
- reason: result.reason,
224
- program: this.#snapshot(program),
225
- }).catch(() => {
226
- // best effort only
227
- });
228
- return { status: "skipped", reason: "not_running" };
214
+ eventStatus = "not_running";
215
+ eventReason = result.reason;
216
+ eventMessage = `heartbeat program skipped (not running): ${program.title}`;
217
+ heartbeatResult = { status: "skipped", reason: "not_running" };
229
218
  }
230
- if (result.reason === "not_found") {
219
+ else if (result.reason === "not_found") {
231
220
  program.last_result = "not_found";
232
221
  program.last_error = null;
233
- void this.#persist().catch(() => {
234
- // Best effort persistence on background ticks.
235
- });
236
- void this.#emitTickEvent({
237
- ts_ms: nowMs,
238
- program_id: program.program_id,
239
- message: `heartbeat program skipped (not found): ${program.title}`,
240
- status: "not_found",
241
- reason: result.reason,
242
- program: this.#snapshot(program),
243
- }).catch(() => {
244
- // best effort only
245
- });
246
- return { status: "skipped", reason: "not_found" };
222
+ eventStatus = "not_found";
223
+ eventReason = result.reason;
224
+ eventMessage = `heartbeat program skipped (not found): ${program.title}`;
225
+ heartbeatResult = { status: "skipped", reason: "not_found" };
226
+ }
227
+ else {
228
+ program.last_result = "failed";
229
+ program.last_error = result.reason ?? "heartbeat_program_tick_failed";
230
+ eventStatus = "failed";
231
+ eventReason = program.last_error;
232
+ eventMessage = `heartbeat program failed: ${program.title}`;
233
+ heartbeatResult = { status: "failed", reason: program.last_error };
247
234
  }
248
- program.last_result = "failed";
249
- program.last_error = result.reason ?? "heartbeat_program_tick_failed";
250
- void this.#persist().catch(() => {
251
- // Best effort persistence on background ticks.
252
- });
253
- void this.#emitTickEvent({
254
- ts_ms: nowMs,
255
- program_id: program.program_id,
256
- message: `heartbeat program failed: ${program.title}`,
257
- status: "failed",
258
- reason: program.last_error,
259
- program: this.#snapshot(program),
260
- }).catch(() => {
261
- // best effort only
262
- });
263
- return { status: "failed", reason: program.last_error };
264
235
  }
265
236
  catch (err) {
266
237
  program.last_result = "failed";
267
238
  program.last_error = err instanceof Error ? err.message : String(err);
268
- void this.#persist().catch(() => {
269
- // Best effort persistence on background ticks.
270
- });
271
- void this.#emitTickEvent({
272
- ts_ms: nowMs,
273
- program_id: program.program_id,
274
- message: `heartbeat program failed: ${program.title}`,
275
- status: "failed",
276
- reason: program.last_error,
277
- program: this.#snapshot(program),
278
- }).catch(() => {
279
- // best effort only
280
- });
281
- return { status: "failed", reason: program.last_error };
239
+ eventStatus = "failed";
240
+ eventReason = program.last_error;
241
+ eventMessage = `heartbeat program failed: ${program.title}`;
242
+ heartbeatResult = { status: "failed", reason: program.last_error };
243
+ }
244
+ const shouldAutoDisableOnTerminal = program.target.kind === "run" &&
245
+ program.metadata.auto_disable_on_terminal === true &&
246
+ heartbeatResult.status === "skipped" &&
247
+ (heartbeatResult.reason === "not_running" || heartbeatResult.reason === "not_found");
248
+ if (shouldAutoDisableOnTerminal) {
249
+ program.enabled = false;
250
+ program.every_ms = 0;
251
+ program.updated_at_ms = Math.trunc(this.#nowMs());
252
+ program.metadata = {
253
+ ...program.metadata,
254
+ auto_disabled_at_ms: Math.trunc(this.#nowMs()),
255
+ auto_disabled_reason: heartbeatResult.status === "skipped" ? (heartbeatResult.reason ?? null) : null,
256
+ };
257
+ this.#heartbeatScheduler.unregister(this.#scheduleId(program.program_id));
258
+ eventMessage = `${eventMessage} (auto-disabled)`;
282
259
  }
260
+ await this.#persist();
261
+ await this.#emitTickEvent({
262
+ ts_ms: nowMs,
263
+ program_id: program.program_id,
264
+ message: eventMessage,
265
+ status: eventStatus,
266
+ reason: eventReason,
267
+ program: this.#snapshot(program),
268
+ }).catch(() => {
269
+ // best effort only
270
+ });
271
+ return heartbeatResult;
283
272
  }
284
273
  async list(opts = {}) {
285
274
  await this.#ensureLoaded();
@@ -322,6 +311,7 @@ export class HeartbeatProgramRegistry {
322
311
  ? Math.max(0, Math.trunc(opts.everyMs))
323
312
  : 15_000,
324
313
  reason: opts.reason?.trim() || "scheduled",
314
+ wake_mode: normalizeWakeMode(opts.wakeMode),
325
315
  target,
326
316
  metadata: sanitizeMetadata(opts.metadata),
327
317
  created_at_ms: nowMs,
@@ -354,6 +344,9 @@ export class HeartbeatProgramRegistry {
354
344
  if (typeof opts.reason === "string") {
355
345
  program.reason = opts.reason.trim() || "scheduled";
356
346
  }
347
+ if (typeof opts.wakeMode === "string") {
348
+ program.wake_mode = normalizeWakeMode(opts.wakeMode);
349
+ }
357
350
  if (typeof opts.enabled === "boolean") {
358
351
  program.enabled = opts.enabled;
359
352
  }
@@ -49,37 +49,61 @@ function toMs(value, fallback, min) {
49
49
  }
50
50
  return Math.max(min, Math.trunc(value));
51
51
  }
52
+ function shouldRetry(result) {
53
+ if (result.status === "failed") {
54
+ return true;
55
+ }
56
+ return result.status === "skipped" && result.reason === "requests-in-flight";
57
+ }
52
58
  export class ActivityHeartbeatScheduler {
53
59
  #states = new Map();
54
60
  #nowMs;
55
61
  #defaultCoalesceMs;
56
62
  #retryMs;
57
63
  #minIntervalMs;
64
+ #wakeTimerToken = 0;
58
65
  constructor(opts = {}) {
59
66
  this.#nowMs = opts.nowMs ?? defaultNowMs;
60
67
  this.#defaultCoalesceMs = Math.max(0, Math.trunc(opts.defaultCoalesceMs ?? DEFAULT_COALESCE_MS));
61
68
  this.#retryMs = Math.max(100, Math.trunc(opts.retryMs ?? DEFAULT_RETRY_MS));
62
69
  this.#minIntervalMs = Math.max(100, Math.trunc(opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS));
63
70
  }
71
+ #isCurrentState(state) {
72
+ if (state.disposed) {
73
+ return false;
74
+ }
75
+ return this.#states.get(state.activityId) === state;
76
+ }
77
+ #normalizeDelayMs(coalesceMs) {
78
+ if (!Number.isFinite(coalesceMs)) {
79
+ return this.#defaultCoalesceMs;
80
+ }
81
+ return Math.max(0, Math.trunc(coalesceMs));
82
+ }
64
83
  #clearWakeTimer(state) {
65
84
  if (state.wakeTimer) {
66
- clearTimeout(state.wakeTimer);
67
- state.wakeTimer = null;
85
+ clearTimeout(state.wakeTimer.handle);
68
86
  }
69
- state.wakeDueAt = null;
70
- state.wakeKind = null;
87
+ state.wakeTimer = null;
71
88
  }
72
89
  #disposeState(state) {
90
+ if (state.disposed) {
91
+ return;
92
+ }
93
+ state.disposed = true;
73
94
  if (state.intervalTimer) {
74
95
  clearInterval(state.intervalTimer);
75
96
  state.intervalTimer = null;
76
97
  }
77
98
  this.#clearWakeTimer(state);
78
99
  state.pendingWake = null;
79
- state.running = false;
80
100
  state.scheduled = false;
101
+ state.running = false;
81
102
  }
82
103
  #queuePendingWakeReason(state, reason) {
104
+ if (!this.#isCurrentState(state)) {
105
+ return;
106
+ }
83
107
  const normalized = normalizeReason(reason);
84
108
  const next = {
85
109
  reason: normalized,
@@ -98,73 +122,90 @@ export class ActivityHeartbeatScheduler {
98
122
  state.pendingWake = next;
99
123
  }
100
124
  }
101
- #schedule(state, coalesceMs, kind = "normal") {
102
- const delay = Math.max(0, Math.trunc(Number.isFinite(coalesceMs) ? coalesceMs : this.#defaultCoalesceMs));
125
+ #scheduleWake(state, coalesceMs, kind = "normal") {
126
+ if (!this.#isCurrentState(state)) {
127
+ return;
128
+ }
129
+ const delay = this.#normalizeDelayMs(coalesceMs);
103
130
  const dueAt = this.#nowMs() + delay;
104
- if (state.wakeTimer) {
131
+ const activeTimer = state.wakeTimer;
132
+ if (activeTimer) {
105
133
  // Retry cooldown should remain in force.
106
- if (state.wakeKind === "retry") {
134
+ if (activeTimer.kind === "retry") {
107
135
  return;
108
136
  }
109
- if (typeof state.wakeDueAt === "number" && state.wakeDueAt <= dueAt) {
137
+ if (activeTimer.dueAt <= dueAt) {
110
138
  return;
111
139
  }
112
140
  this.#clearWakeTimer(state);
113
141
  }
114
- state.wakeDueAt = dueAt;
115
- state.wakeKind = kind;
116
- state.wakeTimer = setTimeout(() => {
117
- void this.#flush(state.activityId, delay, kind);
142
+ const timerToken = ++this.#wakeTimerToken;
143
+ const handle = setTimeout(() => {
144
+ void this.#flushWake(state, {
145
+ timerToken,
146
+ delay,
147
+ kind,
148
+ });
118
149
  }, delay);
119
- state.wakeTimer.unref?.();
150
+ handle.unref?.();
151
+ state.wakeTimer = {
152
+ handle,
153
+ dueAt,
154
+ kind,
155
+ token: timerToken,
156
+ };
120
157
  }
121
- async #flush(activityId, delay, kind) {
122
- const state = this.#states.get(activityId);
123
- if (!state) {
124
- return;
125
- }
126
- this.#clearWakeTimer(state);
127
- state.scheduled = false;
128
- if (state.running) {
129
- state.scheduled = true;
130
- this.#schedule(state, delay, kind);
131
- return;
132
- }
133
- const reason = state.pendingWake?.reason;
134
- state.pendingWake = null;
135
- state.running = true;
136
- let result;
158
+ async #invokeHandler(state, reason) {
137
159
  const startedAt = this.#nowMs();
138
160
  try {
139
- result = await state.handler({ activityId, reason: reason ?? undefined });
161
+ const result = await state.handler({
162
+ activityId: state.activityId,
163
+ reason,
164
+ });
140
165
  if (result.status === "ran" && result.durationMs == null) {
141
- result = {
166
+ return {
142
167
  status: "ran",
143
168
  durationMs: Math.max(0, Math.trunc(this.#nowMs() - startedAt)),
144
169
  };
145
170
  }
171
+ return result;
146
172
  }
147
173
  catch (err) {
148
- result = {
174
+ return {
149
175
  status: "failed",
150
176
  reason: err instanceof Error ? err.message : String(err),
151
177
  };
152
178
  }
153
- state.running = false;
154
- // If the activity was removed while the handler was running, bail out quietly.
155
- if (this.#states.get(activityId) !== state) {
179
+ }
180
+ async #flushWake(state, params) {
181
+ if (!this.#isCurrentState(state)) {
156
182
  return;
157
183
  }
158
- if (result.status === "failed") {
159
- this.#queuePendingWakeReason(state, reason ?? "retry");
160
- this.#schedule(state, this.#retryMs, "retry");
184
+ const activeTimer = state.wakeTimer;
185
+ if (!activeTimer || activeTimer.token !== params.timerToken) {
186
+ return;
187
+ }
188
+ this.#clearWakeTimer(state);
189
+ state.scheduled = false;
190
+ if (state.running) {
191
+ state.scheduled = true;
192
+ this.#scheduleWake(state, params.delay, params.kind);
193
+ return;
194
+ }
195
+ const reason = state.pendingWake?.reason;
196
+ state.pendingWake = null;
197
+ state.running = true;
198
+ const result = await this.#invokeHandler(state, reason ?? undefined);
199
+ state.running = false;
200
+ if (!this.#isCurrentState(state)) {
201
+ return;
161
202
  }
162
- else if (result.status === "skipped" && result.reason === "requests-in-flight") {
203
+ if (shouldRetry(result)) {
163
204
  this.#queuePendingWakeReason(state, reason ?? "retry");
164
- this.#schedule(state, this.#retryMs, "retry");
205
+ this.#scheduleWake(state, this.#retryMs, "retry");
165
206
  }
166
207
  if (state.pendingWake || state.scheduled) {
167
- this.#schedule(state, state.coalesceMs, "normal");
208
+ this.#scheduleWake(state, state.coalesceMs, "normal");
168
209
  }
169
210
  }
170
211
  register(opts) {
@@ -174,9 +215,10 @@ export class ActivityHeartbeatScheduler {
174
215
  this.#disposeState(existing);
175
216
  this.#states.delete(activityId);
176
217
  }
218
+ const hasInterval = Number.isFinite(opts.everyMs) && Math.trunc(opts.everyMs) > 0;
177
219
  const state = {
178
220
  activityId,
179
- everyMs: toMs(opts.everyMs, this.#minIntervalMs, this.#minIntervalMs),
221
+ everyMs: hasInterval ? toMs(opts.everyMs, this.#minIntervalMs, this.#minIntervalMs) : 0,
180
222
  coalesceMs: Math.max(0, Math.trunc(opts.coalesceMs ?? this.#defaultCoalesceMs)),
181
223
  handler: opts.handler,
182
224
  pendingWake: null,
@@ -184,13 +226,14 @@ export class ActivityHeartbeatScheduler {
184
226
  running: false,
185
227
  intervalTimer: null,
186
228
  wakeTimer: null,
187
- wakeDueAt: null,
188
- wakeKind: null,
229
+ disposed: false,
189
230
  };
190
- state.intervalTimer = setInterval(() => {
191
- this.requestNow(activityId, { reason: "interval", coalesceMs: 0 });
192
- }, state.everyMs);
193
- state.intervalTimer.unref?.();
231
+ if (state.everyMs > 0) {
232
+ state.intervalTimer = setInterval(() => {
233
+ this.requestNow(activityId, { reason: "interval", coalesceMs: 0 });
234
+ }, state.everyMs);
235
+ state.intervalTimer.unref?.();
236
+ }
194
237
  this.#states.set(activityId, state);
195
238
  }
196
239
  requestNow(activityIdRaw, opts) {
@@ -203,7 +246,7 @@ export class ActivityHeartbeatScheduler {
203
246
  return false;
204
247
  }
205
248
  this.#queuePendingWakeReason(state, opts?.reason);
206
- this.#schedule(state, opts?.coalesceMs ?? state.coalesceMs, "normal");
249
+ this.#scheduleWake(state, opts?.coalesceMs ?? state.coalesceMs, "normal");
207
250
  return true;
208
251
  }
209
252
  unregister(activityIdRaw) {
package/dist/index.d.ts CHANGED
@@ -1,12 +1,18 @@
1
- export type { MuConfig, MuConfigPatch, MuConfigPresence } from "./config.js";
2
- export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
3
1
  export type { ControlPlaneActivityEvent, ControlPlaneActivityEventKind, ControlPlaneActivityMutationResult, ControlPlaneActivitySnapshot, ControlPlaneActivityStatus, ControlPlaneActivitySupervisorOpts, } from "./activity_supervisor.js";
4
2
  export { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
5
- export type { HeartbeatProgramOperationResult, HeartbeatProgramRegistryOpts, HeartbeatProgramSnapshot, HeartbeatProgramTarget, HeartbeatProgramTickEvent, } from "./heartbeat_programs.js";
6
- export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
3
+ export type { MuConfig, MuConfigPatch, MuConfigPresence } from "./config.js";
4
+ export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
7
5
  export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneHandle } from "./control_plane.js";
8
6
  export { bootstrapControlPlane, detectAdapters } from "./control_plane.js";
9
- export type { HeartbeatRunResult, HeartbeatTickHandler, ActivityHeartbeatSchedulerOpts } from "./heartbeat_scheduler.js";
7
+ export type { CronProgramLifecycleAction, CronProgramLifecycleEvent, CronProgramOperationResult, CronProgramRegistryOpts, CronProgramSnapshot, CronProgramStatusSnapshot, CronProgramTarget, CronProgramTickEvent, CronProgramWakeMode, } from "./cron_programs.js";
8
+ export { CronProgramRegistry } from "./cron_programs.js";
9
+ export type { CronProgramSchedule as CronSchedule, CronProgramSchedule } from "./cron_schedule.js";
10
+ export { computeNextScheduleRunAtMs, normalizeCronSchedule } from "./cron_schedule.js";
11
+ export type { CronTimerRegistryOpts, CronTimerSnapshot } from "./cron_timer.js";
12
+ export { CronTimerRegistry } from "./cron_timer.js";
13
+ export type { HeartbeatProgramOperationResult, HeartbeatProgramRegistryOpts, HeartbeatProgramSnapshot, HeartbeatProgramTarget, HeartbeatProgramTickEvent, HeartbeatProgramWakeMode, } from "./heartbeat_programs.js";
14
+ export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
15
+ export type { ActivityHeartbeatSchedulerOpts, HeartbeatRunResult, HeartbeatTickHandler, } from "./heartbeat_scheduler.js";
10
16
  export { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
11
17
  export type { ServerContext, ServerOptions, ServerWithControlPlane } from "./server.js";
12
18
  export { createContext, createServer, createServerAsync } from "./server.js";
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
- export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
2
1
  export { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
3
- export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
2
+ export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
4
3
  export { bootstrapControlPlane, detectAdapters } from "./control_plane.js";
4
+ export { CronProgramRegistry } from "./cron_programs.js";
5
+ export { computeNextScheduleRunAtMs, normalizeCronSchedule } from "./cron_schedule.js";
6
+ export { CronTimerRegistry } from "./cron_timer.js";
7
+ export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
5
8
  export { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
6
9
  export { createContext, createServer, createServerAsync } from "./server.js";
@@ -2,6 +2,7 @@ import type { CommandRecord } from "@femtomc/mu-control-plane";
2
2
  import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
3
3
  export type ControlPlaneRunMode = "run_start" | "run_resume";
4
4
  export type ControlPlaneRunStatus = "running" | "completed" | "failed" | "cancelled";
5
+ export type ControlPlaneRunWakeMode = "immediate" | "next_heartbeat";
5
6
  export type ControlPlaneRunSnapshot = {
6
7
  job_id: string;
7
8
  mode: ControlPlaneRunMode;
@@ -95,6 +96,7 @@ export declare class ControlPlaneRunSupervisor {
95
96
  jobId?: string | null;
96
97
  rootIssueId?: string | null;
97
98
  reason?: string | null;
99
+ wakeMode?: string | null;
98
100
  }): ControlPlaneRunHeartbeatResult;
99
101
  startFromCommand(command: CommandRecord): Promise<ControlPlaneRunSnapshot | null>;
100
102
  stop(): Promise<void>;
@@ -45,6 +45,13 @@ function normalizeIssueId(value) {
45
45
  }
46
46
  return trimmed.toLowerCase();
47
47
  }
48
+ function normalizeWakeMode(value) {
49
+ if (typeof value !== "string") {
50
+ return "immediate";
51
+ }
52
+ const normalized = value.trim().toLowerCase().replaceAll("-", "_");
53
+ return normalized === "next_heartbeat" ? "next_heartbeat" : "immediate";
54
+ }
48
55
  function pushBounded(lines, line, maxLines) {
49
56
  lines.push(line);
50
57
  if (lines.length <= maxLines) {
@@ -232,6 +239,7 @@ export class ControlPlaneRunSupervisor {
232
239
  stderr_lines: [],
233
240
  log_hints: new Set(),
234
241
  interrupt_requested: false,
242
+ next_heartbeat_reason: null,
235
243
  hard_kill_timer: null,
236
244
  };
237
245
  this.#jobsById.set(snapshot.job_id, job);
@@ -242,14 +250,22 @@ export class ControlPlaneRunSupervisor {
242
250
  this.#heartbeatScheduler.register({
243
251
  activityId: snapshot.job_id,
244
252
  everyMs: this.#heartbeatIntervalMs,
245
- handler: async () => {
253
+ handler: async ({ reason }) => {
246
254
  if (job.snapshot.status !== "running") {
247
255
  return { status: "skipped", reason: "not_running" };
248
256
  }
257
+ const normalizedReason = reason?.trim();
258
+ const heartbeatReason = normalizedReason && normalizedReason.length > 0 && normalizedReason !== "interval"
259
+ ? normalizedReason
260
+ : job.next_heartbeat_reason;
261
+ if (heartbeatReason) {
262
+ job.next_heartbeat_reason = null;
263
+ }
249
264
  const elapsedSec = Math.max(0, Math.trunc((this.#nowMs() - job.snapshot.started_at_ms) / 1_000));
250
265
  const root = job.snapshot.root_issue_id ?? job.snapshot.job_id;
251
266
  const progress = job.snapshot.last_progress ? ` · ${job.snapshot.last_progress}` : "";
252
- this.#emit("run_heartbeat", job, `⏱ ${root} running for ${elapsedSec}s${progress}`);
267
+ const reasonSuffix = heartbeatReason ? ` · wake=${heartbeatReason}` : "";
268
+ this.#emit("run_heartbeat", job, `⏱ ${root} running for ${elapsedSec}s${progress}${reasonSuffix}`);
253
269
  return { status: "ran" };
254
270
  },
255
271
  });
@@ -288,7 +304,7 @@ export class ControlPlaneRunSupervisor {
288
304
  throw new Error("run_start_prompt_required");
289
305
  }
290
306
  const maxSteps = toPositiveInt(opts.maxSteps, DEFAULT_MAX_STEPS);
291
- const argv = ["mu", "run", prompt, "--max-steps", String(maxSteps), "--raw-stream"];
307
+ const argv = ["mu", "_run-direct", prompt, "--max-steps", String(maxSteps), "--raw-stream"];
292
308
  return await this.#launch({
293
309
  mode: "run_start",
294
310
  prompt,
@@ -424,6 +440,15 @@ export class ControlPlaneRunSupervisor {
424
440
  return { ok: false, reason: "not_running", run: this.#snapshot(job) };
425
441
  }
426
442
  const reason = opts.reason?.trim() || "manual";
443
+ const wakeMode = normalizeWakeMode(opts.wakeMode);
444
+ if (wakeMode === "next_heartbeat") {
445
+ job.next_heartbeat_reason = reason;
446
+ this.#touch(job);
447
+ return { ok: true, reason: null, run: this.#snapshot(job) };
448
+ }
449
+ if (reason !== "interval") {
450
+ job.next_heartbeat_reason = null;
451
+ }
427
452
  this.#heartbeatScheduler.requestNow(job.snapshot.job_id, {
428
453
  reason,
429
454
  coalesceMs: 0,
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@ import { IssueStore } from "@femtomc/mu-issue";
6
6
  import { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
7
7
  import { type MuConfig } from "./config.js";
8
8
  import { type ControlPlaneConfig, type ControlPlaneHandle } from "./control_plane.js";
9
+ import { CronProgramRegistry } from "./cron_programs.js";
9
10
  import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
10
11
  import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
11
12
  type ControlPlaneReloader = (opts: {
@@ -24,6 +25,8 @@ export type ServerOptions = {
24
25
  activitySupervisor?: ControlPlaneActivitySupervisor;
25
26
  controlPlaneReloader?: ControlPlaneReloader;
26
27
  generationTelemetry?: GenerationTelemetryRecorder;
28
+ operatorWakeCoalesceMs?: number;
29
+ autoRunHeartbeatEveryMs?: number;
27
30
  config?: MuConfig;
28
31
  configReader?: ConfigReader;
29
32
  configWriter?: ConfigWriter;
@@ -43,6 +46,7 @@ export declare function createServer(options?: ServerOptions): {
43
46
  controlPlane: ControlPlaneHandle;
44
47
  activitySupervisor: ControlPlaneActivitySupervisor;
45
48
  heartbeatPrograms: HeartbeatProgramRegistry;
49
+ cronPrograms: CronProgramRegistry;
46
50
  };
47
51
  export type ServerWithControlPlane = {
48
52
  serverConfig: ReturnType<typeof createServer>;