@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.
@@ -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
- const conductor = await ConductorClient.connect({
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.7",
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.7",
20
- "@love-moon/conductor-sdk": "0.2.7",
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 { task_id: taskId, project_id: projectId, backend_type: backendType, initial_content: initialContent } =
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("");