@love-moon/conductor-cli 0.2.6 → 0.2.8
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/bin/conductor-fire.js +148 -203
- package/package.json +3 -4
- package/src/daemon.js +163 -2
package/bin/conductor-fire.js
CHANGED
|
@@ -4,24 +4,21 @@
|
|
|
4
4
|
* conductor-fire - Conductor-aware AI coding agent runner.
|
|
5
5
|
*
|
|
6
6
|
* Supports configurable backends via allow_cli_list in config file.
|
|
7
|
-
* This CLI bridges various AI coding agents with Conductor via
|
|
7
|
+
* This CLI bridges various AI coding agents with Conductor via the Conductor SDK.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
|
-
import { createRequire } from "node:module";
|
|
12
11
|
import os from "node:os";
|
|
13
12
|
import path from "node:path";
|
|
14
13
|
import process from "node:process";
|
|
15
14
|
import { setTimeout as delay } from "node:timers/promises";
|
|
16
15
|
import { fileURLToPath } from "node:url";
|
|
17
16
|
|
|
18
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
20
17
|
import yargs from "yargs/yargs";
|
|
21
18
|
import { hideBin } from "yargs/helpers";
|
|
22
19
|
import yaml from "js-yaml";
|
|
23
20
|
import { TuiDriver, claudeCodeProfile, codexProfile, copilotProfile } from "@love-moon/tui-driver";
|
|
24
|
-
import { loadConfig } from "@love-moon/conductor-sdk";
|
|
21
|
+
import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
|
|
25
22
|
import {
|
|
26
23
|
loadHistoryFromSpec,
|
|
27
24
|
parseFromSpec,
|
|
@@ -31,21 +28,8 @@ import {
|
|
|
31
28
|
|
|
32
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
33
30
|
const __dirname = path.dirname(__filename);
|
|
34
|
-
const require = createRequire(import.meta.url);
|
|
35
31
|
const PKG_ROOT = path.join(__dirname, "..");
|
|
36
32
|
const CLI_PROJECT_PATH = process.cwd();
|
|
37
|
-
const REPO_ROOT_FALLBACK = path.resolve(__dirname, "..", "..");
|
|
38
|
-
const SDK_FALLBACK_PATH = path.join(REPO_ROOT_FALLBACK, "modules", "sdk");
|
|
39
|
-
const SDK_ROOT =
|
|
40
|
-
process.env.CONDUCTOR_SDK_PATH ||
|
|
41
|
-
(() => {
|
|
42
|
-
try {
|
|
43
|
-
return path.dirname(require.resolve("@love-moon/conductor-sdk/package.json"));
|
|
44
|
-
} catch {
|
|
45
|
-
return SDK_FALLBACK_PATH;
|
|
46
|
-
}
|
|
47
|
-
})();
|
|
48
|
-
const MCP_SERVER_SCRIPT = path.join(SDK_ROOT, "dist", "bin", "mcp-server.js");
|
|
49
33
|
|
|
50
34
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
|
|
51
35
|
const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1] || "conductor-fire")).replace(
|
|
@@ -122,12 +106,6 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
|
|
|
122
106
|
10,
|
|
123
107
|
);
|
|
124
108
|
|
|
125
|
-
const MCP_SERVER_LAUNCH = {
|
|
126
|
-
command: "node",
|
|
127
|
-
args: [MCP_SERVER_SCRIPT],
|
|
128
|
-
cwd: SDK_ROOT,
|
|
129
|
-
};
|
|
130
|
-
|
|
131
109
|
async function main() {
|
|
132
110
|
const cliArgs = parseCliArgs();
|
|
133
111
|
|
|
@@ -204,6 +182,45 @@ async function main() {
|
|
|
204
182
|
log(`Using backend: ${cliArgs.backend}`);
|
|
205
183
|
|
|
206
184
|
const env = buildEnv();
|
|
185
|
+
const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
|
|
186
|
+
let reconnectRunner = null;
|
|
187
|
+
let reconnectTaskId = null;
|
|
188
|
+
let conductor = null;
|
|
189
|
+
let reconnectResumeInFlight = false;
|
|
190
|
+
|
|
191
|
+
const scheduleReconnectRecovery = ({ isReconnect }) => {
|
|
192
|
+
if (!isReconnect) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
log("Conductor connection restored");
|
|
196
|
+
if (reconnectRunner && typeof reconnectRunner.noteReconnect === "function") {
|
|
197
|
+
reconnectRunner.noteReconnect();
|
|
198
|
+
}
|
|
199
|
+
if (!conductor || !reconnectTaskId || reconnectResumeInFlight) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
reconnectResumeInFlight = true;
|
|
203
|
+
void (async () => {
|
|
204
|
+
try {
|
|
205
|
+
await conductor.sendAgentResume({
|
|
206
|
+
active_tasks: [reconnectTaskId],
|
|
207
|
+
source: "conductor-fire",
|
|
208
|
+
metadata: { reconnect: true },
|
|
209
|
+
});
|
|
210
|
+
if (!launchedByDaemon) {
|
|
211
|
+
await conductor.sendTaskStatus(reconnectTaskId, {
|
|
212
|
+
status: "RUNNING",
|
|
213
|
+
summary: "conductor fire reconnected",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
log(`Failed to report reconnect resume: ${error?.message || error}`);
|
|
218
|
+
} finally {
|
|
219
|
+
reconnectResumeInFlight = false;
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
};
|
|
223
|
+
|
|
207
224
|
if (cliArgs.configFile) {
|
|
208
225
|
env.CONDUCTOR_CONFIG = cliArgs.configFile;
|
|
209
226
|
}
|
|
@@ -222,11 +239,11 @@ async function main() {
|
|
|
222
239
|
// Ignore config loading errors, rely on env vars or defaults
|
|
223
240
|
}
|
|
224
241
|
|
|
225
|
-
|
|
226
|
-
launcher: MCP_SERVER_LAUNCH,
|
|
227
|
-
workingDirectory: CLI_PROJECT_PATH,
|
|
242
|
+
conductor = await ConductorClient.connect({
|
|
228
243
|
projectPath: CLI_PROJECT_PATH,
|
|
229
244
|
extraEnv: env,
|
|
245
|
+
configFile: cliArgs.configFile,
|
|
246
|
+
onConnected: scheduleReconnectRecovery,
|
|
230
247
|
});
|
|
231
248
|
|
|
232
249
|
const taskContext = await ensureTaskContext(conductor, {
|
|
@@ -242,6 +259,17 @@ async function main() {
|
|
|
242
259
|
taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
|
|
243
260
|
}`,
|
|
244
261
|
);
|
|
262
|
+
reconnectTaskId = taskContext.taskId;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await conductor.sendAgentResume({
|
|
266
|
+
active_tasks: [taskContext.taskId],
|
|
267
|
+
source: "conductor-fire",
|
|
268
|
+
metadata: { reconnect: false },
|
|
269
|
+
});
|
|
270
|
+
} catch (error) {
|
|
271
|
+
log(`Failed to report agent resume: ${error?.message || error}`);
|
|
272
|
+
}
|
|
245
273
|
|
|
246
274
|
const runner = new BridgeRunner({
|
|
247
275
|
backendSession,
|
|
@@ -253,10 +281,10 @@ async function main() {
|
|
|
253
281
|
cliArgs: cliArgs.rawBackendArgs,
|
|
254
282
|
backendName: cliArgs.backend,
|
|
255
283
|
});
|
|
284
|
+
reconnectRunner = runner;
|
|
256
285
|
|
|
257
286
|
const signals = new AbortController();
|
|
258
287
|
let shutdownSignal = null;
|
|
259
|
-
const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
|
|
260
288
|
const onSigint = () => {
|
|
261
289
|
shutdownSignal = shutdownSignal || "SIGINT";
|
|
262
290
|
signals.abort();
|
|
@@ -268,19 +296,44 @@ async function main() {
|
|
|
268
296
|
process.on("SIGINT", onSigint);
|
|
269
297
|
process.on("SIGTERM", onSigterm);
|
|
270
298
|
|
|
299
|
+
if (!launchedByDaemon) {
|
|
300
|
+
try {
|
|
301
|
+
await conductor.sendTaskStatus(taskContext.taskId, {
|
|
302
|
+
status: "RUNNING",
|
|
303
|
+
});
|
|
304
|
+
} catch (error) {
|
|
305
|
+
log(`Failed to report task status (RUNNING): ${error?.message || error}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let runnerError = null;
|
|
271
310
|
try {
|
|
272
311
|
await runner.start(signals.signal);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
runnerError = error;
|
|
314
|
+
throw error;
|
|
273
315
|
} finally {
|
|
274
316
|
process.off("SIGINT", onSigint);
|
|
275
317
|
process.off("SIGTERM", onSigterm);
|
|
276
|
-
if (
|
|
318
|
+
if (!launchedByDaemon) {
|
|
319
|
+
const finalStatus = shutdownSignal
|
|
320
|
+
? {
|
|
321
|
+
status: "KILLED",
|
|
322
|
+
summary: `terminated by ${shutdownSignal}`,
|
|
323
|
+
}
|
|
324
|
+
: runnerError
|
|
325
|
+
? {
|
|
326
|
+
status: "KILLED",
|
|
327
|
+
summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
|
|
328
|
+
}
|
|
329
|
+
: {
|
|
330
|
+
status: "COMPLETED",
|
|
331
|
+
summary: "conductor fire exited",
|
|
332
|
+
};
|
|
277
333
|
try {
|
|
278
|
-
await conductor.sendTaskStatus(taskContext.taskId,
|
|
279
|
-
status: "KILLED",
|
|
280
|
-
summary: `terminated by ${shutdownSignal}`,
|
|
281
|
-
});
|
|
334
|
+
await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
|
|
282
335
|
} catch (error) {
|
|
283
|
-
log(`Failed to report task status (
|
|
336
|
+
log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
|
|
284
337
|
}
|
|
285
338
|
}
|
|
286
339
|
if (typeof backendSession.close === "function") {
|
|
@@ -537,7 +590,19 @@ async function resolveProjectId(conductor, explicit) {
|
|
|
537
590
|
try {
|
|
538
591
|
const record = await conductor.getLocalProjectRecord();
|
|
539
592
|
if (record?.project_id) {
|
|
540
|
-
|
|
593
|
+
try {
|
|
594
|
+
const listing = await conductor.listProjects();
|
|
595
|
+
const exists = Array.isArray(listing?.projects)
|
|
596
|
+
? listing.projects.some((project) => String(project?.id || "") === String(record.project_id))
|
|
597
|
+
: false;
|
|
598
|
+
if (exists) {
|
|
599
|
+
return record.project_id;
|
|
600
|
+
}
|
|
601
|
+
log(`Local session project ${record.project_id} no longer exists; falling back to server project list`);
|
|
602
|
+
} catch (verifyError) {
|
|
603
|
+
log(`Unable to verify local project record; using cached project id: ${verifyError.message}`);
|
|
604
|
+
return record.project_id;
|
|
605
|
+
}
|
|
541
606
|
}
|
|
542
607
|
} catch (error) {
|
|
543
608
|
log(`Unable to resolve project via local session: ${error.message}`);
|
|
@@ -973,174 +1038,6 @@ class TuiDriverSession {
|
|
|
973
1038
|
}
|
|
974
1039
|
}
|
|
975
1040
|
|
|
976
|
-
class ConductorClient {
|
|
977
|
-
constructor(client, transport, options = {}) {
|
|
978
|
-
this.client = client;
|
|
979
|
-
this.transport = transport;
|
|
980
|
-
this.closed = false;
|
|
981
|
-
this.projectPath = options.projectPath || process.cwd();
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
static async connect({ launcher, workingDirectory, extraEnv, projectPath }) {
|
|
985
|
-
if (!fs.existsSync(launcher.args[0])) {
|
|
986
|
-
throw new Error(`Conductor MCP server not found at ${launcher.args[0]}`);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
const transport = new StdioClientTransport({
|
|
990
|
-
command: launcher.command,
|
|
991
|
-
args: launcher.args,
|
|
992
|
-
env: extraEnv,
|
|
993
|
-
cwd: launcher.cwd || workingDirectory,
|
|
994
|
-
stderr: "pipe",
|
|
995
|
-
});
|
|
996
|
-
|
|
997
|
-
const client = new Client({
|
|
998
|
-
name: CLI_NAME,
|
|
999
|
-
version: pkgJson.version,
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
client.onerror = (err) => {
|
|
1003
|
-
log(`MCP client error: ${err.message}`);
|
|
1004
|
-
};
|
|
1005
|
-
|
|
1006
|
-
const stderr = transport.stderr;
|
|
1007
|
-
if (stderr) {
|
|
1008
|
-
stderr.setEncoding("utf-8");
|
|
1009
|
-
stderr.on("data", (chunk) => {
|
|
1010
|
-
const text = chunk.toString();
|
|
1011
|
-
const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
|
|
1012
|
-
text
|
|
1013
|
-
.split(/\r?\n/)
|
|
1014
|
-
.filter(Boolean)
|
|
1015
|
-
.forEach((line) => process.stderr.write(`[conductor ${ts}] ${line}\n`));
|
|
1016
|
-
});
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
await client.connect(transport);
|
|
1020
|
-
return new ConductorClient(client, transport, { projectPath });
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
async close() {
|
|
1024
|
-
if (this.closed) {
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
this.closed = true;
|
|
1028
|
-
await this.client.close();
|
|
1029
|
-
await this.transport.close();
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
injectProjectPath(payload = {}) {
|
|
1033
|
-
if (!this.projectPath || payload.project_path) {
|
|
1034
|
-
return payload;
|
|
1035
|
-
}
|
|
1036
|
-
return { ...payload, project_path: this.projectPath };
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
async createTaskSession(payload) {
|
|
1040
|
-
const args = payload ? { ...payload } : {};
|
|
1041
|
-
return this.callTool("create_task_session", this.injectProjectPath(args));
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
async sendMessage(taskId, content, metadata) {
|
|
1045
|
-
return this.callTool("send_message", {
|
|
1046
|
-
task_id: taskId,
|
|
1047
|
-
content,
|
|
1048
|
-
metadata,
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
async sendTaskStatus(taskId, payload) {
|
|
1053
|
-
return this.callTool("send_task_status", {
|
|
1054
|
-
task_id: taskId,
|
|
1055
|
-
...(payload || {}),
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
async sendRuntimeStatus(taskId, payload) {
|
|
1060
|
-
return this.callTool("send_runtime_status", {
|
|
1061
|
-
task_id: taskId,
|
|
1062
|
-
...(payload || {}),
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
async receiveMessages(taskId, limit = 20) {
|
|
1067
|
-
return this.callTool("receive_messages", {
|
|
1068
|
-
task_id: taskId,
|
|
1069
|
-
limit,
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
async ackMessages(taskId, ackToken) {
|
|
1074
|
-
if (!ackToken) {
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
await this.callTool("ack_messages", { task_id: taskId, ack_token: ackToken });
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
async listProjects() {
|
|
1081
|
-
return this.callTool("list_projects", {});
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
async createProject(name, description, metadata) {
|
|
1085
|
-
return this.callTool("create_project", {
|
|
1086
|
-
name,
|
|
1087
|
-
description,
|
|
1088
|
-
metadata,
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
async getLocalProjectRecord() {
|
|
1093
|
-
return this.callTool("get_local_project_id", this.injectProjectPath({}));
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
async matchProjectByPath() {
|
|
1097
|
-
return this.callTool("match_project_by_path", this.injectProjectPath({}));
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
async bindProjectPath(projectId) {
|
|
1101
|
-
return this.callTool("bind_project_path", this.injectProjectPath({ project_id: projectId }));
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
async callTool(name, args) {
|
|
1105
|
-
const result = await this.client.callTool({
|
|
1106
|
-
name,
|
|
1107
|
-
arguments: args || {},
|
|
1108
|
-
});
|
|
1109
|
-
if (result?.isError) {
|
|
1110
|
-
const message = extractFirstText(result?.content) || `tool ${name} failed`;
|
|
1111
|
-
throw new Error(message);
|
|
1112
|
-
}
|
|
1113
|
-
const structured = result?.structuredContent;
|
|
1114
|
-
if (structured && Object.keys(structured).length > 0) {
|
|
1115
|
-
return structured;
|
|
1116
|
-
}
|
|
1117
|
-
const textPayload = extractFirstText(result?.content);
|
|
1118
|
-
if (!textPayload) {
|
|
1119
|
-
return undefined;
|
|
1120
|
-
}
|
|
1121
|
-
try {
|
|
1122
|
-
return JSON.parse(textPayload);
|
|
1123
|
-
} catch {
|
|
1124
|
-
return textPayload;
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function extractFirstText(blocks) {
|
|
1130
|
-
if (!Array.isArray(blocks)) {
|
|
1131
|
-
return "";
|
|
1132
|
-
}
|
|
1133
|
-
for (const block of blocks) {
|
|
1134
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
1135
|
-
const trimmed = block.text.trim();
|
|
1136
|
-
if (trimmed) {
|
|
1137
|
-
return trimmed;
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
return "";
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
1041
|
class BridgeRunner {
|
|
1145
1042
|
constructor({
|
|
1146
1043
|
backendSession,
|
|
@@ -1164,6 +1061,8 @@ class BridgeRunner {
|
|
|
1164
1061
|
this.runningTurn = false;
|
|
1165
1062
|
this.processedMessageIds = new Set();
|
|
1166
1063
|
this.lastRuntimeStatusSignature = null;
|
|
1064
|
+
this.lastRuntimeStatusPayload = null;
|
|
1065
|
+
this.needsReconnectRecovery = false;
|
|
1167
1066
|
}
|
|
1168
1067
|
|
|
1169
1068
|
async start(abortSignal) {
|
|
@@ -1179,6 +1078,9 @@ class BridgeRunner {
|
|
|
1179
1078
|
await this.backfillPendingUserMessages();
|
|
1180
1079
|
|
|
1181
1080
|
while (!this.stopped) {
|
|
1081
|
+
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
1082
|
+
await this.recoverAfterReconnect();
|
|
1083
|
+
}
|
|
1182
1084
|
let processed = false;
|
|
1183
1085
|
try {
|
|
1184
1086
|
processed = await this.processIncomingBatch();
|
|
@@ -1192,6 +1094,26 @@ class BridgeRunner {
|
|
|
1192
1094
|
}
|
|
1193
1095
|
}
|
|
1194
1096
|
|
|
1097
|
+
noteReconnect() {
|
|
1098
|
+
this.needsReconnectRecovery = true;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async recoverAfterReconnect() {
|
|
1102
|
+
if (!this.needsReconnectRecovery) {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
this.needsReconnectRecovery = false;
|
|
1106
|
+
log(`Recovering task ${this.taskId} after reconnect`);
|
|
1107
|
+
// With durable web outbox enabled, user messages are replayed by the server.
|
|
1108
|
+
// Re-running DB-history backfill here can re-drive stale prompts and confuse
|
|
1109
|
+
// the local TUI session after reconnect. Keep startup backfill, but disable
|
|
1110
|
+
// reconnect backfill by default (opt-in for debugging/legacy fallback).
|
|
1111
|
+
if (process.env.CONDUCTOR_FIRE_RECONNECT_BACKFILL === "1") {
|
|
1112
|
+
await this.backfillPendingUserMessages();
|
|
1113
|
+
}
|
|
1114
|
+
await this.replayLastRuntimeStatus();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1195
1117
|
async processIncomingBatch() {
|
|
1196
1118
|
const result = await this.conductor.receiveMessages(this.taskId, 20);
|
|
1197
1119
|
const messages = Array.isArray(result?.messages) ? result.messages : [];
|
|
@@ -1313,6 +1235,9 @@ class BridgeRunner {
|
|
|
1313
1235
|
return;
|
|
1314
1236
|
}
|
|
1315
1237
|
this.lastRuntimeStatusSignature = signature;
|
|
1238
|
+
this.lastRuntimeStatusPayload = {
|
|
1239
|
+
...runtime,
|
|
1240
|
+
};
|
|
1316
1241
|
|
|
1317
1242
|
try {
|
|
1318
1243
|
await this.conductor.sendRuntimeStatus(this.taskId, {
|
|
@@ -1324,6 +1249,20 @@ class BridgeRunner {
|
|
|
1324
1249
|
}
|
|
1325
1250
|
}
|
|
1326
1251
|
|
|
1252
|
+
async replayLastRuntimeStatus() {
|
|
1253
|
+
if (!this.lastRuntimeStatusPayload) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
await this.conductor.sendRuntimeStatus(this.taskId, {
|
|
1258
|
+
...this.lastRuntimeStatusPayload,
|
|
1259
|
+
created_at: new Date().toISOString(),
|
|
1260
|
+
});
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
log(`Failed to replay runtime status after reconnect: ${error?.message || error}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1327
1266
|
async respondToMessage(message) {
|
|
1328
1267
|
const content = String(message.content || "").trim();
|
|
1329
1268
|
if (!content) {
|
|
@@ -1334,6 +1273,7 @@ class BridgeRunner {
|
|
|
1334
1273
|
return;
|
|
1335
1274
|
}
|
|
1336
1275
|
this.lastRuntimeStatusSignature = null;
|
|
1276
|
+
this.runningTurn = true;
|
|
1337
1277
|
log(`Processing message ${replyTo} (${message.role})`);
|
|
1338
1278
|
try {
|
|
1339
1279
|
await this.reportRuntimeStatus(
|
|
@@ -1392,11 +1332,14 @@ class BridgeRunner {
|
|
|
1392
1332
|
replyTo,
|
|
1393
1333
|
);
|
|
1394
1334
|
await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
|
|
1335
|
+
} finally {
|
|
1336
|
+
this.runningTurn = false;
|
|
1395
1337
|
}
|
|
1396
1338
|
}
|
|
1397
1339
|
|
|
1398
1340
|
async handleSyntheticMessage(content, { includeImages }) {
|
|
1399
1341
|
this.lastRuntimeStatusSignature = null;
|
|
1342
|
+
this.runningTurn = true;
|
|
1400
1343
|
try {
|
|
1401
1344
|
const result = await this.backendSession.runTurn(content, {
|
|
1402
1345
|
useInitialImages: includeImages,
|
|
@@ -1423,6 +1366,8 @@ class BridgeRunner {
|
|
|
1423
1366
|
});
|
|
1424
1367
|
} catch (error) {
|
|
1425
1368
|
await this.reportError(`初始提示执行失败: ${error.message}`);
|
|
1369
|
+
} finally {
|
|
1370
|
+
this.runningTurn = false;
|
|
1426
1371
|
}
|
|
1427
1372
|
}
|
|
1428
1373
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"conductor": "bin/conductor.js"
|
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
"test": "node --test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@love-moon/tui-driver": "0.2.
|
|
20
|
-
"@love-moon/conductor-sdk": "0.2.
|
|
21
|
-
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
19
|
+
"@love-moon/tui-driver": "0.2.8",
|
|
20
|
+
"@love-moon/conductor-sdk": "0.2.8",
|
|
22
21
|
"dotenv": "^16.4.5",
|
|
23
22
|
"enquirer": "^2.4.1",
|
|
24
23
|
"js-yaml": "^4.1.1",
|
package/src/daemon.js
CHANGED
|
@@ -272,6 +272,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
272
272
|
let didRecoverStaleTasks = false;
|
|
273
273
|
const activeTaskProcesses = new Map();
|
|
274
274
|
const suppressedExitStatusReports = new Set();
|
|
275
|
+
const seenCommandRequestIds = new Set();
|
|
275
276
|
const client = createWebSocketClient(sdkConfig, {
|
|
276
277
|
extraHeaders: {
|
|
277
278
|
"x-conductor-host": AGENT_NAME,
|
|
@@ -282,11 +283,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
282
283
|
log("Connected to backend");
|
|
283
284
|
}
|
|
284
285
|
disconnectedSinceLastConnectedLog = false;
|
|
286
|
+
sendAgentResume(isReconnect).catch((error) => {
|
|
287
|
+
logError(`sendAgentResume failed: ${error?.message || error}`);
|
|
288
|
+
});
|
|
285
289
|
if (!didRecoverStaleTasks) {
|
|
286
290
|
didRecoverStaleTasks = true;
|
|
287
291
|
recoverStaleTasks().catch((error) => {
|
|
288
292
|
logError(`recoverStaleTasks failed: ${error?.message || error}`);
|
|
289
293
|
});
|
|
294
|
+
} else if (isReconnect) {
|
|
295
|
+
reconcileAssignedTasks().catch((error) => {
|
|
296
|
+
logError(`reconcileAssignedTasks failed: ${error?.message || error}`);
|
|
297
|
+
});
|
|
290
298
|
}
|
|
291
299
|
},
|
|
292
300
|
onDisconnected: () => {
|
|
@@ -356,6 +364,102 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
366
|
|
|
367
|
+
async function reconcileAssignedTasks() {
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
370
|
+
method: "GET",
|
|
371
|
+
headers: {
|
|
372
|
+
Authorization: `Bearer ${AGENT_TOKEN}`,
|
|
373
|
+
Accept: "application/json",
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
if (!response.ok) {
|
|
377
|
+
logError(`Failed to reconcile tasks: HTTP ${response.status}`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const tasks = await response.json();
|
|
381
|
+
if (!Array.isArray(tasks)) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const localTaskIds = new Set(activeTaskProcesses.keys());
|
|
385
|
+
const assigned = tasks.filter((task) => {
|
|
386
|
+
const agentHost = String(task?.agent_host || "").trim();
|
|
387
|
+
const status = String(task?.status || "").trim().toLowerCase();
|
|
388
|
+
return agentHost === AGENT_NAME && (status === "unknown" || status === "running");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
let killedCount = 0;
|
|
392
|
+
for (const task of assigned) {
|
|
393
|
+
const taskId = String(task?.id || "");
|
|
394
|
+
if (!taskId) continue;
|
|
395
|
+
if (localTaskIds.has(taskId)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
|
|
399
|
+
method: "PATCH",
|
|
400
|
+
headers: {
|
|
401
|
+
Authorization: `Bearer ${AGENT_TOKEN}`,
|
|
402
|
+
Accept: "application/json",
|
|
403
|
+
"Content-Type": "application/json",
|
|
404
|
+
},
|
|
405
|
+
body: JSON.stringify({ status: "killed" }),
|
|
406
|
+
});
|
|
407
|
+
if (patchResp.ok) {
|
|
408
|
+
killedCount += 1;
|
|
409
|
+
} else {
|
|
410
|
+
logError(`Failed to reconcile stale task ${taskId}: HTTP ${patchResp.status}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (assigned.length || localTaskIds.size) {
|
|
415
|
+
log(
|
|
416
|
+
`Reconciled tasks after reconnect: backendAssigned=${assigned.length} localActive=${localTaskIds.size} markedKilled=${killedCount}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
logError(`reconcileAssignedTasks error: ${error?.message || error}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function sendAgentResume(isReconnect = false) {
|
|
425
|
+
await client.sendJson({
|
|
426
|
+
type: "agent_resume",
|
|
427
|
+
payload: {
|
|
428
|
+
active_tasks: [...activeTaskProcesses.keys()],
|
|
429
|
+
source: "conductor-daemon",
|
|
430
|
+
metadata: { is_reconnect: Boolean(isReconnect) },
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function markRequestSeen(requestId) {
|
|
436
|
+
if (!requestId) return true;
|
|
437
|
+
if (seenCommandRequestIds.has(requestId)) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
seenCommandRequestIds.add(requestId);
|
|
441
|
+
if (seenCommandRequestIds.size > 2000) {
|
|
442
|
+
const first = seenCommandRequestIds.values().next();
|
|
443
|
+
if (!first.done) {
|
|
444
|
+
seenCommandRequestIds.delete(first.value);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sendAgentCommandAck({ requestId, taskId, eventType, accepted = true }) {
|
|
451
|
+
if (!requestId) return Promise.resolve();
|
|
452
|
+
return client.sendJson({
|
|
453
|
+
type: "agent_command_ack",
|
|
454
|
+
payload: {
|
|
455
|
+
request_id: String(requestId),
|
|
456
|
+
task_id: taskId ? String(taskId) : undefined,
|
|
457
|
+
event_type: eventType,
|
|
458
|
+
accepted: Boolean(accepted),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
359
463
|
function handleEvent(event) {
|
|
360
464
|
if (event.type === "create_task") {
|
|
361
465
|
handleCreateTask(event.payload);
|
|
@@ -369,8 +473,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
369
473
|
function handleStopTask(payload) {
|
|
370
474
|
const taskId = payload?.task_id;
|
|
371
475
|
if (!taskId) return;
|
|
372
|
-
|
|
373
476
|
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
477
|
+
if (requestId && !markRequestSeen(requestId)) {
|
|
478
|
+
log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
|
|
479
|
+
sendAgentCommandAck({
|
|
480
|
+
requestId,
|
|
481
|
+
taskId,
|
|
482
|
+
eventType: "stop_task",
|
|
483
|
+
accepted: true,
|
|
484
|
+
}).catch(() => {});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
374
488
|
const sendStopAck = (accepted) => {
|
|
375
489
|
if (!requestId) return;
|
|
376
490
|
client
|
|
@@ -385,6 +499,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
385
499
|
.catch((err) => {
|
|
386
500
|
logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
|
|
387
501
|
});
|
|
502
|
+
sendAgentCommandAck({
|
|
503
|
+
requestId,
|
|
504
|
+
taskId,
|
|
505
|
+
eventType: "stop_task",
|
|
506
|
+
accepted,
|
|
507
|
+
}).catch((err) => {
|
|
508
|
+
logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
|
|
509
|
+
});
|
|
388
510
|
};
|
|
389
511
|
|
|
390
512
|
const record = activeTaskProcesses.get(taskId);
|
|
@@ -485,11 +607,35 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
485
607
|
}
|
|
486
608
|
|
|
487
609
|
async function handleCreateTask(payload) {
|
|
488
|
-
const {
|
|
610
|
+
const {
|
|
611
|
+
task_id: taskId,
|
|
612
|
+
project_id: projectId,
|
|
613
|
+
backend_type: backendType,
|
|
614
|
+
initial_content: initialContent,
|
|
615
|
+
request_id: requestIdRaw,
|
|
616
|
+
} =
|
|
489
617
|
payload || {};
|
|
618
|
+
const requestId = requestIdRaw ? String(requestIdRaw) : "";
|
|
490
619
|
|
|
491
620
|
if (!taskId || !projectId) {
|
|
492
621
|
logError(`Invalid create_task payload: ${JSON.stringify(payload)}`);
|
|
622
|
+
sendAgentCommandAck({
|
|
623
|
+
requestId,
|
|
624
|
+
taskId,
|
|
625
|
+
eventType: "create_task",
|
|
626
|
+
accepted: false,
|
|
627
|
+
}).catch(() => {});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (requestId && !markRequestSeen(requestId)) {
|
|
632
|
+
log(`Duplicate create_task ignored for ${taskId} (request_id=${requestId})`);
|
|
633
|
+
sendAgentCommandAck({
|
|
634
|
+
requestId,
|
|
635
|
+
taskId,
|
|
636
|
+
eventType: "create_task",
|
|
637
|
+
accepted: true,
|
|
638
|
+
}).catch(() => {});
|
|
493
639
|
return;
|
|
494
640
|
}
|
|
495
641
|
|
|
@@ -497,6 +643,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
497
643
|
const effectiveBackend = backendType || SUPPORTED_BACKENDS[0];
|
|
498
644
|
if (!SUPPORTED_BACKENDS.includes(effectiveBackend)) {
|
|
499
645
|
logError(`Unsupported backend: ${effectiveBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
|
|
646
|
+
sendAgentCommandAck({
|
|
647
|
+
requestId,
|
|
648
|
+
taskId,
|
|
649
|
+
eventType: "create_task",
|
|
650
|
+
accepted: false,
|
|
651
|
+
}).catch(() => {});
|
|
500
652
|
client
|
|
501
653
|
.sendJson({
|
|
502
654
|
type: "task_status_update",
|
|
@@ -511,6 +663,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
511
663
|
return;
|
|
512
664
|
}
|
|
513
665
|
|
|
666
|
+
sendAgentCommandAck({
|
|
667
|
+
requestId,
|
|
668
|
+
taskId,
|
|
669
|
+
eventType: "create_task",
|
|
670
|
+
accepted: true,
|
|
671
|
+
}).catch((err) => {
|
|
672
|
+
logError(`Failed to report agent_command_ack(create_task) for ${taskId}: ${err?.message || err}`);
|
|
673
|
+
});
|
|
674
|
+
|
|
514
675
|
const cliCommand = ALLOW_CLI_LIST[effectiveBackend];
|
|
515
676
|
|
|
516
677
|
log("");
|