@love-moon/conductor-cli 0.2.12 → 0.2.14

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.
@@ -51,6 +51,7 @@ const backendUrl =
51
51
  process.env.CONDUCTOR_BACKEND_URL ||
52
52
  process.env.BACKEND_URL ||
53
53
  "https://conductor-ai.top";
54
+ const defaultDaemonName = os.hostname() || "my-daemon";
54
55
 
55
56
  // ANSI 颜色代码
56
57
  const COLORS = {
@@ -157,6 +158,7 @@ async function main() {
157
158
  const lines = [
158
159
  `agent_token: ${yamlQuote(token)}`,
159
160
  `backend_url: ${yamlQuote(backendUrl)}`,
161
+ `daemon_name: ${yamlQuote(defaultDaemonName)}`,
160
162
  "log_level: debug",
161
163
  "workspace: '~/ws/fires'",
162
164
  "",
@@ -92,13 +92,7 @@ function pidFromLockFile(lockFile) {
92
92
 
93
93
  const args = yargs(argv)
94
94
  .scriptName(CLI_NAME)
95
- .usage("Usage: $0 [--name <daemon-name>] [--clean-all] [--config-file <path>] [--nohup] [--force]")
96
- .option("name", {
97
- alias: "n",
98
- type: "string",
99
- demandOption: false,
100
- describe: "Unique daemon name (used as agent host)",
101
- })
95
+ .usage("Usage: $0 [--clean-all] [--config-file <path>] [--nohup] [--force]")
102
96
  .option("nohup", {
103
97
  type: "boolean",
104
98
  default: false,
@@ -118,10 +112,7 @@ const args = yargs(argv)
118
112
  type: "string",
119
113
  describe: "Path to Conductor config file",
120
114
  })
121
- .example(
122
- "$0 --config-file ~/.conductor/config.yaml --name agent-1",
123
- "Use custom config file and daemon name",
124
- )
115
+ .example("$0 --config-file ~/.conductor/config.yaml", "Run with daemon_name from config")
125
116
  .example("$0 --nohup", "Run daemon in background with logfile")
126
117
  .example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
127
118
  .help()
@@ -155,7 +146,6 @@ if (args.nohup) {
155
146
  }
156
147
 
157
148
  startDaemon({
158
- NAME: args.name,
159
149
  CLEAN_ALL: args.cleanAll,
160
150
  CONFIG_FILE: args.configFile,
161
151
  FORCE: args.force,
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from "node:process";
4
+ import yargs from "yargs/yargs";
5
+ import { hideBin } from "yargs/helpers";
6
+
7
+ import { loadConfig } from "@love-moon/conductor-sdk";
8
+
9
+ const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor diagnose";
10
+ const DEFAULT_TIMEOUT_MS = 8000;
11
+
12
+ const args = yargs(hideBin(process.argv))
13
+ .scriptName(CLI_NAME)
14
+ .usage("Usage: $0 <task-id> [options]")
15
+ .option("config-file", {
16
+ type: "string",
17
+ describe: "Path to Conductor config file",
18
+ })
19
+ .option("json", {
20
+ type: "boolean",
21
+ default: false,
22
+ describe: "Print raw JSON output",
23
+ })
24
+ .option("timeout-ms", {
25
+ type: "number",
26
+ default: DEFAULT_TIMEOUT_MS,
27
+ describe: "HTTP timeout in milliseconds",
28
+ })
29
+ .example("$0 3b3e09b8-6d6f-4f3b-9f2f-3fca86b9e3cb", "Diagnose a task")
30
+ .example("$0 3b3e09b8-6d6f-4f3b-9f2f-3fca86b9e3cb --json", "Diagnose and print JSON")
31
+ .demandCommand(1, "task-id is required")
32
+ .help()
33
+ .strictOptions()
34
+ .parse();
35
+
36
+ const taskId = String(args._[0] || "").trim();
37
+ if (!taskId) {
38
+ process.stderr.write("task-id is required\n");
39
+ process.exit(1);
40
+ }
41
+
42
+ main().catch((error) => {
43
+ process.stderr.write(`diagnose failed: ${error?.message || error}\n`);
44
+ process.exit(1);
45
+ });
46
+
47
+ async function main() {
48
+ const config = loadConfig(args.configFile);
49
+ const timeoutMs = normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS);
50
+ const baseUrl = String(config.backendUrl || "").replace(/\/+$/, "");
51
+ const endpoint = `${baseUrl}/api/diagnostics/tasks/${encodeURIComponent(taskId)}`;
52
+
53
+ const full = await fetchJson(endpoint, config.agentToken, timeoutMs);
54
+ let report;
55
+ if (full.ok) {
56
+ report = {
57
+ mode: "full",
58
+ endpoint,
59
+ payload: full.body,
60
+ };
61
+ } else if (full.status === 404) {
62
+ const fallback = await runFallbackDiagnosis(baseUrl, config.agentToken, taskId, timeoutMs);
63
+ report = {
64
+ mode: "fallback",
65
+ endpoint,
66
+ payload: fallback,
67
+ note: "backend does not expose /api/diagnostics/tasks/:taskId yet",
68
+ };
69
+ } else {
70
+ const body = full.body && typeof full.body === "object" ? full.body : null;
71
+ const msg = body?.error || body?.message || `HTTP ${full.status}`;
72
+ throw new Error(`diagnostics request failed: ${msg}`);
73
+ }
74
+
75
+ if (args.json) {
76
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
77
+ return;
78
+ }
79
+
80
+ printReport(taskId, report);
81
+ }
82
+
83
+ async function runFallbackDiagnosis(baseUrl, token, taskId, timeoutMs) {
84
+ const taskResp = await fetchJson(`${baseUrl}/api/tasks/${encodeURIComponent(taskId)}`, token, timeoutMs);
85
+ if (!taskResp.ok) {
86
+ throw new Error(`fallback: failed to fetch task (${taskResp.status})`);
87
+ }
88
+
89
+ const msgResp = await fetchJson(
90
+ `${baseUrl}/api/tasks/${encodeURIComponent(taskId)}/messages`,
91
+ token,
92
+ timeoutMs,
93
+ );
94
+ if (!msgResp.ok) {
95
+ throw new Error(`fallback: failed to fetch messages (${msgResp.status})`);
96
+ }
97
+
98
+ const agentsResp = await fetchJson(`${baseUrl}/api/agents`, token, timeoutMs);
99
+ if (!agentsResp.ok) {
100
+ throw new Error(`fallback: failed to fetch agents (${agentsResp.status})`);
101
+ }
102
+
103
+ const task = taskResp.body || {};
104
+ const messages = Array.isArray(msgResp.body) ? msgResp.body : [];
105
+ const agents = Array.isArray(agentsResp.body) ? agentsResp.body : [];
106
+
107
+ const latestUser = messages
108
+ .filter((m) => normalizeRole(m?.role) === "user")
109
+ .sort((a, b) => toMs(b?.created_at || b?.createdAt) - toMs(a?.created_at || a?.createdAt))[0] || null;
110
+ const latestSdk = messages
111
+ .filter((m) => {
112
+ const role = normalizeRole(m?.role);
113
+ return role === "sdk" || role === "assistant";
114
+ })
115
+ .sort((a, b) => toMs(b?.created_at || b?.createdAt) - toMs(a?.created_at || a?.createdAt))[0] || null;
116
+
117
+ const latestUserMs = toMs(latestUser?.created_at || latestUser?.createdAt);
118
+ const latestSdkMs = toMs(latestSdk?.created_at || latestSdk?.createdAt);
119
+ const hasPendingUser = Boolean(latestUser && (!latestSdk || latestUserMs > latestSdkMs));
120
+ const pendingAgeMs = hasPendingUser && Number.isFinite(latestUserMs) ? Date.now() - latestUserMs : null;
121
+ const latestSdkFailureKey = detectExecutionFailureLoopKey(latestSdk?.content);
122
+
123
+ const assignedHost = cleanText(
124
+ task?.execution_host || task?.executionHost || task?.agent_host || task?.agentHost,
125
+ );
126
+ const assignedConnected = Boolean(
127
+ assignedHost && agents.some((agent) => cleanText(agent?.host) === assignedHost),
128
+ );
129
+
130
+ const diagnosis = classifyFallback({
131
+ taskStatus: normalizeTaskStatus(task?.status),
132
+ hasPendingUser,
133
+ pendingAgeMs,
134
+ latestSdkFailureKey,
135
+ assignedHost,
136
+ assignedConnected,
137
+ });
138
+
139
+ return {
140
+ task: {
141
+ id: task?.id || taskId,
142
+ status: normalizeTaskStatus(task?.status),
143
+ agent_host: assignedHost || null,
144
+ execution_host: cleanText(task?.execution_host || task?.executionHost) || null,
145
+ },
146
+ messages: {
147
+ total: messages.length,
148
+ latest_user: latestUser,
149
+ latest_sdk: latestSdk,
150
+ latest_sdk_failure_key: latestSdkFailureKey,
151
+ has_pending_user: hasPendingUser,
152
+ pending_age_ms: pendingAgeMs,
153
+ },
154
+ realtime: {
155
+ connected_agents: agents,
156
+ assigned_host_connected: assignedConnected,
157
+ },
158
+ diagnosis,
159
+ };
160
+ }
161
+
162
+ function classifyFallback(input) {
163
+ if (input.taskStatus === "completed" || input.taskStatus === "killed") {
164
+ return {
165
+ code: "task_terminal",
166
+ confidence: "high",
167
+ summary: `task is already ${input.taskStatus}`,
168
+ reasons: [`task.status=${input.taskStatus}`],
169
+ next_actions: [],
170
+ };
171
+ }
172
+
173
+ if (!input.hasPendingUser) {
174
+ if (input.latestSdkFailureKey) {
175
+ return {
176
+ code: "execution_failure_loop",
177
+ confidence: "high",
178
+ summary: "latest sdk message indicates execution-layer failure loop",
179
+ reasons: [
180
+ "latest user message is not newer than latest sdk message",
181
+ `latest sdk failure key=${input.latestSdkFailureKey}`,
182
+ ],
183
+ next_actions: [
184
+ "restart task-scoped fire process and verify PTY child cleanup",
185
+ "if repeated, create a new task and terminate old stuck task",
186
+ ],
187
+ };
188
+ }
189
+ return {
190
+ code: "no_pending_user",
191
+ confidence: "high",
192
+ summary: "no pending user message newer than last sdk reply",
193
+ reasons: ["latest user message is not newer than latest sdk message"],
194
+ next_actions: [],
195
+ };
196
+ }
197
+
198
+ if (!input.assignedConnected) {
199
+ return {
200
+ code: "likely_ws_or_routing_issue",
201
+ confidence: "high",
202
+ summary: "pending user message and assigned agent is offline",
203
+ reasons: [
204
+ "latest user message is pending",
205
+ input.assignedHost ? `assigned host ${input.assignedHost} is not connected` : "task has no assigned host",
206
+ ],
207
+ next_actions: [
208
+ "check fire/daemon websocket reconnect logs",
209
+ "verify task binding and outbox delivery on backend",
210
+ ],
211
+ };
212
+ }
213
+
214
+ if (typeof input.pendingAgeMs === "number" && input.pendingAgeMs > 120000) {
215
+ return {
216
+ code: "likely_runturn_stuck",
217
+ confidence: "medium",
218
+ summary: "agent is online but pending user message has waited >120s",
219
+ reasons: [
220
+ "assigned host is still connected",
221
+ `pending age ${Math.round(input.pendingAgeMs / 1000)}s`,
222
+ ],
223
+ next_actions: [
224
+ "inspect fire log for repeated turn waiting on same replyTo",
225
+ "enable CONDUCTOR_TUI_TRACE=1 to confirm driver state progression",
226
+ ],
227
+ };
228
+ }
229
+
230
+ return {
231
+ code: "pending_but_processing",
232
+ confidence: "low",
233
+ summary: "pending user message exists but may still be processing",
234
+ reasons: [
235
+ "assigned host is connected",
236
+ typeof input.pendingAgeMs === "number" ? `pending age ${Math.round(input.pendingAgeMs / 1000)}s` : "pending age unknown",
237
+ ],
238
+ next_actions: [],
239
+ };
240
+ }
241
+
242
+ function printReport(taskId, report) {
243
+ const payload = report?.payload || {};
244
+ const diagnosis = payload?.diagnosis || {};
245
+ const task = payload?.task || {};
246
+ const realtime = payload?.realtime || {};
247
+ const messages = payload?.messages || {};
248
+
249
+ process.stdout.write(`Task: ${task?.id || taskId}\n`);
250
+ process.stdout.write(`Mode: ${report.mode}${report.note ? ` (${report.note})` : ""}\n`);
251
+ process.stdout.write(`Verdict: ${String(diagnosis.code || "unknown")} (${String(diagnosis.confidence || "unknown")})\n`);
252
+ process.stdout.write(`Summary: ${String(diagnosis.summary || "n/a")}\n`);
253
+ process.stdout.write("\n");
254
+ process.stdout.write("Signals:\n");
255
+ process.stdout.write(`- task.status: ${String(task?.status || "unknown")}\n`);
256
+ process.stdout.write(`- task.agent_host: ${String(task?.agent_host || task?.agentHost || "n/a")}\n`);
257
+ process.stdout.write(`- task.execution_host: ${String(task?.execution_host || task?.executionHost || "n/a")}\n`);
258
+ process.stdout.write(`- bound_agent_host: ${String(realtime?.bound_agent_host || realtime?.boundAgentHost || "n/a")}\n`);
259
+ process.stdout.write(
260
+ `- pending user: ${Boolean(messages?.has_pending_user ?? messages?.hasPendingUser)}${
261
+ typeof (messages?.pending_age_ms ?? messages?.pendingAgeMs) === "number"
262
+ ? ` (${Math.round((messages?.pending_age_ms ?? messages?.pendingAgeMs) / 1000)}s)`
263
+ : ""
264
+ }\n`,
265
+ );
266
+ if (payload?.outbox) {
267
+ process.stdout.write(`- outbox.available: ${Boolean(payload.outbox.available)}\n`);
268
+ if (payload.outbox.latest_for_pending_user) {
269
+ process.stdout.write(
270
+ `- outbox.latest_for_pending_user: ${String(payload.outbox.latest_for_pending_user.status || "unknown")} (${String(
271
+ payload.outbox.latest_for_pending_user.last_error || "no_error",
272
+ )})\n`,
273
+ );
274
+ }
275
+ }
276
+
277
+ const reasons = Array.isArray(diagnosis.reasons) ? diagnosis.reasons : [];
278
+ if (reasons.length > 0) {
279
+ process.stdout.write("\nReasons:\n");
280
+ for (const reason of reasons) {
281
+ process.stdout.write(`- ${reason}\n`);
282
+ }
283
+ }
284
+
285
+ const nextActions = Array.isArray(diagnosis.next_actions) ? diagnosis.next_actions : [];
286
+ if (nextActions.length > 0) {
287
+ process.stdout.write("\nNext Actions:\n");
288
+ for (const action of nextActions) {
289
+ process.stdout.write(`- ${action}\n`);
290
+ }
291
+ }
292
+ }
293
+
294
+ async function fetchJson(url, token, timeoutMs) {
295
+ const controller = new AbortController();
296
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
297
+ if (typeof timer.unref === "function") {
298
+ timer.unref();
299
+ }
300
+ try {
301
+ const response = await fetch(url, {
302
+ method: "GET",
303
+ headers: {
304
+ Authorization: `Bearer ${token}`,
305
+ Accept: "application/json",
306
+ },
307
+ signal: controller.signal,
308
+ });
309
+ const body = await parseJsonSafe(response);
310
+ return {
311
+ ok: response.ok,
312
+ status: response.status,
313
+ body,
314
+ };
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+
320
+ async function parseJsonSafe(response) {
321
+ const text = await response.text();
322
+ if (!text) {
323
+ return null;
324
+ }
325
+ try {
326
+ return JSON.parse(text);
327
+ } catch {
328
+ return { raw: text };
329
+ }
330
+ }
331
+
332
+ function normalizeRole(role) {
333
+ return cleanText(role).toLowerCase();
334
+ }
335
+
336
+ function normalizeTaskStatus(status) {
337
+ const normalized = cleanText(status).toLowerCase();
338
+ if (normalized === "completed") return "completed";
339
+ if (normalized === "running") return "running";
340
+ if (normalized === "killed" || normalized === "failed" || normalized === "cancelled") return "killed";
341
+ return normalized || "unknown";
342
+ }
343
+
344
+ function cleanText(value) {
345
+ return typeof value === "string" ? value.trim() : "";
346
+ }
347
+
348
+ function toMs(value) {
349
+ const ms = Date.parse(String(value || ""));
350
+ return Number.isFinite(ms) ? ms : NaN;
351
+ }
352
+
353
+ function detectExecutionFailureLoopKey(value) {
354
+ const normalized = cleanText(value).toLowerCase();
355
+ if (!normalized) return null;
356
+ if (normalized.includes("pty session already spawned")) return "pty_session_already_spawned";
357
+ if (
358
+ normalized.includes("tui process has exited") ||
359
+ normalized.includes("cannot proceed: tui process has exited")
360
+ ) {
361
+ return "tui_process_exited";
362
+ }
363
+ if (normalized.includes("execution_failure_loop")) return "execution_failure_loop";
364
+ return null;
365
+ }
366
+
367
+ function normalizePositiveInt(value, fallback) {
368
+ const parsed = Number.parseInt(String(value ?? ""), 10);
369
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
370
+ }