@love-moon/conductor-cli 0.2.17 → 0.2.18
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-config.js +14 -14
- package/bin/conductor-fire.js +133 -10
- package/bin/conductor-send-file.js +290 -0
- package/bin/conductor.js +5 -1
- package/package.json +5 -5
- package/src/daemon.js +333 -2
package/bin/conductor-config.js
CHANGED
|
@@ -25,11 +25,11 @@ const DEFAULT_CLIs = {
|
|
|
25
25
|
execArgs: "exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check",
|
|
26
26
|
description: "OpenAI Codex CLI"
|
|
27
27
|
},
|
|
28
|
-
copilot: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
28
|
+
// copilot: {
|
|
29
|
+
// command: "copilot",
|
|
30
|
+
// execArgs: "--allow-all-paths --allow-all-tools",
|
|
31
|
+
// description: "GitHub Copilot CLI"
|
|
32
|
+
// },
|
|
33
33
|
// gemini: {
|
|
34
34
|
// command: "gemini",
|
|
35
35
|
// execArgs: "",
|
|
@@ -282,15 +282,15 @@ function checkAlternativeInstallations(command) {
|
|
|
282
282
|
);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
// 特殊检查:Copilot CLI 可能是 gh copilot 扩展
|
|
286
|
-
if (command === "copilot" || command === "copilot-chat") {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
285
|
+
// // 特殊检查:Copilot CLI 可能是 gh copilot 扩展
|
|
286
|
+
// if (command === "copilot" || command === "copilot-chat") {
|
|
287
|
+
// try {
|
|
288
|
+
// execSync("gh copilot --help", { stdio: "pipe", timeout: 5000 });
|
|
289
|
+
// return true;
|
|
290
|
+
// } catch {
|
|
291
|
+
// // gh copilot 未安装
|
|
292
|
+
// }
|
|
293
|
+
// }
|
|
294
294
|
|
|
295
295
|
// 检查文件是否存在
|
|
296
296
|
for (const checkPath of commonPaths) {
|
package/bin/conductor-fire.js
CHANGED
|
@@ -32,6 +32,7 @@ const __dirname = path.dirname(__filename);
|
|
|
32
32
|
const PKG_ROOT = path.join(__dirname, "..");
|
|
33
33
|
const INITIAL_CLI_PROJECT_PATH = process.cwd();
|
|
34
34
|
const FIRE_LOG_PATH = path.join(INITIAL_CLI_PROJECT_PATH, "conductor.log");
|
|
35
|
+
const FIRE_TASK_MARKER_PREFIX = "active-fire";
|
|
35
36
|
const ENABLE_FIRE_LOCAL_LOG = !process.env.CONDUCTOR_CLI_COMMAND;
|
|
36
37
|
|
|
37
38
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
|
|
@@ -303,6 +304,13 @@ async function main() {
|
|
|
303
304
|
backend: cliArgs.backend,
|
|
304
305
|
daemonName: configuredDaemonName,
|
|
305
306
|
});
|
|
307
|
+
injectResolvedTaskId(taskContext.taskId);
|
|
308
|
+
injectResolvedTaskId(taskContext.taskId, env);
|
|
309
|
+
try {
|
|
310
|
+
writeFireTaskMarker(taskContext.taskId, runtimeProjectPath);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
log(`Note: Could not persist local task marker: ${error.message}`);
|
|
313
|
+
}
|
|
306
314
|
|
|
307
315
|
log(
|
|
308
316
|
`Attached to Conductor task ${taskContext.taskId}${
|
|
@@ -711,6 +719,73 @@ export function resolveRequestedTaskTitle({
|
|
|
711
719
|
return resolveTaskTitle(explicit, runtimeProjectPath);
|
|
712
720
|
}
|
|
713
721
|
|
|
722
|
+
function normalizeTaskId(value) {
|
|
723
|
+
if (typeof value !== "string") {
|
|
724
|
+
return "";
|
|
725
|
+
}
|
|
726
|
+
return value.trim();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function resolveFireStateDir(workingDirectory = process.cwd()) {
|
|
730
|
+
const baseDir =
|
|
731
|
+
typeof workingDirectory === "string" && workingDirectory.trim()
|
|
732
|
+
? path.resolve(workingDirectory.trim())
|
|
733
|
+
: process.cwd();
|
|
734
|
+
return path.join(baseDir, ".conductor", "state");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function injectResolvedTaskId(taskId, env = process.env) {
|
|
738
|
+
const normalizedTaskId = normalizeTaskId(taskId);
|
|
739
|
+
if (!normalizedTaskId) {
|
|
740
|
+
return "";
|
|
741
|
+
}
|
|
742
|
+
env.CONDUCTOR_TASK_ID = normalizedTaskId;
|
|
743
|
+
return normalizedTaskId;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function writeFireTaskMarker(taskId, workingDirectory = process.cwd()) {
|
|
747
|
+
const normalizedTaskId = normalizeTaskId(taskId);
|
|
748
|
+
if (!normalizedTaskId) {
|
|
749
|
+
return "";
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const stateDir = resolveFireStateDir(workingDirectory);
|
|
753
|
+
const markerFileName = `${FIRE_TASK_MARKER_PREFIX}.task_${normalizedTaskId}.json`;
|
|
754
|
+
const markerPath = path.join(stateDir, markerFileName);
|
|
755
|
+
|
|
756
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
757
|
+
fs.writeFileSync(
|
|
758
|
+
markerPath,
|
|
759
|
+
`${JSON.stringify(
|
|
760
|
+
{
|
|
761
|
+
source: "conductor-fire",
|
|
762
|
+
taskId: normalizedTaskId,
|
|
763
|
+
cwd: path.resolve(workingDirectory),
|
|
764
|
+
updatedAt: new Date().toISOString(),
|
|
765
|
+
},
|
|
766
|
+
null,
|
|
767
|
+
2,
|
|
768
|
+
)}\n`,
|
|
769
|
+
"utf8",
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
for (const entry of fs.readdirSync(stateDir)) {
|
|
773
|
+
if (entry === markerFileName) {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (!entry.startsWith(`${FIRE_TASK_MARKER_PREFIX}.task_`) || !entry.endsWith(".json")) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
fs.unlinkSync(path.join(stateDir, entry));
|
|
781
|
+
} catch {
|
|
782
|
+
// ignore stale marker cleanup failures
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return markerPath;
|
|
787
|
+
}
|
|
788
|
+
|
|
714
789
|
function normalizeArray(value) {
|
|
715
790
|
if (!value) {
|
|
716
791
|
return [];
|
|
@@ -992,6 +1067,7 @@ export class BridgeRunner {
|
|
|
992
1067
|
this.needsReconnectRecovery = false;
|
|
993
1068
|
this.remoteStopInfo = null;
|
|
994
1069
|
this.sessionAnnouncementSent = false;
|
|
1070
|
+
this.boundSessionId = "";
|
|
995
1071
|
this.errorLoop = null;
|
|
996
1072
|
this.errorLoopWindowMs = getBoundedEnvInt(
|
|
997
1073
|
"CONDUCTOR_ERROR_LOOP_WINDOW_MS",
|
|
@@ -1058,16 +1134,11 @@ export class BridgeRunner {
|
|
|
1058
1134
|
const message = hasRealSessionId
|
|
1059
1135
|
? `${this.backendName} session started: ${sessionId}`
|
|
1060
1136
|
: `${this.backendName} session started`;
|
|
1061
|
-
if (hasRealSessionId
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
backend_type: this.backendName,
|
|
1067
|
-
});
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
log(`Failed to persist task session binding for ${this.taskId}: ${error?.message || error}`);
|
|
1070
|
-
}
|
|
1137
|
+
if (hasRealSessionId) {
|
|
1138
|
+
await this.persistTaskSessionBinding({
|
|
1139
|
+
sessionId,
|
|
1140
|
+
sessionFilePath,
|
|
1141
|
+
});
|
|
1071
1142
|
}
|
|
1072
1143
|
try {
|
|
1073
1144
|
await this.conductor.sendMessage(this.taskId, message, {
|
|
@@ -1101,6 +1172,51 @@ export class BridgeRunner {
|
|
|
1101
1172
|
}
|
|
1102
1173
|
}
|
|
1103
1174
|
|
|
1175
|
+
async persistTaskSessionBinding({ sessionId, sessionFilePath } = {}) {
|
|
1176
|
+
const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
1177
|
+
if (!normalizedSessionId || normalizedSessionId === this.boundSessionId) {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof this.conductor?.bindTaskSession !== "function") {
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
await this.conductor.bindTaskSession(this.taskId, {
|
|
1185
|
+
session_id: normalizedSessionId,
|
|
1186
|
+
session_file_path:
|
|
1187
|
+
typeof sessionFilePath === "string" && sessionFilePath.trim() ? sessionFilePath.trim() : undefined,
|
|
1188
|
+
backend_type: this.backendName,
|
|
1189
|
+
});
|
|
1190
|
+
this.boundSessionId = normalizedSessionId;
|
|
1191
|
+
return true;
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
log(`Failed to persist task session binding for ${this.taskId}: ${error?.message || error}`);
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async syncBackendSessionBinding() {
|
|
1199
|
+
if (!this.backendSession) {
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
let sessionInfo = null;
|
|
1203
|
+
try {
|
|
1204
|
+
if (typeof this.backendSession.getSessionInfo === "function") {
|
|
1205
|
+
sessionInfo = this.backendSession.getSessionInfo();
|
|
1206
|
+
}
|
|
1207
|
+
if (!sessionInfo && typeof this.backendSession.ensureSessionInfo === "function") {
|
|
1208
|
+
sessionInfo = await this.backendSession.ensureSessionInfo();
|
|
1209
|
+
}
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
this.copilotLog(`session binding sync skipped: ${sanitizeForLog(error?.message || error, 160)}`);
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
return this.persistTaskSessionBinding({
|
|
1215
|
+
sessionId: sessionInfo?.sessionId,
|
|
1216
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1104
1220
|
async start(abortSignal) {
|
|
1105
1221
|
abortSignal?.addEventListener("abort", () => {
|
|
1106
1222
|
this.stopped = true;
|
|
@@ -1505,6 +1621,11 @@ export class BridgeRunner {
|
|
|
1505
1621
|
return;
|
|
1506
1622
|
}
|
|
1507
1623
|
|
|
1624
|
+
await this.persistTaskSessionBinding({
|
|
1625
|
+
sessionId,
|
|
1626
|
+
sessionFilePath,
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1508
1629
|
logBackendReply(this.backendName, normalizedText, {
|
|
1509
1630
|
usage: null,
|
|
1510
1631
|
replyTo: replyTo || "latest",
|
|
@@ -1748,6 +1869,7 @@ export class BridgeRunner {
|
|
|
1748
1869
|
cli_args: this.cliArgs,
|
|
1749
1870
|
});
|
|
1750
1871
|
}
|
|
1872
|
+
await this.syncBackendSessionBinding();
|
|
1751
1873
|
if (replyTo) {
|
|
1752
1874
|
this.processedMessageIds.add(replyTo);
|
|
1753
1875
|
}
|
|
@@ -1877,6 +1999,7 @@ export class BridgeRunner {
|
|
|
1877
1999
|
} else {
|
|
1878
2000
|
this.copilotLog("synthetic session_file turn settled");
|
|
1879
2001
|
}
|
|
2002
|
+
await this.syncBackendSessionBinding();
|
|
1880
2003
|
} catch (error) {
|
|
1881
2004
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1882
2005
|
if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import yargs from "yargs/yargs";
|
|
11
|
+
import { hideBin } from "yargs/helpers";
|
|
12
|
+
import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MIME_TYPE = "application/octet-stream";
|
|
15
|
+
const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".conductor", "config.yaml");
|
|
16
|
+
const FIRE_TASK_MARKER_PREFIX = "active-fire";
|
|
17
|
+
|
|
18
|
+
const EXTENSION_TO_MIME = {
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".heic": "image/heic",
|
|
21
|
+
".jpeg": "image/jpeg",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".json": "application/json",
|
|
24
|
+
".mov": "video/quicktime",
|
|
25
|
+
".mp3": "audio/mpeg",
|
|
26
|
+
".mp4": "video/mp4",
|
|
27
|
+
".pdf": "application/pdf",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".svg": "image/svg+xml",
|
|
30
|
+
".txt": "text/plain",
|
|
31
|
+
".wav": "audio/wav",
|
|
32
|
+
".webm": "video/webm",
|
|
33
|
+
".webp": "image/webp",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isMainModule = (() => {
|
|
37
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
38
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
39
|
+
return entryFile === currentFile;
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
function walkUpDirectories(startDir) {
|
|
43
|
+
const visited = [];
|
|
44
|
+
let currentDir = path.resolve(startDir);
|
|
45
|
+
while (true) {
|
|
46
|
+
visited.push(currentDir);
|
|
47
|
+
const parentDir = path.dirname(currentDir);
|
|
48
|
+
if (parentDir === currentDir) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
currentDir = parentDir;
|
|
52
|
+
}
|
|
53
|
+
return visited;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeTaskId(value) {
|
|
57
|
+
if (typeof value !== "string") {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pickLatestTaskIdFromStateDir(stateDir) {
|
|
64
|
+
if (!fs.existsSync(stateDir)) {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const matches = [];
|
|
69
|
+
for (const entry of fs.readdirSync(stateDir)) {
|
|
70
|
+
const match = entry.match(new RegExp(`^${FIRE_TASK_MARKER_PREFIX}\\.task_([0-9a-f-]+)\\.json$`, "i"));
|
|
71
|
+
if (!match) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const filePath = path.join(stateDir, entry);
|
|
75
|
+
let mtimeMs = 0;
|
|
76
|
+
try {
|
|
77
|
+
mtimeMs = fs.statSync(filePath).mtimeMs;
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
matches.push({ taskId: match[1], mtimeMs });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (matches.length === 0) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
matches.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
89
|
+
return matches[0].taskId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function detectTaskId(options = {}) {
|
|
93
|
+
const env = options.env || process.env;
|
|
94
|
+
const cwd = options.cwd || process.cwd();
|
|
95
|
+
|
|
96
|
+
const envTaskId = normalizeTaskId(env.CONDUCTOR_TASK_ID);
|
|
97
|
+
if (envTaskId) {
|
|
98
|
+
return envTaskId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const directory of walkUpDirectories(cwd)) {
|
|
102
|
+
const stateTaskId = pickLatestTaskIdFromStateDir(path.join(directory, ".conductor", "state"));
|
|
103
|
+
if (stateTaskId) {
|
|
104
|
+
return stateTaskId;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadCliConfig(configFile, env = process.env) {
|
|
112
|
+
const configPath = configFile ? path.resolve(configFile) : DEFAULT_CONFIG_PATH;
|
|
113
|
+
if (fs.existsSync(configPath)) {
|
|
114
|
+
return loadConfig(configPath, { env });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
118
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
119
|
+
if (agentToken && backendUrl) {
|
|
120
|
+
return new ConductorConfig({
|
|
121
|
+
agentToken,
|
|
122
|
+
backendUrl,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return loadConfig(configPath, { env });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function guessMimeType(fileName, preferredMimeType = "") {
|
|
130
|
+
const preferred = typeof preferredMimeType === "string" ? preferredMimeType.trim() : "";
|
|
131
|
+
if (preferred) {
|
|
132
|
+
return preferred;
|
|
133
|
+
}
|
|
134
|
+
const extension = path.extname(fileName).toLowerCase();
|
|
135
|
+
return EXTENSION_TO_MIME[extension] || DEFAULT_MIME_TYPE;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatErrorBody(text) {
|
|
139
|
+
const normalized = text.trim();
|
|
140
|
+
if (!normalized) {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(normalized);
|
|
145
|
+
if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
|
|
146
|
+
return parsed.error;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore parse failures
|
|
150
|
+
}
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function sendFileToTask(options) {
|
|
155
|
+
const env = options.env || process.env;
|
|
156
|
+
const cwd = options.cwd || process.cwd();
|
|
157
|
+
const fetchImpl = options.fetchImpl || global.fetch;
|
|
158
|
+
if (typeof fetchImpl !== "function") {
|
|
159
|
+
throw new Error("fetch is not available");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const taskId = normalizeTaskId(options.taskId) || detectTaskId({ env, cwd });
|
|
163
|
+
if (!taskId) {
|
|
164
|
+
throw new Error("Unable to resolve task ID. Pass --task-id or run inside an active Conductor fire workspace.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const config = loadCliConfig(options.configFile, env);
|
|
168
|
+
const filePath = path.resolve(cwd, String(options.filePath || ""));
|
|
169
|
+
const stats = await fsp.stat(filePath).catch(() => null);
|
|
170
|
+
if (!stats || !stats.isFile()) {
|
|
171
|
+
throw new Error(`File not found: ${filePath}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fileBuffer = await fsp.readFile(filePath);
|
|
175
|
+
const fileName =
|
|
176
|
+
typeof options.name === "string" && options.name.trim()
|
|
177
|
+
? path.basename(options.name.trim())
|
|
178
|
+
: path.basename(filePath);
|
|
179
|
+
const mimeType = guessMimeType(fileName, options.mimeType);
|
|
180
|
+
const body = new FormData();
|
|
181
|
+
body.set("file", new Blob([fileBuffer], { type: mimeType }), fileName);
|
|
182
|
+
|
|
183
|
+
const content = typeof options.content === "string" ? options.content.trim() : "";
|
|
184
|
+
if (content) {
|
|
185
|
+
body.set("content", content);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const role = typeof options.role === "string" && options.role.trim()
|
|
189
|
+
? options.role.trim().toLowerCase()
|
|
190
|
+
: "sdk";
|
|
191
|
+
body.set("role", role);
|
|
192
|
+
|
|
193
|
+
const url = new URL(`/api/tasks/${encodeURIComponent(taskId)}/attachments`, config.backendUrl);
|
|
194
|
+
const response = await fetchImpl(String(url), {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
198
|
+
Accept: "application/json",
|
|
199
|
+
},
|
|
200
|
+
body,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const rawText = await response.text();
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const details = formatErrorBody(rawText);
|
|
206
|
+
throw new Error(`Upload failed (${response.status})${details ? `: ${details}` : ""}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
taskId,
|
|
211
|
+
response: rawText ? JSON.parse(rawText) : {},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function main(argvInput = hideBin(process.argv)) {
|
|
216
|
+
await yargs(argvInput)
|
|
217
|
+
.scriptName("conductor send-file")
|
|
218
|
+
.command(
|
|
219
|
+
"$0 <file>",
|
|
220
|
+
"Upload a local file into the task session",
|
|
221
|
+
(command) => command
|
|
222
|
+
.positional("file", {
|
|
223
|
+
describe: "Path to the local file to upload into the task session",
|
|
224
|
+
type: "string",
|
|
225
|
+
demandOption: true,
|
|
226
|
+
})
|
|
227
|
+
.option("task-id", {
|
|
228
|
+
type: "string",
|
|
229
|
+
describe: "Explicit Conductor task ID. Defaults to auto-detecting the current task.",
|
|
230
|
+
})
|
|
231
|
+
.option("config-file", {
|
|
232
|
+
type: "string",
|
|
233
|
+
describe: "Path to Conductor config file",
|
|
234
|
+
})
|
|
235
|
+
.option("content", {
|
|
236
|
+
alias: "m",
|
|
237
|
+
type: "string",
|
|
238
|
+
describe: "Optional message text to accompany the uploaded file",
|
|
239
|
+
})
|
|
240
|
+
.option("role", {
|
|
241
|
+
choices: ["sdk", "assistant", "user"],
|
|
242
|
+
default: "sdk",
|
|
243
|
+
describe: "Message role to write into the task session",
|
|
244
|
+
})
|
|
245
|
+
.option("mime-type", {
|
|
246
|
+
type: "string",
|
|
247
|
+
describe: "Override MIME type detection",
|
|
248
|
+
})
|
|
249
|
+
.option("name", {
|
|
250
|
+
type: "string",
|
|
251
|
+
describe: "Override the filename shown in Conductor",
|
|
252
|
+
})
|
|
253
|
+
.option("json", {
|
|
254
|
+
type: "boolean",
|
|
255
|
+
default: false,
|
|
256
|
+
describe: "Print the raw JSON response",
|
|
257
|
+
}),
|
|
258
|
+
async (argv) => {
|
|
259
|
+
const result = await sendFileToTask({
|
|
260
|
+
filePath: argv.file,
|
|
261
|
+
taskId: argv.taskId,
|
|
262
|
+
configFile: argv.configFile,
|
|
263
|
+
content: argv.content,
|
|
264
|
+
role: argv.role,
|
|
265
|
+
mimeType: argv.mimeType,
|
|
266
|
+
name: argv.name,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (argv.json) {
|
|
270
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const attachments = Array.isArray(result.response?.attachments) ? result.response.attachments : [];
|
|
275
|
+
const attachmentNames = attachments.map((attachment) => attachment?.name).filter(Boolean);
|
|
276
|
+
const summary = attachmentNames.length > 0 ? attachmentNames.join(", ") : path.basename(String(argv.file));
|
|
277
|
+
process.stdout.write(`Uploaded ${summary} to task ${result.taskId}\n`);
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
.help()
|
|
281
|
+
.strict()
|
|
282
|
+
.parse();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (isMainModule) {
|
|
286
|
+
main().catch((error) => {
|
|
287
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
});
|
|
290
|
+
}
|
package/bin/conductor.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* config - Interactive configuration setup
|
|
10
10
|
* update - Update the CLI to the latest version
|
|
11
11
|
* diagnose - Diagnose a task in production/backend
|
|
12
|
+
* send-file - Upload a local file into a task session
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -45,7 +46,7 @@ if (argv[0] === "--version" || argv[0] === "-v") {
|
|
|
45
46
|
const subcommand = argv[0];
|
|
46
47
|
|
|
47
48
|
// Valid subcommands
|
|
48
|
-
const validSubcommands = ["fire", "daemon", "config", "update", "diagnose"];
|
|
49
|
+
const validSubcommands = ["fire", "daemon", "config", "update", "diagnose", "send-file"];
|
|
49
50
|
|
|
50
51
|
if (!validSubcommands.includes(subcommand)) {
|
|
51
52
|
console.error(`Error: Unknown subcommand '${subcommand}'`);
|
|
@@ -88,6 +89,7 @@ Subcommands:
|
|
|
88
89
|
config Interactive configuration setup
|
|
89
90
|
update Update the CLI to the latest version
|
|
90
91
|
diagnose Diagnose a task and print likely root cause
|
|
92
|
+
send-file Upload a local file into a task session
|
|
91
93
|
|
|
92
94
|
Options:
|
|
93
95
|
-h, --help Show this help message
|
|
@@ -98,6 +100,7 @@ Examples:
|
|
|
98
100
|
conductor fire --backend claude -- "add feature"
|
|
99
101
|
conductor daemon --config-file ~/.conductor/config.yaml
|
|
100
102
|
conductor diagnose <task-id>
|
|
103
|
+
conductor send-file ./screenshot.png
|
|
101
104
|
conductor config
|
|
102
105
|
conductor update
|
|
103
106
|
|
|
@@ -107,6 +110,7 @@ For subcommand-specific help:
|
|
|
107
110
|
conductor config --help
|
|
108
111
|
conductor update --help
|
|
109
112
|
conductor diagnose --help
|
|
113
|
+
conductor send-file --help
|
|
110
114
|
|
|
111
115
|
Version: ${pkgJson.version}
|
|
112
116
|
`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.2.18",
|
|
4
|
+
"gitCommitId": "942418b",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"conductor": "bin/conductor.js"
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node --test"
|
|
17
|
+
"test": "node --test test/*.test.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@love-moon/ai-sdk": "0.2.
|
|
21
|
-
"@love-moon/conductor-sdk": "0.2.
|
|
20
|
+
"@love-moon/ai-sdk": "0.2.18",
|
|
21
|
+
"@love-moon/conductor-sdk": "0.2.18",
|
|
22
22
|
"dotenv": "^16.4.5",
|
|
23
23
|
"enquirer": "^2.4.1",
|
|
24
24
|
"js-yaml": "^4.1.1",
|
package/src/daemon.js
CHANGED
|
@@ -230,6 +230,30 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
230
230
|
process.env.CONDUCTOR_SHUTDOWN_DISCONNECT_TIMEOUT_MS,
|
|
231
231
|
1000,
|
|
232
232
|
);
|
|
233
|
+
const DAEMON_WATCHDOG_INTERVAL_MS = parsePositiveInt(
|
|
234
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_INTERVAL_MS,
|
|
235
|
+
30_000,
|
|
236
|
+
);
|
|
237
|
+
const DAEMON_WATCHDOG_STALE_WS_MS = parsePositiveInt(
|
|
238
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_STALE_WS_MS,
|
|
239
|
+
75_000,
|
|
240
|
+
);
|
|
241
|
+
const DAEMON_WATCHDOG_CONNECT_GRACE_MS = parsePositiveInt(
|
|
242
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_CONNECT_GRACE_MS,
|
|
243
|
+
35_000,
|
|
244
|
+
);
|
|
245
|
+
const DAEMON_WATCHDOG_RECONNECT_COOLDOWN_MS = parsePositiveInt(
|
|
246
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_RECONNECT_COOLDOWN_MS,
|
|
247
|
+
45_000,
|
|
248
|
+
);
|
|
249
|
+
const DAEMON_WATCHDOG_HTTP_TIMEOUT_MS = parsePositiveInt(
|
|
250
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_HTTP_TIMEOUT_MS,
|
|
251
|
+
5_000,
|
|
252
|
+
);
|
|
253
|
+
const DAEMON_WATCHDOG_MAX_SELF_HEALS = parsePositiveInt(
|
|
254
|
+
process.env.CONDUCTOR_DAEMON_WATCHDOG_MAX_SELF_HEALS,
|
|
255
|
+
3,
|
|
256
|
+
);
|
|
233
257
|
|
|
234
258
|
try {
|
|
235
259
|
mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
|
|
@@ -363,19 +387,42 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
363
387
|
|
|
364
388
|
let disconnectedSinceLastConnectedLog = false;
|
|
365
389
|
let didRecoverStaleTasks = false;
|
|
390
|
+
let daemonShuttingDown = false;
|
|
366
391
|
const activeTaskProcesses = new Map();
|
|
367
392
|
const suppressedExitStatusReports = new Set();
|
|
368
393
|
const seenCommandRequestIds = new Set();
|
|
394
|
+
let lastConnectedAt = null;
|
|
395
|
+
let lastPongAt = null;
|
|
396
|
+
let lastInboundAt = null;
|
|
397
|
+
let lastSuccessfulHttpAt = null;
|
|
398
|
+
let lastPresenceCheckAt = null;
|
|
399
|
+
let lastPresenceConfirmedAt = null;
|
|
400
|
+
let wsConnected = false;
|
|
401
|
+
let watchdogLastHealAt = 0;
|
|
402
|
+
let watchdogHealAttempts = 0;
|
|
403
|
+
let watchdogProbeInFlight = false;
|
|
404
|
+
let watchdogLastProbeErrorAt = 0;
|
|
405
|
+
let watchdogLastPresenceMismatchAt = 0;
|
|
406
|
+
let watchdogAwaitingHealthySignalAt = null;
|
|
407
|
+
let watchdogTimer = null;
|
|
369
408
|
const logCollector = createLogCollector(BACKEND_HTTP);
|
|
370
409
|
const client = createWebSocketClient(sdkConfig, {
|
|
371
410
|
extraHeaders: {
|
|
372
411
|
"x-conductor-host": AGENT_NAME,
|
|
373
412
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
374
413
|
},
|
|
375
|
-
onConnected: ({ isReconnect } = { isReconnect: false }) => {
|
|
414
|
+
onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
|
|
415
|
+
wsConnected = true;
|
|
416
|
+
lastConnectedAt = connectedAt || Date.now();
|
|
417
|
+
lastPongAt = lastPongAt && lastPongAt > lastConnectedAt ? lastPongAt : lastConnectedAt;
|
|
376
418
|
if (!isReconnect || disconnectedSinceLastConnectedLog) {
|
|
377
419
|
log("Connected to backend");
|
|
378
420
|
}
|
|
421
|
+
if (watchdogHealAttempts > 0) {
|
|
422
|
+
watchdogAwaitingHealthySignalAt = lastConnectedAt;
|
|
423
|
+
} else {
|
|
424
|
+
watchdogAwaitingHealthySignalAt = null;
|
|
425
|
+
}
|
|
379
426
|
disconnectedSinceLastConnectedLog = false;
|
|
380
427
|
sendAgentResume(isReconnect).catch((error) => {
|
|
381
428
|
logError(`sendAgentResume failed: ${error?.message || error}`);
|
|
@@ -391,8 +438,24 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
391
438
|
});
|
|
392
439
|
}
|
|
393
440
|
},
|
|
394
|
-
onDisconnected: () => {
|
|
441
|
+
onDisconnected: (event = {}) => {
|
|
442
|
+
wsConnected = false;
|
|
395
443
|
disconnectedSinceLastConnectedLog = true;
|
|
444
|
+
if (!daemonShuttingDown) {
|
|
445
|
+
logError(
|
|
446
|
+
`[daemon-ws] Disconnected from backend: ${formatDisconnectDiagnostics(event)} (${formatDaemonHealthState({
|
|
447
|
+
connectedAt: lastConnectedAt,
|
|
448
|
+
lastPongAt,
|
|
449
|
+
lastInboundAt,
|
|
450
|
+
lastSuccessfulHttpAt,
|
|
451
|
+
lastPresenceConfirmedAt,
|
|
452
|
+
})})`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
onPong: ({ at }) => {
|
|
457
|
+
lastPongAt = at;
|
|
458
|
+
markWatchdogHealthy("pong", at);
|
|
396
459
|
},
|
|
397
460
|
});
|
|
398
461
|
|
|
@@ -404,6 +467,165 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
404
467
|
logError(`Failed to connect: ${err}`);
|
|
405
468
|
});
|
|
406
469
|
|
|
470
|
+
watchdogTimer = setInterval(() => {
|
|
471
|
+
void runDaemonWatchdog();
|
|
472
|
+
}, DAEMON_WATCHDOG_INTERVAL_MS);
|
|
473
|
+
if (typeof watchdogTimer?.unref === "function") {
|
|
474
|
+
watchdogTimer.unref();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function markBackendHttpSuccess(at = Date.now()) {
|
|
478
|
+
lastSuccessfulHttpAt = at;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function probeAgentPresence() {
|
|
482
|
+
lastPresenceCheckAt = Date.now();
|
|
483
|
+
try {
|
|
484
|
+
const response = await withTimeout(
|
|
485
|
+
fetchFn(`${BACKEND_HTTP}/api/agents`, {
|
|
486
|
+
method: "GET",
|
|
487
|
+
headers: {
|
|
488
|
+
Authorization: `Bearer ${AGENT_TOKEN}`,
|
|
489
|
+
Accept: "application/json",
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
DAEMON_WATCHDOG_HTTP_TIMEOUT_MS,
|
|
493
|
+
"daemon agent presence probe",
|
|
494
|
+
);
|
|
495
|
+
if (!response.ok) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
status: response.status,
|
|
499
|
+
error: `HTTP ${response.status}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const at = Date.now();
|
|
503
|
+
markBackendHttpSuccess(at);
|
|
504
|
+
const payload = await response.json();
|
|
505
|
+
const agents = Array.isArray(payload) ? payload : [];
|
|
506
|
+
const selfOnline = agents.some((entry) => String(entry?.host || "").trim() === AGENT_NAME);
|
|
507
|
+
if (selfOnline) {
|
|
508
|
+
lastPresenceConfirmedAt = at;
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
selfOnline,
|
|
513
|
+
agentCount: agents.length,
|
|
514
|
+
};
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
status: null,
|
|
519
|
+
error: error?.message || String(error),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function requestWatchdogSelfHeal(reason, extra = {}) {
|
|
525
|
+
if (daemonShuttingDown || !wsConnected) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
if (watchdogLastHealAt && now - watchdogLastHealAt < DAEMON_WATCHDOG_RECONNECT_COOLDOWN_MS) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
watchdogLastHealAt = now;
|
|
533
|
+
watchdogHealAttempts += 1;
|
|
534
|
+
logError(
|
|
535
|
+
`[watchdog] ${reason}; restarting daemon websocket (${watchdogHealAttempts}/${DAEMON_WATCHDOG_MAX_SELF_HEALS}) ${formatWatchdogExtra(extra)} (${formatDaemonHealthState({
|
|
536
|
+
connectedAt: lastConnectedAt,
|
|
537
|
+
lastPongAt,
|
|
538
|
+
lastInboundAt,
|
|
539
|
+
lastSuccessfulHttpAt,
|
|
540
|
+
lastPresenceConfirmedAt,
|
|
541
|
+
})})`,
|
|
542
|
+
);
|
|
543
|
+
if (watchdogHealAttempts > DAEMON_WATCHDOG_MAX_SELF_HEALS) {
|
|
544
|
+
daemonShuttingDown = true;
|
|
545
|
+
logError("[watchdog] Self-heal budget exceeded; exiting daemon for supervisor restart");
|
|
546
|
+
void requestShutdown("watchdog self-heal budget exceeded")
|
|
547
|
+
.catch((error) => {
|
|
548
|
+
logError(`watchdog shutdown failed: ${error?.message || error}`);
|
|
549
|
+
})
|
|
550
|
+
.finally(() => {
|
|
551
|
+
cleanupLock();
|
|
552
|
+
exitFn(1);
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
watchdogAwaitingHealthySignalAt = null;
|
|
557
|
+
wsConnected = false;
|
|
558
|
+
disconnectedSinceLastConnectedLog = true;
|
|
559
|
+
if (typeof client.forceReconnect === "function") {
|
|
560
|
+
Promise.resolve(client.forceReconnect(`watchdog:${reason}`)).catch((error) => {
|
|
561
|
+
logError(`watchdog forceReconnect failed: ${error?.message || error}`);
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
Promise.resolve(client.disconnect())
|
|
566
|
+
.catch((error) => {
|
|
567
|
+
logError(`watchdog disconnect failed: ${error?.message || error}`);
|
|
568
|
+
})
|
|
569
|
+
.finally(() => {
|
|
570
|
+
client.connect().catch((error) => {
|
|
571
|
+
logError(`watchdog reconnect failed: ${error?.message || error}`);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function runDaemonWatchdog() {
|
|
577
|
+
if (daemonShuttingDown || !wsConnected || watchdogProbeInFlight) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const startedAt = Date.now();
|
|
581
|
+
if (!lastConnectedAt || startedAt - lastConnectedAt < DAEMON_WATCHDOG_CONNECT_GRACE_MS) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
watchdogProbeInFlight = true;
|
|
585
|
+
try {
|
|
586
|
+
const probe = await probeAgentPresence();
|
|
587
|
+
const now = Date.now();
|
|
588
|
+
const lastWsHealthAt = Math.max(lastPongAt || 0, lastInboundAt || 0, lastConnectedAt || 0);
|
|
589
|
+
const staleWs = !lastWsHealthAt || now - lastWsHealthAt > DAEMON_WATCHDOG_STALE_WS_MS;
|
|
590
|
+
|
|
591
|
+
if (!probe.ok) {
|
|
592
|
+
if (now - watchdogLastProbeErrorAt >= DAEMON_WATCHDOG_RECONNECT_COOLDOWN_MS) {
|
|
593
|
+
watchdogLastProbeErrorAt = now;
|
|
594
|
+
logError(`[watchdog] agent presence probe failed: ${probe.error}`);
|
|
595
|
+
}
|
|
596
|
+
if (staleWs) {
|
|
597
|
+
requestWatchdogSelfHeal("stale_ws_health", {
|
|
598
|
+
probeAt: lastPresenceCheckAt,
|
|
599
|
+
probeStatus: probe.status,
|
|
600
|
+
probeError: probe.error,
|
|
601
|
+
lastWsHealthAt,
|
|
602
|
+
staleForMs: now - lastWsHealthAt,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!probe.selfOnline && now - watchdogLastPresenceMismatchAt >= DAEMON_WATCHDOG_RECONNECT_COOLDOWN_MS) {
|
|
609
|
+
watchdogLastPresenceMismatchAt = now;
|
|
610
|
+
logError(`[watchdog] agent presence probe did not include current host; skipping self-heal to avoid false positives on non-sticky HTTP/WS deployments (${formatWatchdogExtra({
|
|
611
|
+
agentCount: probe.agentCount,
|
|
612
|
+
probeAt: lastPresenceCheckAt,
|
|
613
|
+
})})`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (staleWs) {
|
|
617
|
+
requestWatchdogSelfHeal("stale_ws_health", {
|
|
618
|
+
agentCount: probe.agentCount,
|
|
619
|
+
lastWsHealthAt,
|
|
620
|
+
staleForMs: now - lastWsHealthAt,
|
|
621
|
+
probeAt: lastPresenceCheckAt,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
watchdogProbeInFlight = false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
407
629
|
async function recoverStaleTasks() {
|
|
408
630
|
try {
|
|
409
631
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
@@ -417,6 +639,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
417
639
|
logError(`Failed to recover stale tasks: HTTP ${response.status}`);
|
|
418
640
|
return;
|
|
419
641
|
}
|
|
642
|
+
markBackendHttpSuccess();
|
|
420
643
|
|
|
421
644
|
const tasks = await response.json();
|
|
422
645
|
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
@@ -448,6 +671,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
448
671
|
});
|
|
449
672
|
if (!patchResp.ok) {
|
|
450
673
|
logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
|
|
674
|
+
} else {
|
|
675
|
+
markBackendHttpSuccess();
|
|
451
676
|
}
|
|
452
677
|
}),
|
|
453
678
|
);
|
|
@@ -471,6 +696,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
471
696
|
logError(`Failed to reconcile tasks: HTTP ${response.status}`);
|
|
472
697
|
return;
|
|
473
698
|
}
|
|
699
|
+
markBackendHttpSuccess();
|
|
474
700
|
const tasks = await response.json();
|
|
475
701
|
if (!Array.isArray(tasks)) {
|
|
476
702
|
return;
|
|
@@ -500,6 +726,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
500
726
|
});
|
|
501
727
|
if (patchResp.ok) {
|
|
502
728
|
killedCount += 1;
|
|
729
|
+
markBackendHttpSuccess();
|
|
503
730
|
} else {
|
|
504
731
|
logError(`Failed to reconcile stale task ${taskId}: HTTP ${patchResp.status}`);
|
|
505
732
|
}
|
|
@@ -555,6 +782,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
555
782
|
}
|
|
556
783
|
|
|
557
784
|
function handleEvent(event) {
|
|
785
|
+
const receivedAt = Date.now();
|
|
786
|
+
lastInboundAt = receivedAt;
|
|
787
|
+
markWatchdogHealthy("inbound", receivedAt);
|
|
558
788
|
if (event.type === "error") {
|
|
559
789
|
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
|
560
790
|
const planLimitMessage = getPlanLimitMessage(payload);
|
|
@@ -584,6 +814,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
584
814
|
}
|
|
585
815
|
}
|
|
586
816
|
|
|
817
|
+
function markWatchdogHealthy(signal, at = Date.now()) {
|
|
818
|
+
if (!watchdogAwaitingHealthySignalAt || watchdogHealAttempts === 0) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (at < watchdogAwaitingHealthySignalAt) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
log(
|
|
825
|
+
`[watchdog] Backend websocket healthy again after self-heal via ${signal} (${formatDaemonHealthState({
|
|
826
|
+
connectedAt: lastConnectedAt,
|
|
827
|
+
lastPongAt,
|
|
828
|
+
lastInboundAt,
|
|
829
|
+
lastSuccessfulHttpAt,
|
|
830
|
+
lastPresenceConfirmedAt,
|
|
831
|
+
})})`,
|
|
832
|
+
);
|
|
833
|
+
watchdogAwaitingHealthySignalAt = null;
|
|
834
|
+
watchdogHealAttempts = 0;
|
|
835
|
+
}
|
|
836
|
+
|
|
587
837
|
async function handleCollectLogs(payload) {
|
|
588
838
|
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
589
839
|
const taskId = payload?.task_id ? String(payload.task_id).trim() : "";
|
|
@@ -1060,6 +1310,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1060
1310
|
}
|
|
1061
1311
|
|
|
1062
1312
|
closePromise = (async () => {
|
|
1313
|
+
daemonShuttingDown = true;
|
|
1314
|
+
if (watchdogTimer) {
|
|
1315
|
+
clearInterval(watchdogTimer);
|
|
1316
|
+
watchdogTimer = null;
|
|
1317
|
+
}
|
|
1063
1318
|
const activeEntries = [...activeTaskProcesses.entries()];
|
|
1064
1319
|
if (activeEntries.length > 0) {
|
|
1065
1320
|
log(`Shutdown requested (${reason}); stopping ${activeEntries.length} active task(s)`);
|
|
@@ -1174,6 +1429,82 @@ function parsePositiveInt(value, fallback) {
|
|
|
1174
1429
|
return fallback;
|
|
1175
1430
|
}
|
|
1176
1431
|
|
|
1432
|
+
function formatDisconnectDiagnostics(event) {
|
|
1433
|
+
const parts = [];
|
|
1434
|
+
const reason = typeof event?.reason === "string" && event.reason.trim()
|
|
1435
|
+
? event.reason.trim()
|
|
1436
|
+
: "unknown";
|
|
1437
|
+
parts.push(`reason=${reason}`);
|
|
1438
|
+
if (Number.isFinite(event?.closeCode)) {
|
|
1439
|
+
parts.push(`close_code=${event.closeCode}`);
|
|
1440
|
+
}
|
|
1441
|
+
if (typeof event?.closeReason === "string" && event.closeReason.trim()) {
|
|
1442
|
+
parts.push(`close_reason=${event.closeReason.trim()}`);
|
|
1443
|
+
}
|
|
1444
|
+
if (typeof event?.socketError === "string" && event.socketError.trim()) {
|
|
1445
|
+
parts.push(`socket_error=${event.socketError.trim()}`);
|
|
1446
|
+
}
|
|
1447
|
+
if (Number.isFinite(event?.missedPongs) && event.missedPongs > 0) {
|
|
1448
|
+
parts.push(`missed_pongs=${event.missedPongs}`);
|
|
1449
|
+
}
|
|
1450
|
+
if (Number.isFinite(event?.lastPingAt)) {
|
|
1451
|
+
parts.push(`last_ping_at=${formatIsoTimestamp(event.lastPingAt)}`);
|
|
1452
|
+
}
|
|
1453
|
+
if (Number.isFinite(event?.lastPongAt)) {
|
|
1454
|
+
parts.push(`last_pong_at=${formatIsoTimestamp(event.lastPongAt)}`);
|
|
1455
|
+
}
|
|
1456
|
+
if (Number.isFinite(event?.lastMessageAt)) {
|
|
1457
|
+
parts.push(`last_message_at=${formatIsoTimestamp(event.lastMessageAt)}`);
|
|
1458
|
+
}
|
|
1459
|
+
return parts.join(" ");
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function formatDaemonHealthState({
|
|
1463
|
+
connectedAt,
|
|
1464
|
+
lastPongAt,
|
|
1465
|
+
lastInboundAt,
|
|
1466
|
+
lastSuccessfulHttpAt,
|
|
1467
|
+
lastPresenceConfirmedAt,
|
|
1468
|
+
}) {
|
|
1469
|
+
return [
|
|
1470
|
+
`connected_at=${formatIsoTimestamp(connectedAt)}`,
|
|
1471
|
+
`last_pong_at=${formatIsoTimestamp(lastPongAt)}`,
|
|
1472
|
+
`last_inbound_at=${formatIsoTimestamp(lastInboundAt)}`,
|
|
1473
|
+
`last_http_ok_at=${formatIsoTimestamp(lastSuccessfulHttpAt)}`,
|
|
1474
|
+
`last_presence_at=${formatIsoTimestamp(lastPresenceConfirmedAt)}`,
|
|
1475
|
+
].join(" ");
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function formatWatchdogExtra(extra) {
|
|
1479
|
+
const parts = [];
|
|
1480
|
+
if (Number.isFinite(extra?.agentCount)) {
|
|
1481
|
+
parts.push(`agent_count=${extra.agentCount}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (Number.isFinite(extra?.probeStatus)) {
|
|
1484
|
+
parts.push(`probe_status=${extra.probeStatus}`);
|
|
1485
|
+
}
|
|
1486
|
+
if (Number.isFinite(extra?.probeAt)) {
|
|
1487
|
+
parts.push(`probe_at=${formatIsoTimestamp(extra.probeAt)}`);
|
|
1488
|
+
}
|
|
1489
|
+
if (typeof extra?.probeError === "string" && extra.probeError.trim()) {
|
|
1490
|
+
parts.push(`probe_error=${extra.probeError.trim()}`);
|
|
1491
|
+
}
|
|
1492
|
+
if (Number.isFinite(extra?.lastWsHealthAt)) {
|
|
1493
|
+
parts.push(`last_ws_health_at=${formatIsoTimestamp(extra.lastWsHealthAt)}`);
|
|
1494
|
+
}
|
|
1495
|
+
if (Number.isFinite(extra?.staleForMs)) {
|
|
1496
|
+
parts.push(`stale_for_ms=${extra.staleForMs}`);
|
|
1497
|
+
}
|
|
1498
|
+
return parts.length ? parts.join(" ") : "no-extra-diagnostics";
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function formatIsoTimestamp(value) {
|
|
1502
|
+
if (!Number.isFinite(value)) {
|
|
1503
|
+
return "never";
|
|
1504
|
+
}
|
|
1505
|
+
return new Date(value).toISOString();
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1177
1508
|
async function withTimeout(promise, timeoutMs, label) {
|
|
1178
1509
|
let timer = null;
|
|
1179
1510
|
const timeoutPromise = new Promise((_, reject) => {
|