@love-moon/conductor-cli 0.2.2 → 0.2.4

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.
@@ -255,16 +255,32 @@ async function main() {
255
255
  });
256
256
 
257
257
  const signals = new AbortController();
258
- process.on("SIGINT", () => signals.abort());
259
- process.on("SIGTERM", () => signals.abort());
258
+ let shutdownSignal = null;
259
+ const onSigint = () => {
260
+ shutdownSignal = shutdownSignal || "SIGINT";
261
+ signals.abort();
262
+ };
263
+ const onSigterm = () => {
264
+ shutdownSignal = shutdownSignal || "SIGTERM";
265
+ signals.abort();
266
+ };
267
+ process.on("SIGINT", onSigint);
268
+ process.on("SIGTERM", onSigterm);
260
269
 
261
270
  try {
262
271
  await runner.start(signals.signal);
263
272
  } finally {
273
+ process.off("SIGINT", onSigint);
274
+ process.off("SIGTERM", onSigterm);
264
275
  if (typeof backendSession.close === "function") {
265
276
  await backendSession.close();
266
277
  }
267
278
  await conductor.close();
279
+ if (shutdownSignal === "SIGINT") {
280
+ process.exitCode = 130;
281
+ } else if (shutdownSignal === "SIGTERM") {
282
+ process.exitCode = 143;
283
+ }
268
284
  }
269
285
  }
270
286
 
@@ -664,6 +680,15 @@ class TuiDriverSession {
664
680
  }
665
681
  : undefined,
666
682
  });
683
+
684
+ // 监听登录需求事件
685
+ this.driver.on("login_required", (health) => {
686
+ log(`[${this.backend}] [WARN] Login required detected: ${health.message || health.reason}`);
687
+ if (health.matchedPattern) {
688
+ log(`[${this.backend}] [WARN] Matched pattern: "${health.matchedPattern}"`);
689
+ }
690
+ log(`[${this.backend}] [WARN] Please run "${this.command} login" or authenticate manually.`);
691
+ });
667
692
  }
668
693
 
669
694
  get threadId() {
@@ -680,6 +705,17 @@ class TuiDriverSession {
680
705
  }
681
706
  }
682
707
 
