@femtomc/mu-server 26.2.41 → 26.2.42
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/dist/activity_supervisor.d.ts +81 -0
- package/dist/activity_supervisor.js +306 -0
- package/dist/control_plane.d.ts +30 -0
- package/dist/control_plane.js +259 -8
- package/dist/heartbeat_programs.d.ts +93 -0
- package/dist/heartbeat_programs.js +415 -0
- package/dist/heartbeat_scheduler.d.ts +38 -0
- package/dist/heartbeat_scheduler.js +238 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/run_supervisor.d.ts +101 -0
- package/dist/run_supervisor.js +480 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +628 -3
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { applyMuConfigPatch, DEFAULT_MU_CONFIG, getMuConfigPath, muConfigPresence, normalizeMuConfig, readMuConfigFile, redactMuConfigSecrets, writeMuConfigFile, } from "./config.js";
|
|
2
|
+
export { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
|
|
3
|
+
export { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
2
4
|
export { bootstrapControlPlane, detectAdapters } from "./control_plane.js";
|
|
5
|
+
export { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
3
6
|
export { createContext, createServer, createServerAsync } from "./server.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CommandRecord } from "@femtomc/mu-control-plane";
|
|
2
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
3
|
+
export type ControlPlaneRunMode = "run_start" | "run_resume";
|
|
4
|
+
export type ControlPlaneRunStatus = "running" | "completed" | "failed" | "cancelled";
|
|
5
|
+
export type ControlPlaneRunSnapshot = {
|
|
6
|
+
job_id: string;
|
|
7
|
+
mode: ControlPlaneRunMode;
|
|
8
|
+
status: ControlPlaneRunStatus;
|
|
9
|
+
prompt: string | null;
|
|
10
|
+
root_issue_id: string | null;
|
|
11
|
+
max_steps: number;
|
|
12
|
+
command_id: string | null;
|
|
13
|
+
source: "command" | "api";
|
|
14
|
+
started_at_ms: number;
|
|
15
|
+
updated_at_ms: number;
|
|
16
|
+
finished_at_ms: number | null;
|
|
17
|
+
exit_code: number | null;
|
|
18
|
+
pid: number | null;
|
|
19
|
+
last_progress: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type ControlPlaneRunTrace = {
|
|
22
|
+
run: ControlPlaneRunSnapshot;
|
|
23
|
+
stdout: string[];
|
|
24
|
+
stderr: string[];
|
|
25
|
+
log_hints: string[];
|
|
26
|
+
trace_files: string[];
|
|
27
|
+
};
|
|
28
|
+
export type ControlPlaneRunInterruptResult = {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
reason: "not_found" | "not_running" | "missing_target" | null;
|
|
31
|
+
run: ControlPlaneRunSnapshot | null;
|
|
32
|
+
};
|
|
33
|
+
export type ControlPlaneRunHeartbeatResult = {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
reason: "not_found" | "not_running" | "missing_target" | null;
|
|
36
|
+
run: ControlPlaneRunSnapshot | null;
|
|
37
|
+
};
|
|
38
|
+
export type ControlPlaneRunEventKind = "run_started" | "run_root_discovered" | "run_progress" | "run_heartbeat" | "run_interrupt_requested" | "run_completed" | "run_failed" | "run_cancelled";
|
|
39
|
+
export type ControlPlaneRunEvent = {
|
|
40
|
+
seq: number;
|
|
41
|
+
ts_ms: number;
|
|
42
|
+
kind: ControlPlaneRunEventKind;
|
|
43
|
+
message: string;
|
|
44
|
+
run: ControlPlaneRunSnapshot;
|
|
45
|
+
command: CommandRecord | null;
|
|
46
|
+
};
|
|
47
|
+
export type ControlPlaneRunProcess = {
|
|
48
|
+
pid: number;
|
|
49
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
50
|
+
stderr: ReadableStream<Uint8Array> | null;
|
|
51
|
+
exited: Promise<number>;
|
|
52
|
+
kill(signal?: number | string): void;
|
|
53
|
+
};
|
|
54
|
+
export type ControlPlaneRunSupervisorOpts = {
|
|
55
|
+
repoRoot: string;
|
|
56
|
+
nowMs?: () => number;
|
|
57
|
+
spawnProcess?: (opts: {
|
|
58
|
+
argv: string[];
|
|
59
|
+
cwd: string;
|
|
60
|
+
}) => ControlPlaneRunProcess;
|
|
61
|
+
heartbeatIntervalMs?: number;
|
|
62
|
+
heartbeatScheduler?: ActivityHeartbeatScheduler;
|
|
63
|
+
maxStoredLines?: number;
|
|
64
|
+
maxHistory?: number;
|
|
65
|
+
onEvent?: (event: ControlPlaneRunEvent) => void | Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
export declare class ControlPlaneRunSupervisor {
|
|
68
|
+
#private;
|
|
69
|
+
constructor(opts: ControlPlaneRunSupervisorOpts);
|
|
70
|
+
launchStart(opts: {
|
|
71
|
+
prompt: string;
|
|
72
|
+
maxSteps?: number;
|
|
73
|
+
command?: CommandRecord | null;
|
|
74
|
+
source?: "command" | "api";
|
|
75
|
+
}): Promise<ControlPlaneRunSnapshot>;
|
|
76
|
+
launchResume(opts: {
|
|
77
|
+
rootIssueId: string;
|
|
78
|
+
maxSteps?: number;
|
|
79
|
+
command?: CommandRecord | null;
|
|
80
|
+
source?: "command" | "api";
|
|
81
|
+
}): Promise<ControlPlaneRunSnapshot>;
|
|
82
|
+
list(opts?: {
|
|
83
|
+
status?: ControlPlaneRunStatus;
|
|
84
|
+
limit?: number;
|
|
85
|
+
}): ControlPlaneRunSnapshot[];
|
|
86
|
+
get(idOrRoot: string): ControlPlaneRunSnapshot | null;
|
|
87
|
+
trace(idOrRoot: string, opts?: {
|
|
88
|
+
limit?: number;
|
|
89
|
+
}): Promise<ControlPlaneRunTrace | null>;
|
|
90
|
+
interrupt(opts: {
|
|
91
|
+
jobId?: string | null;
|
|
92
|
+
rootIssueId?: string | null;
|
|
93
|
+
}): ControlPlaneRunInterruptResult;
|
|
94
|
+
heartbeat(opts: {
|
|
95
|
+
jobId?: string | null;
|
|
96
|
+
rootIssueId?: string | null;
|
|
97
|
+
reason?: string | null;
|
|
98
|
+
}): ControlPlaneRunHeartbeatResult;
|
|
99
|
+
startFromCommand(command: CommandRecord): Promise<ControlPlaneRunSnapshot | null>;
|
|
100
|
+
stop(): Promise<void>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
4
|
+
const DEFAULT_MAX_STEPS = 20;
|
|
5
|
+
const ROOT_RE = /\bRoot:\s*(mu-[a-z0-9][a-z0-9-]*)\b/i;
|
|
6
|
+
const STEP_RE = /^(Step|Done)\s+\d+\/\d+\s+/;
|
|
7
|
+
const LOG_HINT_RE = /\blogs:\s+(\S+)/i;
|
|
8
|
+
function defaultNowMs() {
|
|
9
|
+
return Date.now();
|
|
10
|
+
}
|
|
11
|
+
function defaultSpawnProcess(opts) {
|
|
12
|
+
const proc = Bun.spawn({
|
|
13
|
+
cmd: opts.argv,
|
|
14
|
+
cwd: opts.cwd,
|
|
15
|
+
stdin: "ignore",
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
env: Bun.env,
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
pid: proc.pid,
|
|
22
|
+
stdout: proc.stdout,
|
|
23
|
+
stderr: proc.stderr,
|
|
24
|
+
exited: proc.exited,
|
|
25
|
+
kill(signal) {
|
|
26
|
+
proc.kill(signal);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function toPositiveInt(value, fallback) {
|
|
31
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
32
|
+
return Math.max(1, Math.trunc(value));
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
35
|
+
return Math.max(1, Number.parseInt(value, 10));
|
|
36
|
+
}
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
function normalizeIssueId(value) {
|
|
40
|
+
if (!value)
|
|
41
|
+
return null;
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
if (!/^mu-[a-z0-9][a-z0-9-]*$/i.test(trimmed)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return trimmed.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
function pushBounded(lines, line, maxLines) {
|
|
49
|
+
lines.push(line);
|
|
50
|
+
if (lines.length <= maxLines) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
lines.splice(0, lines.length - maxLines);
|
|
54
|
+
}
|
|
55
|
+
async function consumeStreamLines(stream, onLine) {
|
|
56
|
+
if (!stream) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const reader = stream.getReader();
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
let pending = "";
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
pending += decoder.decode(value, { stream: true });
|
|
69
|
+
while (true) {
|
|
70
|
+
const newline = pending.indexOf("\n");
|
|
71
|
+
if (newline < 0) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const line = pending.slice(0, newline).replace(/\r$/, "");
|
|
75
|
+
pending = pending.slice(newline + 1);
|
|
76
|
+
onLine(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
pending += decoder.decode();
|
|
80
|
+
const finalLine = pending.replace(/\r$/, "").trimEnd();
|
|
81
|
+
if (finalLine.length > 0) {
|
|
82
|
+
onLine(finalLine);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
reader.releaseLock();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function describeRun(snapshot) {
|
|
90
|
+
const root = snapshot.root_issue_id ?? snapshot.job_id;
|
|
91
|
+
return `${snapshot.mode} ${root}`;
|
|
92
|
+
}
|
|
93
|
+
export class ControlPlaneRunSupervisor {
|
|
94
|
+
#repoRoot;
|
|
95
|
+
#nowMs;
|
|
96
|
+
#spawnProcess;
|
|
97
|
+
#heartbeatIntervalMs;
|
|
98
|
+
#heartbeatScheduler;
|
|
99
|
+
#ownsHeartbeatScheduler;
|
|
100
|
+
#maxStoredLines;
|
|
101
|
+
#maxHistory;
|
|
102
|
+
#onEvent;
|
|
103
|
+
#jobsById = new Map();
|
|
104
|
+
#jobIdByRootIssueId = new Map();
|
|
105
|
+
#seq = 0;
|
|
106
|
+
#jobCounter = 0;
|
|
107
|
+
constructor(opts) {
|
|
108
|
+
this.#repoRoot = opts.repoRoot;
|
|
109
|
+
this.#nowMs = opts.nowMs ?? defaultNowMs;
|
|
110
|
+
this.#spawnProcess = opts.spawnProcess ?? defaultSpawnProcess;
|
|
111
|
+
this.#heartbeatIntervalMs = Math.max(2_000, Math.trunc(opts.heartbeatIntervalMs ?? 15_000));
|
|
112
|
+
this.#heartbeatScheduler = opts.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
|
|
113
|
+
this.#ownsHeartbeatScheduler = !opts.heartbeatScheduler;
|
|
114
|
+
this.#maxStoredLines = Math.max(50, Math.trunc(opts.maxStoredLines ?? 1_000));
|
|
115
|
+
this.#maxHistory = Math.max(20, Math.trunc(opts.maxHistory ?? 200));
|
|
116
|
+
this.#onEvent = opts.onEvent ?? null;
|
|
117
|
+
}
|
|
118
|
+
#nextJobId() {
|
|
119
|
+
this.#jobCounter += 1;
|
|
120
|
+
return `run-job-${this.#jobCounter.toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
121
|
+
}
|
|
122
|
+
#snapshot(job) {
|
|
123
|
+
return { ...job.snapshot };
|
|
124
|
+
}
|
|
125
|
+
#allJobsSorted() {
|
|
126
|
+
return [...this.#jobsById.values()].sort((a, b) => {
|
|
127
|
+
if (a.snapshot.started_at_ms !== b.snapshot.started_at_ms) {
|
|
128
|
+
return b.snapshot.started_at_ms - a.snapshot.started_at_ms;
|
|
129
|
+
}
|
|
130
|
+
return a.snapshot.job_id.localeCompare(b.snapshot.job_id);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
#pruneHistory() {
|
|
134
|
+
const jobs = this.#allJobsSorted();
|
|
135
|
+
let kept = 0;
|
|
136
|
+
for (const job of jobs) {
|
|
137
|
+
if (job.snapshot.status === "running") {
|
|
138
|
+
kept += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
kept += 1;
|
|
142
|
+
if (kept <= this.#maxHistory) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
this.#jobsById.delete(job.snapshot.job_id);
|
|
146
|
+
if (job.snapshot.root_issue_id) {
|
|
147
|
+
const current = this.#jobIdByRootIssueId.get(job.snapshot.root_issue_id);
|
|
148
|
+
if (current === job.snapshot.job_id) {
|
|
149
|
+
this.#jobIdByRootIssueId.delete(job.snapshot.root_issue_id);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
#emit(kind, job, message) {
|
|
155
|
+
if (!this.#onEvent) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const event = {
|
|
159
|
+
seq: ++this.#seq,
|
|
160
|
+
ts_ms: Math.trunc(this.#nowMs()),
|
|
161
|
+
kind,
|
|
162
|
+
message,
|
|
163
|
+
run: this.#snapshot(job),
|
|
164
|
+
command: job.command,
|
|
165
|
+
};
|
|
166
|
+
void Promise.resolve(this.#onEvent(event)).catch(() => {
|
|
167
|
+
// Swallow notifier errors to avoid destabilizing run supervision.
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
#touch(job) {
|
|
171
|
+
job.snapshot.updated_at_ms = Math.trunc(this.#nowMs());
|
|
172
|
+
}
|
|
173
|
+
#markRootIssueId(job, rootIssueId) {
|
|
174
|
+
const normalized = normalizeIssueId(rootIssueId);
|
|
175
|
+
if (!normalized) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (job.snapshot.root_issue_id === normalized) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
job.snapshot.root_issue_id = normalized;
|
|
182
|
+
this.#jobIdByRootIssueId.set(normalized, job.snapshot.job_id);
|
|
183
|
+
this.#touch(job);
|
|
184
|
+
this.#emit("run_root_discovered", job, `🧩 Run root identified: ${normalized}`);
|
|
185
|
+
}
|
|
186
|
+
#handleLine(job, stream, line) {
|
|
187
|
+
if (stream === "stdout") {
|
|
188
|
+
pushBounded(job.stdout_lines, line, this.#maxStoredLines);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
pushBounded(job.stderr_lines, line, this.#maxStoredLines);
|
|
192
|
+
}
|
|
193
|
+
const rootMatch = ROOT_RE.exec(line);
|
|
194
|
+
if (rootMatch?.[1]) {
|
|
195
|
+
this.#markRootIssueId(job, rootMatch[1]);
|
|
196
|
+
}
|
|
197
|
+
const logHintMatch = LOG_HINT_RE.exec(line);
|
|
198
|
+
if (logHintMatch?.[1]) {
|
|
199
|
+
job.log_hints.add(logHintMatch[1]);
|
|
200
|
+
this.#touch(job);
|
|
201
|
+
}
|
|
202
|
+
if (STEP_RE.test(line)) {
|
|
203
|
+
job.snapshot.last_progress = line.trim();
|
|
204
|
+
this.#touch(job);
|
|
205
|
+
this.#emit("run_progress", job, `📈 ${line.trim()}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async #launch(opts) {
|
|
209
|
+
const nowMs = Math.trunc(this.#nowMs());
|
|
210
|
+
const process = this.#spawnProcess({ argv: opts.argv, cwd: this.#repoRoot });
|
|
211
|
+
const snapshot = {
|
|
212
|
+
job_id: this.#nextJobId(),
|
|
213
|
+
mode: opts.mode,
|
|
214
|
+
status: "running",
|
|
215
|
+
prompt: opts.prompt,
|
|
216
|
+
root_issue_id: opts.rootIssueId,
|
|
217
|
+
max_steps: opts.maxSteps,
|
|
218
|
+
command_id: opts.command?.command_id ?? null,
|
|
219
|
+
source: opts.source,
|
|
220
|
+
started_at_ms: nowMs,
|
|
221
|
+
updated_at_ms: nowMs,
|
|
222
|
+
finished_at_ms: null,
|
|
223
|
+
exit_code: null,
|
|
224
|
+
pid: process.pid,
|
|
225
|
+
last_progress: null,
|
|
226
|
+
};
|
|
227
|
+
const job = {
|
|
228
|
+
snapshot,
|
|
229
|
+
command: opts.command,
|
|
230
|
+
process,
|
|
231
|
+
stdout_lines: [],
|
|
232
|
+
stderr_lines: [],
|
|
233
|
+
log_hints: new Set(),
|
|
234
|
+
interrupt_requested: false,
|
|
235
|
+
hard_kill_timer: null,
|
|
236
|
+
};
|
|
237
|
+
this.#jobsById.set(snapshot.job_id, job);
|
|
238
|
+
if (snapshot.root_issue_id) {
|
|
239
|
+
this.#jobIdByRootIssueId.set(snapshot.root_issue_id, snapshot.job_id);
|
|
240
|
+
}
|
|
241
|
+
this.#emit("run_started", job, `🚀 Started ${describeRun(snapshot)} (job ${snapshot.job_id}, pid ${snapshot.pid ?? "?"})`);
|
|
242
|
+
this.#heartbeatScheduler.register({
|
|
243
|
+
activityId: snapshot.job_id,
|
|
244
|
+
everyMs: this.#heartbeatIntervalMs,
|
|
245
|
+
handler: async () => {
|
|
246
|
+
if (job.snapshot.status !== "running") {
|
|
247
|
+
return { status: "skipped", reason: "not_running" };
|
|
248
|
+
}
|
|
249
|
+
const elapsedSec = Math.max(0, Math.trunc((this.#nowMs() - job.snapshot.started_at_ms) / 1_000));
|
|
250
|
+
const root = job.snapshot.root_issue_id ?? job.snapshot.job_id;
|
|
251
|
+
const progress = job.snapshot.last_progress ? ` · ${job.snapshot.last_progress}` : "";
|
|
252
|
+
this.#emit("run_heartbeat", job, `⏱ ${root} running for ${elapsedSec}s${progress}`);
|
|
253
|
+
return { status: "ran" };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
void (async () => {
|
|
257
|
+
const stdoutTask = consumeStreamLines(process.stdout, (line) => this.#handleLine(job, "stdout", line));
|
|
258
|
+
const stderrTask = consumeStreamLines(process.stderr, (line) => this.#handleLine(job, "stderr", line));
|
|
259
|
+
const exitCode = await process.exited.catch(() => -1);
|
|
260
|
+
await Promise.allSettled([stdoutTask, stderrTask]);
|
|
261
|
+
this.#heartbeatScheduler.unregister(job.snapshot.job_id);
|
|
262
|
+
if (job.hard_kill_timer) {
|
|
263
|
+
clearTimeout(job.hard_kill_timer);
|
|
264
|
+
job.hard_kill_timer = null;
|
|
265
|
+
}
|
|
266
|
+
job.snapshot.exit_code = exitCode;
|
|
267
|
+
job.snapshot.finished_at_ms = Math.trunc(this.#nowMs());
|
|
268
|
+
job.snapshot.updated_at_ms = job.snapshot.finished_at_ms;
|
|
269
|
+
if (job.interrupt_requested) {
|
|
270
|
+
job.snapshot.status = "cancelled";
|
|
271
|
+
this.#emit("run_cancelled", job, `🛑 ${describeRun(job.snapshot)} interrupted (exit ${exitCode}).`);
|
|
272
|
+
}
|
|
273
|
+
else if (exitCode === 0) {
|
|
274
|
+
job.snapshot.status = "completed";
|
|
275
|
+
this.#emit("run_completed", job, `✅ ${describeRun(job.snapshot)} completed successfully.`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
job.snapshot.status = "failed";
|
|
279
|
+
this.#emit("run_failed", job, `❌ ${describeRun(job.snapshot)} failed (exit ${exitCode}).`);
|
|
280
|
+
}
|
|
281
|
+
this.#pruneHistory();
|
|
282
|
+
})();
|
|
283
|
+
return this.#snapshot(job);
|
|
284
|
+
}
|
|
285
|
+
async launchStart(opts) {
|
|
286
|
+
const prompt = opts.prompt.trim();
|
|
287
|
+
if (prompt.length === 0) {
|
|
288
|
+
throw new Error("run_start_prompt_required");
|
|
289
|
+
}
|
|
290
|
+
const maxSteps = toPositiveInt(opts.maxSteps, DEFAULT_MAX_STEPS);
|
|
291
|
+
const argv = ["mu", "run", prompt, "--max-steps", String(maxSteps), "--raw-stream"];
|
|
292
|
+
return await this.#launch({
|
|
293
|
+
mode: "run_start",
|
|
294
|
+
prompt,
|
|
295
|
+
rootIssueId: null,
|
|
296
|
+
maxSteps,
|
|
297
|
+
argv,
|
|
298
|
+
command: opts.command ?? null,
|
|
299
|
+
source: opts.source ?? "api",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async launchResume(opts) {
|
|
303
|
+
const rootIssueId = normalizeIssueId(opts.rootIssueId);
|
|
304
|
+
if (!rootIssueId) {
|
|
305
|
+
throw new Error("run_resume_invalid_root_issue_id");
|
|
306
|
+
}
|
|
307
|
+
const maxSteps = toPositiveInt(opts.maxSteps, DEFAULT_MAX_STEPS);
|
|
308
|
+
const argv = ["mu", "resume", rootIssueId, "--max-steps", String(maxSteps), "--raw-stream"];
|
|
309
|
+
return await this.#launch({
|
|
310
|
+
mode: "run_resume",
|
|
311
|
+
prompt: null,
|
|
312
|
+
rootIssueId,
|
|
313
|
+
maxSteps,
|
|
314
|
+
argv,
|
|
315
|
+
command: opts.command ?? null,
|
|
316
|
+
source: opts.source ?? "api",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
list(opts = {}) {
|
|
320
|
+
const limit = Math.max(1, Math.min(500, Math.trunc(opts.limit ?? 100)));
|
|
321
|
+
const filtered = this.#allJobsSorted().filter((job) => opts.status ? job.snapshot.status === opts.status : true);
|
|
322
|
+
return filtered.slice(0, limit).map((job) => this.#snapshot(job));
|
|
323
|
+
}
|
|
324
|
+
#resolveJob(idOrRoot) {
|
|
325
|
+
const trimmed = idOrRoot.trim();
|
|
326
|
+
if (trimmed.length === 0) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const byId = this.#jobsById.get(trimmed);
|
|
330
|
+
if (byId) {
|
|
331
|
+
return byId;
|
|
332
|
+
}
|
|
333
|
+
const root = normalizeIssueId(trimmed);
|
|
334
|
+
if (!root) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const jobId = this.#jobIdByRootIssueId.get(root);
|
|
338
|
+
if (!jobId) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
return this.#jobsById.get(jobId) ?? null;
|
|
342
|
+
}
|
|
343
|
+
get(idOrRoot) {
|
|
344
|
+
const job = this.#resolveJob(idOrRoot);
|
|
345
|
+
return job ? this.#snapshot(job) : null;
|
|
346
|
+
}
|
|
347
|
+
async trace(idOrRoot, opts = {}) {
|
|
348
|
+
const job = this.#resolveJob(idOrRoot);
|
|
349
|
+
if (!job) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const limit = Math.max(1, Math.min(2_000, Math.trunc(opts.limit ?? 200)));
|
|
353
|
+
const rootIssueId = job.snapshot.root_issue_id;
|
|
354
|
+
const traceFiles = [];
|
|
355
|
+
if (rootIssueId) {
|
|
356
|
+
const rootLogsDir = join(this.#repoRoot, ".mu", "logs", rootIssueId);
|
|
357
|
+
try {
|
|
358
|
+
const entries = await readdir(rootLogsDir, { withFileTypes: true });
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (!entry.isFile())
|
|
361
|
+
continue;
|
|
362
|
+
if (!entry.name.endsWith(".jsonl"))
|
|
363
|
+
continue;
|
|
364
|
+
traceFiles.push(relative(this.#repoRoot, join(rootLogsDir, entry.name)).replaceAll("\\", "/"));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// best effort only
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
run: this.#snapshot(job),
|
|
373
|
+
stdout: job.stdout_lines.slice(-limit),
|
|
374
|
+
stderr: job.stderr_lines.slice(-limit),
|
|
375
|
+
log_hints: [...job.log_hints],
|
|
376
|
+
trace_files: traceFiles.sort((a, b) => a.localeCompare(b)),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
interrupt(opts) {
|
|
380
|
+
const target = opts.jobId?.trim() || opts.rootIssueId?.trim() || "";
|
|
381
|
+
if (target.length === 0) {
|
|
382
|
+
return { ok: false, reason: "missing_target", run: null };
|
|
383
|
+
}
|
|
384
|
+
const job = this.#resolveJob(target);
|
|
385
|
+
if (!job) {
|
|
386
|
+
return { ok: false, reason: "not_found", run: null };
|
|
387
|
+
}
|
|
388
|
+
if (job.snapshot.status !== "running") {
|
|
389
|
+
return { ok: false, reason: "not_running", run: this.#snapshot(job) };
|
|
390
|
+
}
|
|
391
|
+
job.interrupt_requested = true;
|
|
392
|
+
this.#touch(job);
|
|
393
|
+
try {
|
|
394
|
+
job.process.kill("SIGINT");
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// best effort
|
|
398
|
+
}
|
|
399
|
+
job.hard_kill_timer = setTimeout(() => {
|
|
400
|
+
if (job.snapshot.status !== "running") {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
job.process.kill("SIGKILL");
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// best effort
|
|
408
|
+
}
|
|
409
|
+
}, 5_000);
|
|
410
|
+
const root = job.snapshot.root_issue_id ?? job.snapshot.job_id;
|
|
411
|
+
this.#emit("run_interrupt_requested", job, `⚠️ Interrupt requested for ${root}.`);
|
|
412
|
+
return { ok: true, reason: null, run: this.#snapshot(job) };
|
|
413
|
+
}
|
|
414
|
+
heartbeat(opts) {
|
|
415
|
+
const target = opts.jobId?.trim() || opts.rootIssueId?.trim() || "";
|
|
416
|
+
if (target.length === 0) {
|
|
417
|
+
return { ok: false, reason: "missing_target", run: null };
|
|
418
|
+
}
|
|
419
|
+
const job = this.#resolveJob(target);
|
|
420
|
+
if (!job) {
|
|
421
|
+
return { ok: false, reason: "not_found", run: null };
|
|
422
|
+
}
|
|
423
|
+
if (job.snapshot.status !== "running") {
|
|
424
|
+
return { ok: false, reason: "not_running", run: this.#snapshot(job) };
|
|
425
|
+
}
|
|
426
|
+
const reason = opts.reason?.trim() || "manual";
|
|
427
|
+
this.#heartbeatScheduler.requestNow(job.snapshot.job_id, {
|
|
428
|
+
reason,
|
|
429
|
+
coalesceMs: 0,
|
|
430
|
+
});
|
|
431
|
+
return { ok: true, reason: null, run: this.#snapshot(job) };
|
|
432
|
+
}
|
|
433
|
+
async startFromCommand(command) {
|
|
434
|
+
switch (command.target_type) {
|
|
435
|
+
case "run start": {
|
|
436
|
+
const prompt = command.command_args.join(" ").trim();
|
|
437
|
+
if (prompt.length === 0) {
|
|
438
|
+
throw new Error("run_start_prompt_required");
|
|
439
|
+
}
|
|
440
|
+
return await this.launchStart({ prompt, command, source: "command" });
|
|
441
|
+
}
|
|
442
|
+
case "run resume": {
|
|
443
|
+
const fallbackRoot = normalizeIssueId(command.target_id);
|
|
444
|
+
const explicitRoot = normalizeIssueId(command.command_args[0] ?? "") ?? fallbackRoot;
|
|
445
|
+
if (!explicitRoot) {
|
|
446
|
+
throw new Error("run_resume_invalid_root_issue_id");
|
|
447
|
+
}
|
|
448
|
+
const maxSteps = toPositiveInt(command.command_args[1], DEFAULT_MAX_STEPS);
|
|
449
|
+
return await this.launchResume({
|
|
450
|
+
rootIssueId: explicitRoot,
|
|
451
|
+
maxSteps,
|
|
452
|
+
command,
|
|
453
|
+
source: "command",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
default:
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async stop() {
|
|
461
|
+
for (const job of this.#jobsById.values()) {
|
|
462
|
+
this.#heartbeatScheduler.unregister(job.snapshot.job_id);
|
|
463
|
+
if (job.hard_kill_timer) {
|
|
464
|
+
clearTimeout(job.hard_kill_timer);
|
|
465
|
+
job.hard_kill_timer = null;
|
|
466
|
+
}
|
|
467
|
+
if (job.snapshot.status === "running") {
|
|
468
|
+
try {
|
|
469
|
+
job.process.kill("SIGTERM");
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// best effort
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (this.#ownsHeartbeatScheduler) {
|
|
477
|
+
this.#heartbeatScheduler.stop();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ import { EventLog } from "@femtomc/mu-core/node";
|
|
|
3
3
|
import { ForumStore } from "@femtomc/mu-forum";
|
|
4
4
|
import { IssueStore } from "@femtomc/mu-issue";
|
|
5
5
|
import { type MuConfig } from "./config.js";
|
|
6
|
+
import { ControlPlaneActivitySupervisor } from "./activity_supervisor.js";
|
|
6
7
|
import { type ControlPlaneConfig, type ControlPlaneHandle } from "./control_plane.js";
|
|
8
|
+
import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
|
|
9
|
+
import { HeartbeatProgramRegistry } from "./heartbeat_programs.js";
|
|
7
10
|
type ControlPlaneReloader = (opts: {
|
|
8
11
|
repoRoot: string;
|
|
9
12
|
previous: ControlPlaneHandle | null;
|
|
@@ -15,6 +18,8 @@ export type ServerOptions = {
|
|
|
15
18
|
repoRoot?: string;
|
|
16
19
|
port?: number;
|
|
17
20
|
controlPlane?: ControlPlaneHandle | null;
|
|
21
|
+
heartbeatScheduler?: ActivityHeartbeatScheduler;
|
|
22
|
+
activitySupervisor?: ControlPlaneActivitySupervisor;
|
|
18
23
|
controlPlaneReloader?: ControlPlaneReloader;
|
|
19
24
|
config?: MuConfig;
|
|
20
25
|
configReader?: ConfigReader;
|
|
@@ -33,6 +38,8 @@ export declare function createServer(options?: ServerOptions): {
|
|
|
33
38
|
fetch: (request: Request) => Promise<Response>;
|
|
34
39
|
hostname: string;
|
|
35
40
|
controlPlane: ControlPlaneHandle;
|
|
41
|
+
activitySupervisor: ControlPlaneActivitySupervisor;
|
|
42
|
+
heartbeatPrograms: HeartbeatProgramRegistry;
|
|
36
43
|
};
|
|
37
44
|
export type ServerWithControlPlane = {
|
|
38
45
|
serverConfig: ReturnType<typeof createServer>;
|