@love-moon/conductor-cli 0.2.7 → 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 +101 -2
- package/package.json +3 -3
- package/src/daemon.js +163 -2
package/bin/conductor-fire.js
CHANGED
|
@@ -182,6 +182,45 @@ async function main() {
|
|
|
182
182
|
log(`Using backend: ${cliArgs.backend}`);
|
|
183
183
|
|
|
184
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
|
+
|
|
185
224
|
if (cliArgs.configFile) {
|
|
186
225
|
env.CONDUCTOR_CONFIG = cliArgs.configFile;
|
|
187
226
|
}
|
|
@@ -200,10 +239,11 @@ async function main() {
|
|
|
200
239
|
// Ignore config loading errors, rely on env vars or defaults
|
|
201
240
|
}
|
|
202
241
|
|
|
203
|
-
|
|
242
|
+
conductor = await ConductorClient.connect({
|
|
204
243
|
projectPath: CLI_PROJECT_PATH,
|
|
205
244
|
extraEnv: env,
|
|
206
245
|
configFile: cliArgs.configFile,
|
|
246
|
+
onConnected: scheduleReconnectRecovery,
|
|
207
247
|
});
|
|
208
248
|
|
|
209
249
|
const taskContext = await ensureTaskContext(conductor, {
|
|
@@ -219,6 +259,17 @@ async function main() {
|
|
|
219
259
|
taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
|
|
220
260
|
}`,
|
|
221
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
|
+
}
|
|
222
273
|
|
|
223
274
|
const runner = new BridgeRunner({
|
|
224
275
|
backendSession,
|
|
@@ -230,10 +281,10 @@ async function main() {
|
|
|
230
281
|
cliArgs: cliArgs.rawBackendArgs,
|
|
231
282
|
backendName: cliArgs.backend,
|
|
232
283
|
});
|
|
284
|
+
reconnectRunner = runner;
|
|
233
285
|
|
|
234
286
|
const signals = new AbortController();
|
|
235
287
|
let shutdownSignal = null;
|
|
236
|
-
const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
|
|
237
288
|
const onSigint = () => {
|
|
238
289
|
shutdownSignal = shutdownSignal || "SIGINT";
|
|
239
290
|
signals.abort();
|
|
@@ -1010,6 +1061,8 @@ class BridgeRunner {
|
|
|
1010
1061
|
this.runningTurn = false;
|
|
1011
1062
|
this.processedMessageIds = new Set();
|
|
1012
1063
|
this.lastRuntimeStatusSignature = null;
|
|
1064
|
+
this.lastRuntimeStatusPayload = null;
|
|
1065
|
+
this.needsReconnectRecovery = false;
|
|
1013
1066
|
}
|
|
1014
1067
|
|
|
1015
1068
|
async start(abortSignal) {
|
|
@@ -1025,6 +1078,9 @@ class BridgeRunner {
|
|
|
1025
1078
|
await this.backfillPendingUserMessages();
|
|
1026
1079
|
|
|
1027
1080
|
while (!this.stopped) {
|
|
1081
|
+
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
1082
|
+
await this.recoverAfterReconnect();
|
|
1083
|
+
}
|
|
1028
1084
|
let processed = false;
|
|
1029
1085
|
try {
|
|
1030
1086
|
processed = await this.processIncomingBatch();
|
|
@@ -1038,6 +1094,26 @@ class BridgeRunner {
|
|
|
1038
1094
|
}
|
|
1039
1095
|
}
|
|
1040
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
|
+
|
|
1041
1117
|
async processIncomingBatch() {
|
|
1042
1118
|
const result = await this.conductor.receiveMessages(this.taskId, 20);
|
|
1043
1119
|
const messages = Array.isArray(result?.messages) ? result.messages : [];
|
|
@@ -1159,6 +1235,9 @@ class BridgeRunner {
|
|
|
1159
1235
|
return;
|
|
1160
1236
|
}
|
|
1161
1237
|
this.lastRuntimeStatusSignature = signature;
|
|
1238
|
+
this.lastRuntimeStatusPayload = {
|
|
1239
|
+
...runtime,
|
|
1240
|
+
};
|
|
1162
1241
|
|
|
1163
1242
|
try {
|
|
1164
1243
|
await this.conductor.sendRuntimeStatus(this.taskId, {
|
|
@@ -1170,6 +1249,20 @@ class BridgeRunner {
|
|
|
1170
1249
|
}
|
|
1171
1250
|
}
|
|
1172
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
|
+
|
|
1173
1266
|
async respondToMessage(message) {
|
|
1174
1267
|
const content = String(message.content || "").trim();
|
|
1175
1268
|
if (!content) {
|
|
@@ -1180,6 +1273,7 @@ class BridgeRunner {
|
|
|
1180
1273
|
return;
|
|
1181
1274
|
}
|
|
1182
1275
|
this.lastRuntimeStatusSignature = null;
|
|
1276
|
+
this.runningTurn = true;
|
|
1183
1277
|
log(`Processing message ${replyTo} (${message.role})`);
|
|
1184
1278
|
try {
|
|
1185
1279
|
await this.reportRuntimeStatus(
|
|
@@ -1238,11 +1332,14 @@ class BridgeRunner {
|
|
|
1238
1332
|
replyTo,
|
|
1239
1333
|
);
|
|
1240
1334
|
await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
|
|
1335
|
+
} finally {
|
|
1336
|
+
this.runningTurn = false;
|
|
1241
1337
|
}
|
|
1242
1338
|
}
|
|
1243
1339
|
|
|
1244
1340
|
async handleSyntheticMessage(content, { includeImages }) {
|
|
1245
1341
|
this.lastRuntimeStatusSignature = null;
|
|
1342
|
+
this.runningTurn = true;
|
|
1246
1343
|
try {
|
|
1247
1344
|
const result = await this.backendSession.runTurn(content, {
|
|
1248
1345
|
useInitialImages: includeImages,
|
|
@@ -1269,6 +1366,8 @@ class BridgeRunner {
|
|
|
1269
1366
|
});
|
|
1270
1367
|
} catch (error) {
|
|
1271
1368
|
await this.reportError(`初始提示执行失败: ${error.message}`);
|
|
1369
|
+
} finally {
|
|
1370
|
+
this.runningTurn = false;
|
|
1272
1371
|
}
|
|
1273
1372
|
}
|
|
1274
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,8 +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.
|
|
19
|
+
"@love-moon/tui-driver": "0.2.8",
|
|
20
|
+
"@love-moon/conductor-sdk": "0.2.8",
|
|
21
21
|
"dotenv": "^16.4.5",
|
|
22
22
|
"enquirer": "^2.4.1",
|
|
23
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("");
|