708
+ /**
709
+ * 获取当前健康状态
710
+ * @returns {Object} 健康状态对象 { healthy, reason, message, matchedPattern }
711
+ */
712
+ getHealthStatus() {
713
+ if (!this.driver) {
714
+ return { healthy: false, reason: "not_initialized", message: "Driver not initialized" };
715
+ }
716
+ return this.driver.healthCheck();
717
+ }
718
+
683
719
  buildPrompt(promptText, { useInitialImages = false } = {}) {
684
720
  let effectivePrompt = String(promptText || "").trim();
685
721
  if (!effectivePrompt) {
@@ -868,13 +904,41 @@ class TuiDriverSession {
868
904
  };
869
905
  } catch (error) {
870
906
  const errorMessage = error instanceof Error ? error.message : String(error);
871
- this.emitProgress(onProgress, {
872
- state: "ERROR",
873
- phase: "exception",
874
- source: "tui-driver",
875
- error: errorMessage,
876
- });
877
- log(`[${this.backend}] Error: ${errorMessage}`);
907
+ const errorReason = error?.reason || "unknown";
908
+
909
+ // 特殊处理登录和权限错误
910
+ if (errorReason === "login_required") {
911
+ this.emitProgress(onProgress, {
912
+ state: "ERROR",
913
+ phase: "login_required",
914
+ source: "tui-driver",
915
+ error: errorMessage,
916
+ reason: errorReason,
917
+ matched_pattern: error?.matchedPattern,
918
+ });
919
+ log(`[${this.backend}] Login required: ${errorMessage}`);
920
+ log(`[${this.backend}] Please run "${this.command} login" or authenticate manually.`);
921
+ } else if (errorReason === "permission_required") {
922
+ this.emitProgress(onProgress, {
923
+ state: "ERROR",
924
+ phase: "permission_required",
925
+ source: "tui-driver",
926
+ error: errorMessage,
927
+ reason: errorReason,
928
+ matched_pattern: error?.matchedPattern,
929
+ });
930
+ log(`[${this.backend}] Permission required: ${errorMessage}`);
931
+ } else {
932
+ this.emitProgress(onProgress, {
933
+ state: "ERROR",
934
+ phase: "exception",
935
+ source: "tui-driver",
936
+ error: errorMessage,
937
+ reason: errorReason,
938
+ });
939
+ log(`[${this.backend}] Error: ${errorMessage}`);
940
+ }
941
+
878
942
  const latestSignals = this.driver.getSignals();
879
943
  const summary = this.formatSignalSummary(latestSignals);
880
944
  this.trace(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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.1",
20
- "@love-moon/conductor-sdk": "0.2.2",
19
+ "@love-moon/tui-driver": "0.2.4",
20
+ "@love-moon/conductor-sdk": "0.2.4",
21
21
  "@modelcontextprotocol/sdk": "^1.20.2",
22
22
  "dotenv": "^16.4.5",
23
23
  "enquirer": "^2.4.1",
@@ -25,8 +25,7 @@
25
25
  "ws": "^8.18.0",
26
26
  "yargs": "^17.7.2",
27
27
  "chrome-launcher": "^1.2.1",
28
- "chrome-remote-interface": "^0.33.0",
29
- "@love-moon/cli2sdk": "0.2.2"
28
+ "chrome-remote-interface": "^0.33.0"
30
29
  },
31
30
  "pnpm": {
32
31
  "overrides": {
package/src/daemon.js CHANGED
@@ -139,6 +139,10 @@ export function startDaemon(config = {}, deps = {}) {
139
139
  process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
140
140
  1500,
141
141
  );
142
+ const STOP_FORCE_KILL_TIMEOUT_MS = parsePositiveInt(
143
+ process.env.CONDUCTOR_STOP_FORCE_KILL_TIMEOUT_MS,
144
+ 5000,
145
+ );
142
146
 
143
147
  try {
144
148
  mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
@@ -250,6 +254,8 @@ export function startDaemon(config = {}, deps = {}) {
250
254
  });
251
255
 
252
256
  let disconnectedSinceLastConnectedLog = false;
257
+ let didRecoverStaleTasks = false;
258
+ const activeTaskProcesses = new Map();
253
259
  const client = createWebSocketClient(sdkConfig, {
254
260
  extraHeaders: {
255
261
  "x-conductor-host": AGENT_NAME,
@@ -260,6 +266,12 @@ export function startDaemon(config = {}, deps = {}) {
260
266
  log("Connected to backend");
261
267
  }
262
268
  disconnectedSinceLastConnectedLog = false;
269
+ if (!didRecoverStaleTasks) {
270
+ didRecoverStaleTasks = true;
271
+ recoverStaleTasks().catch((error) => {
272
+ logError(`recoverStaleTasks failed: ${error?.message || error}`);
273
+ });
274
+ }
263
275
  },
264
276
  onDisconnected: () => {
265
277
  disconnectedSinceLastConnectedLog = true;
@@ -274,9 +286,133 @@ export function startDaemon(config = {}, deps = {}) {
274
286
  logError(`Failed to connect: ${err}`);
275
287
  });
276
288
 
289
+ async function recoverStaleTasks() {
290
+ try {
291
+ const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
292
+ method: "GET",
293
+ headers: {
294
+ Authorization: `Bearer ${AGENT_TOKEN}`,
295
+ Accept: "application/json",
296
+ },
297
+ });
298
+ if (!response.ok) {
299
+ logError(`Failed to recover stale tasks: HTTP ${response.status}`);
300
+ return;
301
+ }
302
+
303
+ const tasks = await response.json();
304
+ if (!Array.isArray(tasks) || tasks.length === 0) {
305
+ return;
306
+ }
307
+
308
+ const staleTasks = tasks.filter((task) => {
309
+ const status = String(task?.status || "").trim().toLowerCase();
310
+ const agentHost = String(task?.agent_host || "").trim();
311
+ return agentHost === AGENT_NAME && (status === "unknown" || status === "running");
312
+ });
313
+
314
+ if (staleTasks.length === 0) {
315
+ return;
316
+ }
317
+
318
+ await Promise.all(
319
+ staleTasks.map(async (task) => {
320
+ const taskId = task?.id;
321
+ if (!taskId) return;
322
+ const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
323
+ method: "PATCH",
324
+ headers: {
325
+ Authorization: `Bearer ${AGENT_TOKEN}`,
326
+ Accept: "application/json",
327
+ "Content-Type": "application/json",
328
+ },
329
+ body: JSON.stringify({ status: "killed" }),
330
+ });
331
+ if (!patchResp.ok) {
332
+ logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
333
+ }
334
+ }),
335
+ );
336
+
337
+ log(`Recovered ${staleTasks.length} stale task(s) to killed`);
338
+ } catch (error) {
339
+ logError(`recoverStaleTasks error: ${error?.message || error}`);
340
+ }
341
+ }
342
+
277
343
  function handleEvent(event) {
278
344
  if (event.type === "create_task") {
279
345
  handleCreateTask(event.payload);
346
+ return;
347
+ }
348
+ if (event.type === "stop_task") {
349
+ handleStopTask(event.payload);
350
+ }
351
+ }
352
+
353
+ function handleStopTask(payload) {
354
+ const taskId = payload?.task_id;
355
+ if (!taskId) return;
356
+
357
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
358
+ const sendStopAck = (accepted) => {
359
+ if (!requestId) return;
360
+ client
361
+ .sendJson({
362
+ type: "task_stop_ack",
363
+ payload: {
364
+ task_id: taskId,
365
+ request_id: requestId,
366
+ accepted: Boolean(accepted),
367
+ },
368
+ })
369
+ .catch((err) => {
370
+ logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
371
+ });
372
+ };
373
+
374
+ const record = activeTaskProcesses.get(taskId);
375
+ if (!record || !record.child) {
376
+ log(`Stop requested for task ${taskId}, but no active process found`);
377
+ sendStopAck(false);
378
+ return;
379
+ }
380
+
381
+ const reason = payload?.reason ? ` (${payload.reason})` : "";
382
+ log(`Stopping task ${taskId}${reason}`);
383
+
384
+ sendStopAck(true);
385
+
386
+ if (record.stopForceKillTimer) {
387
+ clearTimeout(record.stopForceKillTimer);
388
+ record.stopForceKillTimer = null;
389
+ }
390
+
391
+ try {
392
+ if (typeof record.child.kill === "function") {
393
+ record.child.kill("SIGTERM");
394
+ }
395
+ } catch (error) {
396
+ logError(`Failed to stop task ${taskId}: ${error?.message || error}`);
397
+ }
398
+
399
+ record.stopForceKillTimer = setTimeout(() => {
400
+ const latest = activeTaskProcesses.get(taskId);
401
+ if (!latest || latest.child !== record.child) {
402
+ return;
403
+ }
404
+ try {
405
+ if (typeof latest.child.kill === "function") {
406
+ log(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
407
+ latest.child.kill("SIGKILL");
408
+ }
409
+ } catch (error) {
410
+ logError(`Failed to SIGKILL task ${taskId}: ${error?.message || error}`);
411
+ }
412
+ }, STOP_FORCE_KILL_TIMEOUT_MS);
413
+
414
+ if (typeof record.stopForceKillTimer?.unref === "function") {
415
+ record.stopForceKillTimer.unref();
280
416
  }
281
417
  }
282
418
 
@@ -351,7 +487,7 @@ export function startDaemon(config = {}, deps = {}) {
351
487
  payload: {
352
488
  task_id: taskId,
353
489
  project_id: projectId,
354
- status: "FAILED",
490
+ status: "KILLED",
355
491
  summary: `Unsupported backend: ${effectiveBackend}`,
356
492
  },
357
493
  })
@@ -370,11 +506,11 @@ export function startDaemon(config = {}, deps = {}) {
370
506
  payload: {
371
507
  task_id: taskId,
372
508
  project_id: projectId,
373
- status: "CREATED",
509
+ status: "UNKNOWN",
374
510
  },
375
511
  })
376
512
  .catch((err) => {
377
- logError(`Failed to report task status (CREATED) for ${taskId}: ${err?.message || err}`);
513
+ logError(`Failed to report task status (UNKNOWN) for ${taskId}: ${err?.message || err}`);
378
514
  });
379
515
 
380
516
  // Check if project has a bound local path for this daemon
@@ -470,6 +606,14 @@ export function startDaemon(config = {}, deps = {}) {
470
606
 
471
607
  log(`New task workspace: ${taskDir}`);
472
608
  log(`Logs: ${logPath}`);
609
+
610
+ activeTaskProcesses.set(taskId, {
611
+ child,
612
+ projectId,
613
+ logPath,
614
+ stopForceKillTimer: null,
615
+ });
616
+
473
617
  client
474
618
  .sendJson({
475
619
  type: "task_status_update",
@@ -502,15 +646,39 @@ export function startDaemon(config = {}, deps = {}) {
502
646
  }
503
647
  });
504
648
 
505
- child.on("exit", (code) => {
649
+ child.on("exit", (code, signal) => {
650
+ const active = activeTaskProcesses.get(taskId);
651
+ if (active?.stopForceKillTimer) {
652
+ clearTimeout(active.stopForceKillTimer);
653
+ }
654
+ activeTaskProcesses.delete(taskId);
506
655
  if (logStream) {
507
656
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
508
- logStream.write(`[daemon ${ts}] process exited with code ${code}\n`);
657
+ if (signal) {
658
+ logStream.write(`[daemon ${ts}] process killed by signal ${signal}\n`);
659
+ } else {
660
+ logStream.write(`[daemon ${ts}] process exited with code ${code}\n`);
661
+ }
509
662
  logStream.end();
510
663
  }
511
- log(`Task ${taskId} finished with code ${code}`);
664
+ if (signal) {
665
+ log(`Task ${taskId} killed by signal ${signal}`);
666
+ } else {
667
+ log(`Task ${taskId} finished with code ${code}`);
668
+ }
512
669
  log(`Logs: ${logPath}`);
513
- const status = code === 0 ? "COMPLETED" : "FAILED";
670
+
671
+ const isKilledBySignal = Boolean(signal);
672
+ const isKilledByExitCode = code === 130 || code === 143;
673
+ const isKilled = isKilledBySignal || isKilledByExitCode;
674
+
675
+ const status = isKilled ? "KILLED" : code === 0 ? "COMPLETED" : "KILLED";
676
+ const summary = isKilled
677
+ ? (signal ? `killed by signal ${signal}` : `terminated (exit code ${code})`)
678
+ : code === 0
679
+ ? "completed"
680
+ : `exited with code ${code}`;
681
+
514
682
  client
515
683
  .sendJson({
516
684
  type: "task_status_update",
@@ -518,7 +686,7 @@ export function startDaemon(config = {}, deps = {}) {
518
686
  task_id: taskId,
519
687
  project_id: projectId,
520
688
  status,
521
- summary: code === 0 ? "completed" : `exited with code ${code}`,
689
+ summary,
522
690
  },
523
691
  })
524
692
  .catch((err) => {
@@ -529,6 +697,16 @@ export function startDaemon(config = {}, deps = {}) {
529
697
 
530
698
  return {
531
699
  close: () => {
700
+ for (const [taskId, record] of activeTaskProcesses.entries()) {
701
+ try {
702
+ if (typeof record.child?.kill === "function") {
703
+ record.child.kill("SIGTERM");
704
+ }
705
+ } catch (error) {
706
+ logError(`Failed to stop task ${taskId} on daemon close: ${error?.message || error}`);
707
+ }
708
+ }
709
+ activeTaskProcesses.clear();
532
710
  client.disconnect();
533
711
  },
534
712
  };
@@ -540,7 +718,7 @@ async function cleanAllAgents(backendUrl, agentToken, fetchImpl) {
540
718
  const headers = {
541
719
  Authorization: `Bearer ${agentToken}`,
542
720
  };
543
- const res = await fetch(target, { method: "GET", headers });
721
+ const res = await fetchFn(target, { method: "GET", headers });
544
722
  if (!res.ok) {
545
723
  throw new Error(`cleanup failed: ${res.status} ${res.statusText}`);
546
724
  }