@love-moon/conductor-cli 0.2.3 → 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.
- package/bin/conductor-fire.js +73 -9
- package/package.json +4 -5
- package/src/daemon.js +187 -9
package/bin/conductor-fire.js
CHANGED
|
@@ -255,16 +255,32 @@ async function main() {
|
|
|
255
255
|
});
|
|
256
256
|
|
|
257
257
|
const signals = new AbortController();
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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.
|
|
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.
|
|
20
|
-
"@love-moon/conductor-sdk": "0.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.3"
|
|
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: "
|
|
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: "
|
|
509
|
+
status: "UNKNOWN",
|
|
374
510
|
},
|
|
375
511
|
})
|
|
376
512
|
.catch((err) => {
|
|
377
|
-
logError(`Failed to report task status (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|