@love-moon/conductor-cli 0.2.7 → 0.2.9

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.9",
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.9",
20
+ "@love-moon/conductor-sdk": "0.2.9",
21
21
  "dotenv": "^16.4.5",
22
22
  "enquirer": "^2.4.1",
23
23
  "js-yaml": "^4.1.1",
package/src/daemon.js CHANGED
@@ -61,6 +61,7 @@ export function startDaemon(config = {}, deps = {}) {
61
61
  const killFn = deps.kill || process.kill;
62
62
  let requestShutdown = async () => {};
63
63
  let shutdownSignalHandled = false;
64
+ let forcedSignalExitHandled = false;
64
65
 
65
66
  const exitAndReturn = (code) => {
66
67
  exitFn(code);
@@ -145,6 +146,14 @@ export function startDaemon(config = {}, deps = {}) {
145
146
  process.env.CONDUCTOR_STOP_FORCE_KILL_TIMEOUT_MS,
146
147
  5000,
147
148
  );
149
+ const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
150
+ process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
151
+ 1000,
152
+ );
153
+ const SHUTDOWN_DISCONNECT_TIMEOUT_MS = parsePositiveInt(
154
+ process.env.CONDUCTOR_SHUTDOWN_DISCONNECT_TIMEOUT_MS,
155
+ 1000,
156
+ );
148
157
 
149
158
  try {
150
159
  mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
@@ -216,8 +225,16 @@ export function startDaemon(config = {}, deps = {}) {
216
225
  };
217
226
 
218
227
  process.on("exit", cleanupLock);
228
+ const signalExitCode = (signal) => (signal === "SIGINT" ? 130 : 143);
219
229
  const handleSignal = (signal) => {
220
- if (shutdownSignalHandled) return;
230
+ if (shutdownSignalHandled) {
231
+ if (forcedSignalExitHandled) return;
232
+ forcedSignalExitHandled = true;
233
+ log(`Received ${signal} again, forcing exit now`);
234
+ cleanupLock();
235
+ exitFn(signalExitCode(signal));
236
+ return;
237
+ }
221
238
  shutdownSignalHandled = true;
222
239
  void (async () => {
223
240
  try {
@@ -227,7 +244,7 @@ export function startDaemon(config = {}, deps = {}) {
227
244
  logError(`Graceful shutdown failed on ${signal}: ${err?.message || err}`);
228
245
  } finally {
229
246
  cleanupLock();
230
- exitFn(0);
247
+ exitFn(signalExitCode(signal));
231
248
  }
232
249
  })();
233
250
  };
@@ -272,6 +289,7 @@ export function startDaemon(config = {}, deps = {}) {
272
289
  let didRecoverStaleTasks = false;
273
290
  const activeTaskProcesses = new Map();
274
291
  const suppressedExitStatusReports = new Set();
292
+ const seenCommandRequestIds = new Set();
275
293
  const client = createWebSocketClient(sdkConfig, {
276
294
  extraHeaders: {
277
295
  "x-conductor-host": AGENT_NAME,
@@ -282,11 +300,18 @@ export function startDaemon(config = {}, deps = {}) {
282
300
  log("Connected to backend");
283
301
  }
284
302
  disconnectedSinceLastConnectedLog = false;
303
+ sendAgentResume(isReconnect).catch((error) => {
304
+ logError(`sendAgentResume failed: ${error?.message || error}`);
305
+ });
285
306
  if (!didRecoverStaleTasks) {
286
307
  didRecoverStaleTasks = true;
287
308
  recoverStaleTasks().catch((error) => {
288
309
  logError(`recoverStaleTasks failed: ${error?.message || error}`);
289
310
  });
311
+ } else if (isReconnect) {
312
+ reconcileAssignedTasks().catch((error) => {
313
+ logError(`reconcileAssignedTasks failed: ${error?.message || error}`);
314
+ });
290
315
  }
291
316
  },
292
317
  onDisconnected: () => {
@@ -356,6 +381,102 @@ export function startDaemon(config = {}, deps = {}) {
356
381
  }
357
382
  }
358
383
 
384
+ async function reconcileAssignedTasks() {
385
+ try {
386
+ const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
387
+ method: "GET",
388
+ headers: {
389
+ Authorization: `Bearer ${AGENT_TOKEN}`,
390
+ Accept: "application/json",
391
+ },
392
+ });
393
+ if (!response.ok) {
394
+ logError(`Failed to reconcile tasks: HTTP ${response.status}`);
395
+ return;
396
+ }
397
+ const tasks = await response.json();
398
+ if (!Array.isArray(tasks)) {
399
+ return;
400
+ }
401
+ const localTaskIds = new Set(activeTaskProcesses.keys());
402
+ const assigned = tasks.filter((task) => {
403
+ const agentHost = String(task?.agent_host || "").trim();
404
+ const status = String(task?.status || "").trim().toLowerCase();
405
+ return agentHost === AGENT_NAME && (status === "unknown" || status === "running");
406
+ });
407
+
408
+ let killedCount = 0;
409
+ for (const task of assigned) {
410
+ const taskId = String(task?.id || "");
411
+ if (!taskId) continue;
412
+ if (localTaskIds.has(taskId)) {
413
+ continue;
414
+ }
415
+ const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
416
+ method: "PATCH",
417
+ headers: {
418
+ Authorization: `Bearer ${AGENT_TOKEN}`,
419
+ Accept: "application/json",
420
+ "Content-Type": "application/json",
421
+ },
422
+ body: JSON.stringify({ status: "killed" }),
423
+ });
424
+ if (patchResp.ok) {
425
+ killedCount += 1;
426
+ } else {
427
+ logError(`Failed to reconcile stale task ${taskId}: HTTP ${patchResp.status}`);
428
+ }
429
+ }
430
+
431
+ if (assigned.length || localTaskIds.size) {
432
+ log(
433
+ `Reconciled tasks after reconnect: backendAssigned=${assigned.length} localActive=${localTaskIds.size} markedKilled=${killedCount}`,
434
+ );
435
+ }
436
+ } catch (error) {
437
+ logError(`reconcileAssignedTasks error: ${error?.message || error}`);
438
+ }
439
+ }
440
+
441
+ async function sendAgentResume(isReconnect = false) {
442
+ await client.sendJson({
443
+ type: "agent_resume",
444
+ payload: {
445
+ active_tasks: [...activeTaskProcesses.keys()],
446
+ source: "conductor-daemon",
447
+ metadata: { is_reconnect: Boolean(isReconnect) },
448
+ },
449
+ });
450
+ }
451
+
452
+ function markRequestSeen(requestId) {
453
+ if (!requestId) return true;
454
+ if (seenCommandRequestIds.has(requestId)) {
455
+ return false;
456
+ }
457
+ seenCommandRequestIds.add(requestId);
458
+ if (seenCommandRequestIds.size > 2000) {
459
+ const first = seenCommandRequestIds.values().next();
460
+ if (!first.done) {
461
+ seenCommandRequestIds.delete(first.value);
462
+ }
463
+ }
464
+ return true;
465
+ }
466
+
467
+ function sendAgentCommandAck({ requestId, taskId, eventType, accepted = true }) {
468
+ if (!requestId) return Promise.resolve();
469
+ return client.sendJson({
470
+ type: "agent_command_ack",
471
+ payload: {
472
+ request_id: String(requestId),
473
+ task_id: taskId ? String(taskId) : undefined,
474
+ event_type: eventType,
475
+ accepted: Boolean(accepted),
476
+ },
477
+ });
478
+ }
479
+
359
480
  function handleEvent(event) {
360
481
  if (event.type === "create_task") {
361
482
  handleCreateTask(event.payload);
@@ -369,8 +490,18 @@ export function startDaemon(config = {}, deps = {}) {
369
490
  function handleStopTask(payload) {
370
491
  const taskId = payload?.task_id;
371
492
  if (!taskId) return;
372
-
373
493
  const requestId = payload?.request_id ? String(payload.request_id) : "";
494
+ if (requestId && !markRequestSeen(requestId)) {
495
+ log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
496
+ sendAgentCommandAck({
497
+ requestId,
498
+ taskId,
499
+ eventType: "stop_task",
500
+ accepted: true,
501
+ }).catch(() => {});
502
+ return;
503
+ }
504
+
374
505
  const sendStopAck = (accepted) => {
375
506
  if (!requestId) return;
376
507
  client
@@ -385,6 +516,14 @@ export function startDaemon(config = {}, deps = {}) {
385
516
  .catch((err) => {
386
517
  logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
387
518
  });
519
+ sendAgentCommandAck({
520
+ requestId,
521
+ taskId,
522
+ eventType: "stop_task",
523
+ accepted,
524
+ }).catch((err) => {
525
+ logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
526
+ });
388
527
  };
389
528
 
390
529
  const record = activeTaskProcesses.get(taskId);
@@ -485,11 +624,35 @@ export function startDaemon(config = {}, deps = {}) {
485
624
  }
486
625
 
487
626
  async function handleCreateTask(payload) {
488
- const { task_id: taskId, project_id: projectId, backend_type: backendType, initial_content: initialContent } =
627
+ const {
628
+ task_id: taskId,
629
+ project_id: projectId,
630
+ backend_type: backendType,
631
+ initial_content: initialContent,
632
+ request_id: requestIdRaw,
633
+ } =
489
634
  payload || {};
635
+ const requestId = requestIdRaw ? String(requestIdRaw) : "";
490
636
 
491
637
  if (!taskId || !projectId) {
492
638
  logError(`Invalid create_task payload: ${JSON.stringify(payload)}`);
639
+ sendAgentCommandAck({
640
+ requestId,
641
+ taskId,
642
+ eventType: "create_task",
643
+ accepted: false,
644
+ }).catch(() => {});
645
+ return;
646
+ }
647
+
648
+ if (requestId && !markRequestSeen(requestId)) {
649
+ log(`Duplicate create_task ignored for ${taskId} (request_id=${requestId})`);
650
+ sendAgentCommandAck({
651
+ requestId,
652
+ taskId,
653
+ eventType: "create_task",
654
+ accepted: true,
655
+ }).catch(() => {});
493
656
  return;
494
657
  }
495
658
 
@@ -497,6 +660,12 @@ export function startDaemon(config = {}, deps = {}) {
497
660
  const effectiveBackend = backendType || SUPPORTED_BACKENDS[0];
498
661
  if (!SUPPORTED_BACKENDS.includes(effectiveBackend)) {
499
662
  logError(`Unsupported backend: ${effectiveBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
663
+ sendAgentCommandAck({
664
+ requestId,
665
+ taskId,
666
+ eventType: "create_task",
667
+ accepted: false,
668
+ }).catch(() => {});
500
669
  client
501
670
  .sendJson({
502
671
  type: "task_status_update",
@@ -511,6 +680,15 @@ export function startDaemon(config = {}, deps = {}) {
511
680
  return;
512
681
  }
513
682
 
683
+ sendAgentCommandAck({
684
+ requestId,
685
+ taskId,
686
+ eventType: "create_task",
687
+ accepted: true,
688
+ }).catch((err) => {
689
+ logError(`Failed to report agent_command_ack(create_task) for ${taskId}: ${err?.message || err}`);
690
+ });
691
+
514
692
  const cliCommand = ALLOW_CLI_LIST[effectiveBackend];
515
693
 
516
694
  log("");
@@ -731,15 +909,19 @@ export function startDaemon(config = {}, deps = {}) {
731
909
  activeEntries.map(async ([taskId, record]) => {
732
910
  suppressedExitStatusReports.add(taskId);
733
911
  try {
734
- await client.sendJson({
735
- type: "task_status_update",
736
- payload: {
737
- task_id: taskId,
738
- project_id: record.projectId,
739
- status: "KILLED",
740
- summary: `daemon shutdown (${reason})`,
741
- },
742
- });
912
+ await withTimeout(
913
+ client.sendJson({
914
+ type: "task_status_update",
915
+ payload: {
916
+ task_id: taskId,
917
+ project_id: record.projectId,
918
+ status: "KILLED",
919
+ summary: `daemon shutdown (${reason})`,
920
+ },
921
+ }),
922
+ SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
923
+ `report shutdown status for ${taskId}`,
924
+ );
743
925
  } catch (err) {
744
926
  logError(`Failed to report shutdown status (KILLED) for ${taskId}: ${err?.message || err}`);
745
927
  }
@@ -762,7 +944,11 @@ export function startDaemon(config = {}, deps = {}) {
762
944
  activeTaskProcesses.clear();
763
945
 
764
946
  try {
765
- await Promise.resolve(client.disconnect());
947
+ await withTimeout(
948
+ Promise.resolve(client.disconnect()),
949
+ SHUTDOWN_DISCONNECT_TIMEOUT_MS,
950
+ "disconnect daemon websocket",
951
+ );
766
952
  } catch (error) {
767
953
  logError(`Failed to disconnect client on daemon close: ${error?.message || error}`);
768
954
  }
@@ -828,6 +1014,25 @@ function parsePositiveInt(value, fallback) {
828
1014
  return fallback;
829
1015
  }
830
1016
 
1017
+ async function withTimeout(promise, timeoutMs, label) {
1018
+ let timer = null;
1019
+ const timeoutPromise = new Promise((_, reject) => {
1020
+ timer = setTimeout(() => {
1021
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
1022
+ }, timeoutMs);
1023
+ if (typeof timer?.unref === "function") {
1024
+ timer.unref();
1025
+ }
1026
+ });
1027
+ try {
1028
+ return await Promise.race([promise, timeoutPromise]);
1029
+ } finally {
1030
+ if (timer) {
1031
+ clearTimeout(timer);
1032
+ }
1033
+ }
1034
+ }
1035
+
831
1036
  function expandHomePath(inputPath, homeDir) {
832
1037
  if (typeof inputPath !== "string" || !inputPath) {
833
1038
  return inputPath;