@love-moon/conductor-cli 0.12.0 → 0.13.1
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/CHANGELOG.md +39 -0
- package/bin/conductor-fire.js +177 -9
- package/bin/conductor-remote.js +6 -1
- package/bin/conductor.js +1 -1
- package/package.json +5 -5
- package/src/daemon.js +155 -4
- package/src/fire-session-registry.js +19 -0
- package/src/remote/client.js +9 -3
- package/src/remote/exec.js +132 -61
- package/src/remote/wait.js +133 -0
- package/src/serve-ai/index.js +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3948125: Support persistent task rounds (RFC 0039). A `create_task` carrying
|
|
8
|
+
`replace_existing_fire: true` releases the task's previous fire before starting
|
|
9
|
+
the new round instead of ignoring it as a duplicate: tmux sessions are probed
|
|
10
|
+
(and killed if still alive), child processes are stopped and awaited, the old
|
|
11
|
+
fire's terminal status is suppressed, and its undelivered KILLED/COMPLETED
|
|
12
|
+
events are purged from the reused directory. The tmux liveness reaper also stops
|
|
13
|
+
reporting a dead session for a task whose new round is still starting. The
|
|
14
|
+
daemon advertises this as the `persistent_round_v1` capability.
|
|
15
|
+
- @love-moon/conductor-sdk@0.13.1
|
|
16
|
+
- @love-moon/ai-sdk@0.13.1
|
|
17
|
+
|
|
18
|
+
## 0.13.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- 8edbed5: `conductor remote exec` is safer to drive from an agent, and gains a `wait` verb.
|
|
23
|
+
|
|
24
|
+
- New `conductor remote wait -t <daemon> <runId>` re-attaches to a command that
|
|
25
|
+
`exec` left running past its `--timeout`, polling and reporting it exactly as
|
|
26
|
+
`exec` would have. `exec` now prints that command in its "still running" hint
|
|
27
|
+
instead of a raw API path.
|
|
28
|
+
- `exec` retries the initial request on `429 too many concurrent remote exec
|
|
29
|
+
requests` with backoff (up to ~15 s), so a burst of parallel short commands no
|
|
30
|
+
longer fails outright. Other errors are still not retried: a 5xx may already
|
|
31
|
+
have started the command.
|
|
32
|
+
- `exec` and `wait` handle SIGINT/SIGTERM: the run id is printed before exiting,
|
|
33
|
+
and with `--kill-on-timeout` the remote command is cancelled as well. Before,
|
|
34
|
+
a tool timeout that killed the CLI silently orphaned the remote process.
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [80ab13e]
|
|
39
|
+
- @love-moon/conductor-sdk@0.13.0
|
|
40
|
+
- @love-moon/ai-sdk@0.13.0
|
|
41
|
+
|
|
3
42
|
## 0.12.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/bin/conductor-fire.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../src/fire/resume.js";
|
|
29
29
|
import {
|
|
30
30
|
filterRuntimeSupportedAllowCliList,
|
|
31
|
+
inferBuiltInRuntimeBackendFromCommand,
|
|
31
32
|
isBuiltInRuntimeBackend,
|
|
32
33
|
isCommandOptionalBuiltInRuntimeBackend,
|
|
33
34
|
listAdvertisedBackends,
|
|
@@ -218,7 +219,22 @@ export function resolveAiSessionCommandLine(backend, allowCliList, env = process
|
|
|
218
219
|
const daemonCommand =
|
|
219
220
|
typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
|
|
220
221
|
|
|
221
|
-
|
|
222
|
+
// CONDUCTOR_CLI_COMMAND may be stale: a tmux-mode Fire inherits the tmux
|
|
223
|
+
// SERVER's global environment for variables the daemon did not explicitly
|
|
224
|
+
// override, so a value left by another backend's task (e.g. `claude --model
|
|
225
|
+
// opus` reaching a dsh task) can leak in. Only honor the fallback when the
|
|
226
|
+
// command does not provably belong to a different built-in backend. Commands
|
|
227
|
+
// whose executable is not a known backend name (custom wrappers) are kept.
|
|
228
|
+
const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
|
|
229
|
+
const usableDaemonCommand =
|
|
230
|
+
daemonCommand &&
|
|
231
|
+
(!inferredDaemonBackend ||
|
|
232
|
+
inferredDaemonBackend === normalizedSessionBackend ||
|
|
233
|
+
inferredDaemonBackend === normalizedBackend)
|
|
234
|
+
? daemonCommand
|
|
235
|
+
: "";
|
|
236
|
+
|
|
237
|
+
const resolvedCommand = configuredCommand || usableDaemonCommand;
|
|
222
238
|
if (!resolvedCommand) {
|
|
223
239
|
return "";
|
|
224
240
|
}
|
|
@@ -246,6 +262,12 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
|
|
|
246
262
|
const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
|
|
247
263
|
const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
|
|
248
264
|
const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
|
|
265
|
+
// A running turn that has sent no runtime status for this long reports the
|
|
266
|
+
// provider's current tool so the app does not mistake a long tool for a hang.
|
|
267
|
+
const DEFAULT_RUNTIME_HEARTBEAT_MS = 60 * 1000;
|
|
268
|
+
const RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS = 5000;
|
|
269
|
+
// Keep the line within the web chat status pill.
|
|
270
|
+
const RUNTIME_HEARTBEAT_LINE_MAX_CHARS = 100;
|
|
249
271
|
// Runtime backend tokens (first word of the resolved command line) that mean
|
|
250
272
|
// "this task drives a chat-web Chromium browser". Mirrors ai-sdk's chat-web
|
|
251
273
|
// aliases; user-facing aliases like `web-chatgpt` resolve to one of these.
|
|
@@ -738,6 +760,11 @@ async function main() {
|
|
|
738
760
|
return await pendingRemoteInterruptQueue.enqueue(event);
|
|
739
761
|
};
|
|
740
762
|
|
|
763
|
+
const handleReportRuntimeStatusCommand = async (event) => {
|
|
764
|
+
fireWatchdog.onInbound();
|
|
765
|
+
await reconnectRunner?.requestRuntimeStatusFromRemote?.(event || {});
|
|
766
|
+
};
|
|
767
|
+
|
|
741
768
|
const rememberCompletedRefreshSessionRequest = (requestId, accepted) => {
|
|
742
769
|
if (!requestId) {
|
|
743
770
|
return accepted;
|
|
@@ -828,6 +855,7 @@ async function main() {
|
|
|
828
855
|
onStopTask: handleStopTaskCommand,
|
|
829
856
|
onInterruptTurn: handleInterruptTurnCommand,
|
|
830
857
|
onRefreshSession: handleRefreshSessionCommand,
|
|
858
|
+
onReportRuntimeStatus: handleReportRuntimeStatusCommand,
|
|
831
859
|
});
|
|
832
860
|
|
|
833
861
|
const taskContext = await ensureTaskContext(conductor, {
|
|
@@ -1806,15 +1834,28 @@ async function resolveDefaultProjectId(conductor) {
|
|
|
1806
1834
|
}
|
|
1807
1835
|
}
|
|
1808
1836
|
|
|
1809
|
-
|
|
1837
|
+
// Keep in sync with web/src/features/tasks/utils/default-task-title.ts: first clause,
|
|
1838
|
+
// capped at 20 display columns (CJK/full-width/emoji = 2) so it fits the mobile header.
|
|
1839
|
+
export function deriveTaskTitle(prompt, explicit, backend = "codex") {
|
|
1810
1840
|
if (explicit && explicit.trim()) {
|
|
1811
1841
|
return explicit.trim();
|
|
1812
1842
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1843
|
+
const text = (prompt || "").trim();
|
|
1844
|
+
const breakIndex = text.search(/[,。!?;\n]|[,.!?;](?=\s|$)/);
|
|
1845
|
+
const clause = (breakIndex > 0 ? text.slice(0, breakIndex) : text).replace(/\s+/g, " ");
|
|
1846
|
+
let title = "";
|
|
1847
|
+
let width = 0;
|
|
1848
|
+
for (const char of clause) {
|
|
1849
|
+
width += char.codePointAt(0) >= 0x2e80 ? 2 : 1;
|
|
1850
|
+
if (width > 20) {
|
|
1851
|
+
// Never cut an English word in half; drop the partial word unless it is the only one.
|
|
1852
|
+
if (/\w$/.test(title) && /\w/.test(char)) title = title.replace(/\s*\w+$/, "") || title;
|
|
1853
|
+
break;
|
|
1817
1854
|
}
|
|
1855
|
+
title += char;
|
|
1856
|
+
}
|
|
1857
|
+
if (title.trim()) {
|
|
1858
|
+
return title.trim();
|
|
1818
1859
|
}
|
|
1819
1860
|
const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
|
|
1820
1861
|
return `${backendName} Task`;
|
|
@@ -1917,6 +1958,14 @@ function isTruthyEnv(value) {
|
|
|
1917
1958
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1918
1959
|
}
|
|
1919
1960
|
|
|
1961
|
+
function formatElapsedShort(ms) {
|
|
1962
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
1963
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1964
|
+
const minutes = Math.floor(seconds / 60);
|
|
1965
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1966
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1920
1969
|
function isSessionClosedError(error) {
|
|
1921
1970
|
return Boolean(error && typeof error === "object" && error.reason === "session_closed");
|
|
1922
1971
|
}
|
|
@@ -2018,6 +2067,14 @@ export class BridgeRunner {
|
|
|
2018
2067
|
500,
|
|
2019
2068
|
60 * 1000,
|
|
2020
2069
|
);
|
|
2070
|
+
this.runtimeHeartbeatMs = getBoundedEnvInt(
|
|
2071
|
+
"CONDUCTOR_RUNTIME_HEARTBEAT_MS",
|
|
2072
|
+
DEFAULT_RUNTIME_HEARTBEAT_MS,
|
|
2073
|
+
1000,
|
|
2074
|
+
10 * 60 * 1000,
|
|
2075
|
+
);
|
|
2076
|
+
this.runtimeHeartbeatTimer = null;
|
|
2077
|
+
this.turnStartedAt = 0;
|
|
2021
2078
|
this.useSessionFileReplyStream =
|
|
2022
2079
|
Boolean(this.backendSession) &&
|
|
2023
2080
|
typeof this.backendSession.usesSessionFileReplyStream === "function" &&
|
|
@@ -2853,7 +2910,7 @@ export class BridgeRunner {
|
|
|
2853
2910
|
};
|
|
2854
2911
|
}
|
|
2855
2912
|
|
|
2856
|
-
async reportRuntimeStatus(payload, replyTo) {
|
|
2913
|
+
async reportRuntimeStatus(payload, replyTo, { force = false } = {}) {
|
|
2857
2914
|
const runtimeContext = await this.resolveRuntimeContext();
|
|
2858
2915
|
const runtime = this.createRuntimeStatus(payload, replyTo, runtimeContext);
|
|
2859
2916
|
if (!runtime) {
|
|
@@ -2861,10 +2918,14 @@ export class BridgeRunner {
|
|
|
2861
2918
|
}
|
|
2862
2919
|
|
|
2863
2920
|
const signature = JSON.stringify(runtime);
|
|
2864
|
-
if (signature === this.lastRuntimeStatusSignature) {
|
|
2921
|
+
if (!force && signature === this.lastRuntimeStatusSignature) {
|
|
2865
2922
|
return;
|
|
2866
2923
|
}
|
|
2867
|
-
|
|
2924
|
+
// Heartbeats keep the provider's last signature, so its unchanged periodic
|
|
2925
|
+
// status (e.g. claude tool_progress) does not replace the heartbeat line.
|
|
2926
|
+
if (!force) {
|
|
2927
|
+
this.lastRuntimeStatusSignature = signature;
|
|
2928
|
+
}
|
|
2868
2929
|
this.lastRuntimeStatusPayload = {
|
|
2869
2930
|
...runtime,
|
|
2870
2931
|
};
|
|
@@ -2882,6 +2943,109 @@ export class BridgeRunner {
|
|
|
2882
2943
|
} catch (error) {
|
|
2883
2944
|
log(`Failed to report runtime status: ${error?.message || error}`);
|
|
2884
2945
|
}
|
|
2946
|
+
this.scheduleRuntimeHeartbeat();
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
/** (Re)start the silence timer; a no-op that just clears it outside a turn. */
|
|
2950
|
+
scheduleRuntimeHeartbeat() {
|
|
2951
|
+
clearTimeout(this.runtimeHeartbeatTimer);
|
|
2952
|
+
this.runtimeHeartbeatTimer = null;
|
|
2953
|
+
if (!this.runningTurn || this.stopped) {
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
this.runtimeHeartbeatTimer = setTimeout(() => {
|
|
2957
|
+
this.runtimeHeartbeatTimer = null;
|
|
2958
|
+
void this.reportRuntimeHeartbeat();
|
|
2959
|
+
}, this.runtimeHeartbeatMs);
|
|
2960
|
+
this.runtimeHeartbeatTimer.unref?.();
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
async fetchBackendTurnStatus() {
|
|
2964
|
+
const session = this.backendSession;
|
|
2965
|
+
if (typeof session?.fetchCurrentTurnStatus === "function") {
|
|
2966
|
+
let timer = null;
|
|
2967
|
+
try {
|
|
2968
|
+
return await Promise.race([
|
|
2969
|
+
session.fetchCurrentTurnStatus(),
|
|
2970
|
+
new Promise((_, reject) => {
|
|
2971
|
+
timer = setTimeout(() => reject(new Error("turn status query timed out")), RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS);
|
|
2972
|
+
}),
|
|
2973
|
+
]);
|
|
2974
|
+
} catch {
|
|
2975
|
+
// fall back to the last status the session pushed
|
|
2976
|
+
} finally {
|
|
2977
|
+
clearTimeout(timer);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
try {
|
|
2981
|
+
return session?.getCurrentTurnStatus?.() ?? null;
|
|
2982
|
+
} catch {
|
|
2983
|
+
return null;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
formatRuntimeHeartbeatLine(status, now = Date.now()) {
|
|
2988
|
+
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
2989
|
+
const tool = status?.active_tool;
|
|
2990
|
+
const startedAt = Date.parse(tool?.started_at || "") || this.turnStartedAt || now;
|
|
2991
|
+
const elapsed = formatElapsedShort(now - startedAt);
|
|
2992
|
+
// Elapsed time goes before the tool input so it survives truncation.
|
|
2993
|
+
if (tool?.name) {
|
|
2994
|
+
const summary = tool.summary ? `: ${tool.summary}` : "";
|
|
2995
|
+
return clip(`${this.backendName} running ${tool.name} (${elapsed})${summary}`, RUNTIME_HEARTBEAT_LINE_MAX_CHARS);
|
|
2996
|
+
}
|
|
2997
|
+
const base = status?.status_line || `${this.backendName} is working`;
|
|
2998
|
+
return `${clip(base, RUNTIME_HEARTBEAT_LINE_MAX_CHARS - elapsed.length - 3)} (${elapsed})`;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
async reportRuntimeHeartbeat() {
|
|
3002
|
+
if (!this.runningTurn || this.stopped) {
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
const lastSent = this.lastRuntimeStatusPayload;
|
|
3006
|
+
const status = await this.fetchBackendTurnStatus();
|
|
3007
|
+
// Skip if the turn ended, the provider settled it, or a newer frame (e.g.
|
|
3008
|
+
// the terminal one) went out while querying: never re-open a settled turn.
|
|
3009
|
+
if (
|
|
3010
|
+
!this.runningTurn ||
|
|
3011
|
+
this.stopped ||
|
|
3012
|
+
status?.reply_in_progress === false ||
|
|
3013
|
+
this.lastRuntimeStatusPayload !== lastSent
|
|
3014
|
+
) {
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
await this.reportRuntimeStatus(
|
|
3018
|
+
{
|
|
3019
|
+
state: status?.state,
|
|
3020
|
+
phase: status?.phase || "heartbeat",
|
|
3021
|
+
reply_in_progress: true,
|
|
3022
|
+
status_line: this.formatRuntimeHeartbeatLine(status),
|
|
3023
|
+
},
|
|
3024
|
+
status?.replyTo || this.activeTurnReplyTo,
|
|
3025
|
+
// Forced so an unchanged line still goes out and re-arms the timer.
|
|
3026
|
+
{ force: true },
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
/** App asked (page load / stale composer) for the current runtime status. */
|
|
3031
|
+
async requestRuntimeStatusFromRemote(event = {}) {
|
|
3032
|
+
const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
|
|
3033
|
+
if (taskId && taskId !== this.taskId) {
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
if (this.runningTurn) {
|
|
3037
|
+
await this.reportRuntimeHeartbeat();
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
if (this.lastRuntimeStatusPayload?.reply_in_progress) {
|
|
3041
|
+
// No turn is running, so an in-progress frame is stale; settle it.
|
|
3042
|
+
this.lastRuntimeStatusPayload = {
|
|
3043
|
+
...this.lastRuntimeStatusPayload,
|
|
3044
|
+
reply_in_progress: false,
|
|
3045
|
+
status_line: undefined,
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
await this.replayLastRuntimeStatus();
|
|
2885
3049
|
}
|
|
2886
3050
|
|
|
2887
3051
|
async replayLastRuntimeStatus() {
|
|
@@ -3134,6 +3298,7 @@ export class BridgeRunner {
|
|
|
3134
3298
|
this.runningTurn = true;
|
|
3135
3299
|
this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
|
|
3136
3300
|
const turnStartedAt = Date.now();
|
|
3301
|
+
this.turnStartedAt = turnStartedAt;
|
|
3137
3302
|
let turnWatchdog = null;
|
|
3138
3303
|
if (this.isCopilotBackend) {
|
|
3139
3304
|
turnWatchdog = setInterval(() => {
|
|
@@ -3316,6 +3481,7 @@ export class BridgeRunner {
|
|
|
3316
3481
|
`turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
|
|
3317
3482
|
);
|
|
3318
3483
|
this.runningTurn = false;
|
|
3484
|
+
this.scheduleRuntimeHeartbeat();
|
|
3319
3485
|
}
|
|
3320
3486
|
}
|
|
3321
3487
|
|
|
@@ -3442,6 +3608,7 @@ export class BridgeRunner {
|
|
|
3442
3608
|
this.lastRuntimeStatusSignature = null;
|
|
3443
3609
|
this.runningTurn = true;
|
|
3444
3610
|
const startedAt = Date.now();
|
|
3611
|
+
this.turnStartedAt = startedAt;
|
|
3445
3612
|
if (
|
|
3446
3613
|
this.useSessionFileReplyStream &&
|
|
3447
3614
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
@@ -3523,6 +3690,7 @@ export class BridgeRunner {
|
|
|
3523
3690
|
} finally {
|
|
3524
3691
|
this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
|
|
3525
3692
|
this.runningTurn = false;
|
|
3693
|
+
this.scheduleRuntimeHeartbeat();
|
|
3526
3694
|
}
|
|
3527
3695
|
}
|
|
3528
3696
|
|
package/bin/conductor-remote.js
CHANGED
|
@@ -19,12 +19,14 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { EXIT } from "../src/remote/client.js";
|
|
20
20
|
import { runRemoteExec, showHelp as showExecHelp } from "../src/remote/exec.js";
|
|
21
21
|
import { runRemoteCp, showHelp as showCpHelp } from "../src/remote/cp.js";
|
|
22
|
+
import { runRemoteWait, showHelp as showWaitHelp } from "../src/remote/wait.js";
|
|
22
23
|
|
|
23
24
|
export { EXIT };
|
|
24
25
|
|
|
25
26
|
const VERBS = new Map([
|
|
26
27
|
["exec", runRemoteExec],
|
|
27
28
|
["cp", runRemoteCp],
|
|
29
|
+
["wait", runRemoteWait],
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
const isMainModule = (() => {
|
|
@@ -42,6 +44,7 @@ Usage:
|
|
|
42
44
|
Verbs:
|
|
43
45
|
exec Run a command on another daemon's host
|
|
44
46
|
cp Copy a file to or from another daemon's host
|
|
47
|
+
wait Wait for a command that \`exec\` left running (by run id)
|
|
45
48
|
|
|
46
49
|
Options:
|
|
47
50
|
-h, --help Show this help
|
|
@@ -50,10 +53,12 @@ Examples:
|
|
|
50
53
|
conductor remote exec --target ubuntu --workspace /srv/app -- git log --oneline -5
|
|
51
54
|
conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
|
|
52
55
|
conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
|
|
56
|
+
conductor remote wait --target ubuntu <runId>
|
|
53
57
|
|
|
54
58
|
For verb-specific help:
|
|
55
59
|
conductor remote exec --help
|
|
56
60
|
conductor remote cp --help
|
|
61
|
+
conductor remote wait --help
|
|
57
62
|
`);
|
|
58
63
|
}
|
|
59
64
|
|
|
@@ -84,7 +89,7 @@ export async function runRemote(argv, deps = {}) {
|
|
|
84
89
|
return handler(argv.slice(1), deps);
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
export { showExecHelp, showCpHelp };
|
|
92
|
+
export { showExecHelp, showCpHelp, showWaitHelp };
|
|
88
93
|
|
|
89
94
|
if (isMainModule) {
|
|
90
95
|
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
package/bin/conductor.js
CHANGED
|
@@ -137,7 +137,7 @@ Subcommands:
|
|
|
137
137
|
project Manage Conductor projects (list/show/create/...)
|
|
138
138
|
issue Manage issues (list/show/create/update/start/done)
|
|
139
139
|
task Manage tasks (create/list/show/send/messages/schedule)
|
|
140
|
-
remote Run commands and copy files on another daemon's host (exec/cp)
|
|
140
|
+
remote Run commands and copy files on another daemon's host (exec/cp/wait)
|
|
141
141
|
|
|
142
142
|
Options:
|
|
143
143
|
-h, --help Show this help message
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.13.1",
|
|
4
|
+
"gitCommitId": "b82ba1c",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test test/*.test.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@love-moon/ai-sdk": "0.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.
|
|
27
|
+
"@love-moon/ai-sdk": "0.13.1",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.13.1",
|
|
29
29
|
"@github/copilot-sdk": "^0.3.0",
|
|
30
30
|
"chrome-launcher": "^1.2.1",
|
|
31
31
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@roamhq/wrtc": "^0.10.0",
|
|
41
|
-
"@love-moon/chat-web": "0.
|
|
41
|
+
"@love-moon/chat-web": "0.13.1"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
package/src/daemon.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "./conductor-paths.js";
|
|
27
27
|
import {
|
|
28
28
|
deleteFireSessionRecord,
|
|
29
|
+
listFireSessionTaskIds,
|
|
29
30
|
pruneFireSessionRecords,
|
|
30
31
|
readFireSessionRecord,
|
|
31
32
|
resolveFireSessionRegistryDir,
|
|
@@ -890,6 +891,27 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
|
|
|
890
891
|
"CONDUCTOR_RESUME_CWD",
|
|
891
892
|
];
|
|
892
893
|
|
|
894
|
+
// Task-scoped env vars a Fire must NEVER inherit from anywhere but this
|
|
895
|
+
// daemon's explicit choice. In tmux mode the new session merges the tmux
|
|
896
|
+
// SERVER's global environment (inherited from whatever task first started the
|
|
897
|
+
// server), so a variable the daemon sets only conditionally — e.g.
|
|
898
|
+
// CONDUCTOR_CLI_COMMAND, which command-optional backends (dsh/copilot) leave
|
|
899
|
+
// unset — would leak a stale value from an earlier task (2026-09-17: a dsh
|
|
900
|
+
// task inherited `claude --model opus` and forwarded model=opus to the
|
|
901
|
+
// DeepSeek API).
|
|
902
|
+
//
|
|
903
|
+
// The invariant is enforced at the single choke point `spawnFireProcess`:
|
|
904
|
+
// every key in this list is passed to `tmux new-session` explicitly — either
|
|
905
|
+
// with the daemon's value or cleared as `-e KEY=` (empty is our "unset"
|
|
906
|
+
// convention; every consumer trims/negates before use). RULE: any new
|
|
907
|
+
// task-scoped env var added to a Fire spawn must be registered here.
|
|
908
|
+
const FIRE_TASK_SCOPED_ENV_KEYS = [
|
|
909
|
+
...PTY_TASK_SCOPED_ENV_KEYS,
|
|
910
|
+
"CONDUCTOR_AGENT_TOKEN",
|
|
911
|
+
"CONDUCTOR_BACKEND_URL",
|
|
912
|
+
"CONDUCTOR_DAEMON_NAME",
|
|
913
|
+
];
|
|
914
|
+
|
|
893
915
|
function stripPtyTaskScopedEnv(source) {
|
|
894
916
|
const env = {
|
|
895
917
|
...(source && typeof source === "object" ? source : {}),
|
|
@@ -1691,6 +1713,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1691
1713
|
tmuxEnvFlags.push("-e", `${key}=${stringValue}`);
|
|
1692
1714
|
}
|
|
1693
1715
|
|
|
1716
|
+
// Clear every task-scoped variable the daemon did NOT set for this task,
|
|
1717
|
+
// or the session would inherit a stale value from the tmux server's
|
|
1718
|
+
// global environment (see FIRE_TASK_SCOPED_ENV_KEYS). `-e KEY=` (empty)
|
|
1719
|
+
// works on old tmux versions that lack the `-e KEY` unset form, and all
|
|
1720
|
+
// our consumers treat "" as unset.
|
|
1721
|
+
for (const scopedKey of FIRE_TASK_SCOPED_ENV_KEYS) {
|
|
1722
|
+
if (env[scopedKey] === undefined || env[scopedKey] === null) {
|
|
1723
|
+
tmuxEnvFlags.push("-e", `${scopedKey}=`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1694
1727
|
// Use a non-login `bash -c` here: node and the CLI script are passed by
|
|
1695
1728
|
// absolute path, so we don't need PATH from a login shell, and `-l`
|
|
1696
1729
|
// would slow startup and could leak unexpected stderr from user shell
|
|
@@ -2000,7 +2033,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2000
2033
|
// *live* replacement as killed, using the previous run's log tail. Worse,
|
|
2001
2034
|
// the daemon's own map would say running, so reconcile would never repair
|
|
2002
2035
|
// it. Re-check that the task is still absent before speaking.
|
|
2003
|
-
if (activeTaskProcesses.has(taskId)) {
|
|
2036
|
+
if (activeTaskProcesses.has(taskId) || pendingTaskStarts.has(taskId)) {
|
|
2004
2037
|
log(
|
|
2005
2038
|
`Task ${taskId} was restarted while its dead tmux session was being reported; dropping the stale terminal status`,
|
|
2006
2039
|
);
|
|
@@ -2306,6 +2339,30 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2306
2339
|
return FIRE_TMUX_MODE_ENABLED && !FIRE_TMUX_MODE_ACTIVE && isTmuxAvailable();
|
|
2307
2340
|
}
|
|
2308
2341
|
|
|
2342
|
+
// The third door into the same mass kill: `tmux` genuinely absent from PATH.
|
|
2343
|
+
// A daemon restarted by launchd/systemd/cron — or during a `brew upgrade
|
|
2344
|
+
// tmux` — logs the direct-spawn fallback and then cannot enumerate sessions
|
|
2345
|
+
// by any means, so the guard above (which needs tmux to answer) declines and
|
|
2346
|
+
// every Fire the predecessor deliberately left running gets killed.
|
|
2347
|
+
//
|
|
2348
|
+
// The hand-off records on disk are the remaining evidence: they name exactly
|
|
2349
|
+
// the tasks whose liveness this process is unable to answer for. Scoped to
|
|
2350
|
+
// those rather than skipping the sweep wholesale, because a host that simply
|
|
2351
|
+
// never installs tmux spawns its Fires directly and those DO die with their
|
|
2352
|
+
// daemon — blanket-skipping would strand them at `running` with nobody left
|
|
2353
|
+
// to report their death, which is worse than killing them. Nothing prunes
|
|
2354
|
+
// the registry in this state (the prune needs a conclusive listing), so a
|
|
2355
|
+
// later start with a healthy tmux still adopts or reaps them properly.
|
|
2356
|
+
function taskIdsWithUnverifiableTmuxFire() {
|
|
2357
|
+
if (!FIRE_TMUX_MODE_ENABLED || FIRE_TMUX_MODE_ACTIVE) return new Set();
|
|
2358
|
+
try {
|
|
2359
|
+
return listFireSessionTaskIds(FIRE_SESSION_REGISTRY_DIR);
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
logError(`Failed to read tmux hand-off records: ${error?.message || error}`);
|
|
2362
|
+
return new Set();
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2309
2366
|
// Sessions already identified as orphaned shells and killed. `tmux
|
|
2310
2367
|
// kill-session` is asynchronous, so a sweep running moments later still
|
|
2311
2368
|
// sees the name in `list-sessions` — and by then the hand-off record is
|
|
@@ -3790,6 +3847,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3790
3847
|
"project_agents_registry",
|
|
3791
3848
|
"restart_daemon",
|
|
3792
3849
|
"refresh_session_inplace",
|
|
3850
|
+
"persistent_round_v1",
|
|
3793
3851
|
"task_attachments_v1",
|
|
3794
3852
|
"backend_session_list",
|
|
3795
3853
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
@@ -4856,12 +4914,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4856
4914
|
);
|
|
4857
4915
|
return;
|
|
4858
4916
|
}
|
|
4917
|
+
const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
|
|
4918
|
+
const skippedUnverifiable = [];
|
|
4859
4919
|
const deadTasks = staleTasks.filter((task) => {
|
|
4920
|
+
const taskId = String(task?.id || "");
|
|
4860
4921
|
// A spawn already in flight has no record yet either; killing it
|
|
4861
4922
|
// would shoot down a Fire this daemon is in the middle of starting.
|
|
4862
|
-
if (pendingTaskStarts.has(
|
|
4923
|
+
if (pendingTaskStarts.has(taskId)) return false;
|
|
4924
|
+
if (unverifiableTmuxTaskIds.has(taskId)) {
|
|
4925
|
+
skippedUnverifiable.push(taskId);
|
|
4926
|
+
return false;
|
|
4927
|
+
}
|
|
4863
4928
|
return !adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || []);
|
|
4864
4929
|
});
|
|
4930
|
+
if (skippedUnverifiable.length) {
|
|
4931
|
+
logError(
|
|
4932
|
+
`tmux is not on PATH, so the Fires a previous daemon left for ${skippedUnverifiable.length} ` +
|
|
4933
|
+
`task(s) cannot be checked; skipping stale recovery for them rather than killing what this ` +
|
|
4934
|
+
`daemon cannot see. Restart with tmux available to adopt or reap them.`,
|
|
4935
|
+
);
|
|
4936
|
+
}
|
|
4865
4937
|
|
|
4866
4938
|
if (deadTasks.length === 0) {
|
|
4867
4939
|
return;
|
|
@@ -4960,12 +5032,23 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4960
5032
|
);
|
|
4961
5033
|
return;
|
|
4962
5034
|
}
|
|
5035
|
+
const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
|
|
4963
5036
|
for (const task of assigned) {
|
|
4964
5037
|
const taskId = String(task?.id || "");
|
|
4965
5038
|
if (!taskId) continue;
|
|
4966
5039
|
if (localTaskIds.has(taskId) || pendingTaskStarts.has(taskId)) {
|
|
4967
5040
|
continue;
|
|
4968
5041
|
}
|
|
5042
|
+
// No tmux binary to ask, but a predecessor filed a hand-off record for
|
|
5043
|
+
// this task: we cannot see its Fire, which is not the same as it being
|
|
5044
|
+
// dead. See taskIdsWithUnverifiableTmuxFire.
|
|
5045
|
+
if (unverifiableTmuxTaskIds.has(taskId)) {
|
|
5046
|
+
logError(
|
|
5047
|
+
`Task ${taskId} has a tmux hand-off record but tmux is not on PATH; skipping reconcile ` +
|
|
5048
|
+
`rather than killing a Fire this daemon cannot see`,
|
|
5049
|
+
);
|
|
5050
|
+
continue;
|
|
5051
|
+
}
|
|
4969
5052
|
// Adopts on the spot rather than merely skipping: a survivor that
|
|
4970
5053
|
// dodges the kill but gains no watcher would sit at `running`
|
|
4971
5054
|
// forever, because nothing else reports a tmux Fire's death.
|
|
@@ -6897,6 +6980,42 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6897
6980
|
return true;
|
|
6898
6981
|
}
|
|
6899
6982
|
|
|
6983
|
+
// RFC 0039: make sure the previous round's fire is gone before a new round
|
|
6984
|
+
// reuses its task id. The server has normally stopped it already, so what is
|
|
6985
|
+
// left is a still-exiting child or a tmux record the liveness reaper has not
|
|
6986
|
+
// swept yet; a fire that is somehow still alive is stopped here. Its terminal
|
|
6987
|
+
// status is suppressed because the task row now belongs to the new round.
|
|
6988
|
+
async function releaseReplacedFire(taskId) {
|
|
6989
|
+
const record = activeTaskProcesses.get(taskId);
|
|
6990
|
+
if (!recordHasLiveFire(record)) {
|
|
6991
|
+
return true;
|
|
6992
|
+
}
|
|
6993
|
+
if (record.tmuxMode) {
|
|
6994
|
+
const { alive, conclusive } = await probeTmuxSession(record.tmuxSession);
|
|
6995
|
+
if (activeTaskProcesses.get(taskId) !== record) {
|
|
6996
|
+
return !recordHasLiveFire(activeTaskProcesses.get(taskId));
|
|
6997
|
+
}
|
|
6998
|
+
if (!conclusive) {
|
|
6999
|
+
return false;
|
|
7000
|
+
}
|
|
7001
|
+
if (!alive) {
|
|
7002
|
+
activeTaskProcesses.delete(taskId);
|
|
7003
|
+
forgetFireSessionRecord(record);
|
|
7004
|
+
return true;
|
|
7005
|
+
}
|
|
7006
|
+
stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
|
|
7007
|
+
// The tmux stop path retires the record synchronously and never consumes the flag.
|
|
7008
|
+
suppressedExitStatusReports.delete(taskId);
|
|
7009
|
+
return !activeTaskProcesses.has(taskId);
|
|
7010
|
+
}
|
|
7011
|
+
stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
|
|
7012
|
+
const stopped = await waitForTaskToStop(taskId);
|
|
7013
|
+
if (!stopped) {
|
|
7014
|
+
suppressedExitStatusReports.delete(taskId);
|
|
7015
|
+
}
|
|
7016
|
+
return stopped;
|
|
7017
|
+
}
|
|
7018
|
+
|
|
6900
7019
|
async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
|
|
6901
7020
|
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
6902
7021
|
|
|
@@ -7487,6 +7606,23 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7487
7606
|
return;
|
|
7488
7607
|
}
|
|
7489
7608
|
|
|
7609
|
+
// RFC 0039: a persistent task's new round reuses the task id. Release the
|
|
7610
|
+
// previous round's fire first instead of swallowing the round as a duplicate.
|
|
7611
|
+
const replacesPreviousRound = payload?.replace_existing_fire === true;
|
|
7612
|
+
if (
|
|
7613
|
+
replacesPreviousRound &&
|
|
7614
|
+
!pendingTaskStarts.has(taskId) &&
|
|
7615
|
+
!(await releaseReplacedFire(taskId))
|
|
7616
|
+
) {
|
|
7617
|
+
reportCreateTaskFailure({
|
|
7618
|
+
taskId,
|
|
7619
|
+
projectId,
|
|
7620
|
+
requestId,
|
|
7621
|
+
error: new Error("the previous round is still running on this daemon"),
|
|
7622
|
+
});
|
|
7623
|
+
return;
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7490
7626
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
7491
7627
|
if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
|
|
7492
7628
|
log(
|
|
@@ -7629,7 +7765,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7629
7765
|
// even when the daemon was named through the config file.
|
|
7630
7766
|
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
7631
7767
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
7632
|
-
|
|
7768
|
+
// Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
|
|
7769
|
+
// gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
|
|
7770
|
+
// value in the tmux SERVER's global environment (inherited from whatever
|
|
7771
|
+
// task first started the server). Without the override, a commandless
|
|
7772
|
+
// backend (dsh/copilot) would see e.g. `claude --model opus` here and
|
|
7773
|
+
// mis-apply its flags. Fire treats "" as unset.
|
|
7774
|
+
CONDUCTOR_CLI_COMMAND: cliCommand,
|
|
7633
7775
|
};
|
|
7634
7776
|
if (AGENT_TOKEN) {
|
|
7635
7777
|
env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
|
|
@@ -7638,6 +7780,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7638
7780
|
env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
|
|
7639
7781
|
}
|
|
7640
7782
|
|
|
7783
|
+
// A new round may reuse the previous round's directory, and with it that
|
|
7784
|
+
// fire's undelivered KILLED/COMPLETED event (see restart below).
|
|
7785
|
+
if (replacesPreviousRound) {
|
|
7786
|
+
dropSupersededTerminalStatusEvents(taskDir, taskId);
|
|
7787
|
+
}
|
|
7788
|
+
|
|
7641
7789
|
// Sampled before the spawn so it is a true "everything after this is
|
|
7642
7790
|
// ours" watermark.
|
|
7643
7791
|
const logStartOffset = readLogSize(logPath);
|
|
@@ -8372,7 +8520,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8372
8520
|
CONDUCTOR_TASK_ID: normalizedTargetTaskId,
|
|
8373
8521
|
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
8374
8522
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
8375
|
-
|
|
8523
|
+
// Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
|
|
8524
|
+
// gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
|
|
8525
|
+
// value in the tmux SERVER's global environment (see create_task).
|
|
8526
|
+
CONDUCTOR_CLI_COMMAND: cliCommand,
|
|
8376
8527
|
};
|
|
8377
8528
|
env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
|
|
8378
8529
|
if (AGENT_TOKEN) {
|
|
@@ -134,3 +134,22 @@ export function pruneFireSessionRecords(dir, liveSessionNames, { fsImpl = fs } =
|
|
|
134
134
|
}
|
|
135
135
|
return pruned;
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
// Task ids a previous daemon left a tmux Fire running for. This is the only
|
|
139
|
+
// evidence available to a successor that has no `tmux` binary to enumerate
|
|
140
|
+
// sessions with, so it is what scopes that daemon's refusal to kill.
|
|
141
|
+
export function listFireSessionTaskIds(dir, { fsImpl = fs } = {}) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = fsImpl.readdirSync(dir);
|
|
145
|
+
} catch {
|
|
146
|
+
return new Set();
|
|
147
|
+
}
|
|
148
|
+
const taskIds = new Set();
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (!entry.endsWith(".json")) continue;
|
|
151
|
+
const record = readFireSessionRecord(dir, entry.slice(0, -".json".length), { fsImpl });
|
|
152
|
+
if (record?.taskId) taskIds.add(record.taskId);
|
|
153
|
+
}
|
|
154
|
+
return taskIds;
|
|
155
|
+
}
|
package/src/remote/client.js
CHANGED
|
@@ -97,8 +97,14 @@ export function isRetryable(error) {
|
|
|
97
97
|
return status >= 500 || status === 429;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Retry `fn` on transient failures, handing it the attempt number. `retryable`
|
|
102
|
+
* narrows what counts as transient: a caller whose request is not idempotent
|
|
103
|
+
* (starting a command) must not retry a 5xx that may already have taken effect.
|
|
104
|
+
*/
|
|
105
|
+
export async function withRetry(fn, {
|
|
106
|
+
attempts = 4, baseDelayMs = 500, sleep = delay, retryable = isRetryable,
|
|
107
|
+
} = {}) {
|
|
102
108
|
let lastError;
|
|
103
109
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
104
110
|
try {
|
|
@@ -106,7 +112,7 @@ export async function withRetry(fn, { attempts = 4, baseDelayMs = 500, sleep = d
|
|
|
106
112
|
} catch (error) {
|
|
107
113
|
lastError = error;
|
|
108
114
|
// An aborted transfer is the caller's own deadline firing, not a blip.
|
|
109
|
-
if (error?.name === "AbortError" || !
|
|
115
|
+
if (error?.name === "AbortError" || !retryable(error)) throw error;
|
|
110
116
|
if (attempt === attempts - 1) break;
|
|
111
117
|
await sleep(baseDelayMs * 2 ** attempt);
|
|
112
118
|
}
|
package/src/remote/exec.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
delay,
|
|
16
16
|
loadCliConfig,
|
|
17
17
|
parseTimeoutMs as parseSharedTimeoutMs,
|
|
18
|
+
withRetry,
|
|
18
19
|
} from "./client.js";
|
|
19
20
|
|
|
20
21
|
export { EXIT, UsageError };
|
|
@@ -23,6 +24,14 @@ const DEFAULT_TIMEOUT_MS = 60_000;
|
|
|
23
24
|
const POLL_INTERVAL_MS = 1_000;
|
|
24
25
|
/** How long a single request may block server-side before we switch to polling. */
|
|
25
26
|
const POST_WAIT_MS = 10_000;
|
|
27
|
+
/**
|
|
28
|
+
* A 429 means the per-user in-flight cap (8) is full — nothing was started, so
|
|
29
|
+
* retrying is safe. Six attempts at 500ms doubling waits out ~15s, longer than
|
|
30
|
+
* one saturating POST can block (POST_WAIT_MS). Nothing else is retried here:
|
|
31
|
+
* a 5xx may have started the command already, and a second POST would run it twice.
|
|
32
|
+
*/
|
|
33
|
+
const POST_RETRY = { attempts: 6, retryable: (error) => error?.status === 429 };
|
|
34
|
+
const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
26
35
|
|
|
27
36
|
const VALUE_FLAGS = new Map([
|
|
28
37
|
["--target", "target"],
|
|
@@ -120,7 +129,8 @@ Options:
|
|
|
120
129
|
--timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
|
|
121
130
|
-e, --env KEY=VALUE Extra environment variable (repeatable)
|
|
122
131
|
--json Print the raw run result as JSON
|
|
123
|
-
--kill-on-timeout Stop the remote command when --timeout is reached
|
|
132
|
+
--kill-on-timeout Stop the remote command when --timeout is reached or
|
|
133
|
+
this CLI is interrupted (Ctrl-C, SIGTERM)
|
|
124
134
|
(default: it keeps running on the target)
|
|
125
135
|
--config-file <p> Conductor config file to authenticate with
|
|
126
136
|
-h, --help Show this help
|
|
@@ -132,6 +142,9 @@ Notes:
|
|
|
132
142
|
Exit codes follow ssh: the remote command's own code is passed through, and
|
|
133
143
|
255 means this CLI failed (bad usage, daemon offline, network error).
|
|
134
144
|
|
|
145
|
+
A command that outlives --timeout keeps running on the target; the run id is
|
|
146
|
+
printed and \`conductor remote wait -t <daemon> <runId>\` picks it up again.
|
|
147
|
+
|
|
135
148
|
Examples:
|
|
136
149
|
conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
137
150
|
conductor remote exec -t ubuntu -w /srv/app -- git log --oneline -5
|
|
@@ -139,40 +152,23 @@ Examples:
|
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
|
|
146
|
-
* `tar` on the target.
|
|
155
|
+
* Poll `run` until it leaves `running`, the deadline passes, or `signal`
|
|
156
|
+
* aborts. With `killOnTimeout` a run that is still going at that point is
|
|
157
|
+
* cancelled on the target. Shared by `exec` (after its POST) and `wait`.
|
|
147
158
|
*/
|
|
148
|
-
export async function
|
|
159
|
+
export async function waitForRun(config, basePath, initialRun, options = {}) {
|
|
149
160
|
const {
|
|
150
|
-
|
|
151
|
-
workspace,
|
|
152
|
-
env,
|
|
153
|
-
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
161
|
+
deadline,
|
|
154
162
|
fetchImpl = globalThis.fetch,
|
|
155
163
|
sleep = delay,
|
|
156
164
|
now = () => Date.now(),
|
|
157
165
|
killOnTimeout = false,
|
|
166
|
+
signal,
|
|
158
167
|
} = options;
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
const deadline = now() + timeoutMs;
|
|
162
|
-
|
|
163
|
-
let run = await callApi(config, "POST", basePath, {
|
|
164
|
-
command,
|
|
165
|
-
args,
|
|
166
|
-
...(workspace ? { workspace } : {}),
|
|
167
|
-
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
168
|
-
// Deliberately short, and independent of the overall deadline: that is
|
|
169
|
-
// owned by the poll loop below. Handing the daemon the full budget would
|
|
170
|
-
// make one HTTP request block for it and leave the loop unreachable.
|
|
171
|
-
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
172
|
-
}, fetchImpl);
|
|
173
|
-
|
|
169
|
+
let run = initialRun;
|
|
174
170
|
let pollError = null;
|
|
175
|
-
while (run?.status === "running" && now() < deadline) {
|
|
171
|
+
while (run?.status === "running" && now() < deadline && !signal?.aborted) {
|
|
176
172
|
if (!run.runId) {
|
|
177
173
|
pollError = new Error("daemon reported a running command but returned no runId");
|
|
178
174
|
break;
|
|
@@ -211,7 +207,47 @@ export async function execRemote(config, target, command, options = {}) {
|
|
|
211
207
|
}
|
|
212
208
|
}
|
|
213
209
|
|
|
214
|
-
return { run, pollError, killError
|
|
210
|
+
return { run, pollError, killError };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Run one command on `target` and return the final run record.
|
|
215
|
+
*
|
|
216
|
+
* This is the programmatic half of `runRemoteExec`: same two-phase POST-then-poll
|
|
217
|
+
* behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
|
|
218
|
+
* `tar` on the target.
|
|
219
|
+
*/
|
|
220
|
+
export async function execRemote(config, target, command, options = {}) {
|
|
221
|
+
const {
|
|
222
|
+
args = [],
|
|
223
|
+
workspace,
|
|
224
|
+
env,
|
|
225
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
226
|
+
fetchImpl = globalThis.fetch,
|
|
227
|
+
sleep = delay,
|
|
228
|
+
now = () => Date.now(),
|
|
229
|
+
killOnTimeout = false,
|
|
230
|
+
signal,
|
|
231
|
+
} = options;
|
|
232
|
+
|
|
233
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
234
|
+
const deadline = now() + timeoutMs;
|
|
235
|
+
|
|
236
|
+
const started = await withRetry(() => callApi(config, "POST", basePath, {
|
|
237
|
+
command,
|
|
238
|
+
args,
|
|
239
|
+
...(workspace ? { workspace } : {}),
|
|
240
|
+
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
241
|
+
// Deliberately short, and independent of the overall deadline: that is
|
|
242
|
+
// owned by the poll loop below. Handing the daemon the full budget would
|
|
243
|
+
// make one HTTP request block for it and leave the loop unreachable.
|
|
244
|
+
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
245
|
+
}, fetchImpl), { ...POST_RETRY, sleep });
|
|
246
|
+
|
|
247
|
+
const waited = await waitForRun(config, basePath, started, {
|
|
248
|
+
deadline, fetchImpl, sleep, now, killOnTimeout, signal,
|
|
249
|
+
});
|
|
250
|
+
return { ...waited, basePath };
|
|
215
251
|
}
|
|
216
252
|
|
|
217
253
|
/**
|
|
@@ -229,6 +265,60 @@ export async function execRemoteOrThrow(config, target, command, options = {}) {
|
|
|
229
265
|
throw new Error(detail ? `${what} on ${target}: ${detail}` : `${what} on ${target}`);
|
|
230
266
|
}
|
|
231
267
|
|
|
268
|
+
/**
|
|
269
|
+
* An AbortSignal that fires on Ctrl-C / SIGTERM. Installing a listener also
|
|
270
|
+
* stops Node from exiting on the signal, which is the point: the CLI gets to
|
|
271
|
+
* print the run id (and cancel the run with --kill-on-timeout) before it goes.
|
|
272
|
+
* Callers must `release()` so the default behaviour comes back.
|
|
273
|
+
*/
|
|
274
|
+
export function interruptSignal(proc = process) {
|
|
275
|
+
const controller = new AbortController();
|
|
276
|
+
const onSignal = () => controller.abort();
|
|
277
|
+
for (const name of INTERRUPT_SIGNALS) proc.on(name, onSignal);
|
|
278
|
+
return {
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
release: () => {
|
|
281
|
+
for (const name of INTERRUPT_SIGNALS) proc.off(name, onSignal);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Print a finished (or abandoned) run the way `exec` and `wait` both do, and pick the exit code. */
|
|
287
|
+
export function reportRun(run, { consoleImpl, json, target, pollError, waitedMs, interrupted }) {
|
|
288
|
+
if (json) {
|
|
289
|
+
consoleImpl.log(JSON.stringify(run, null, 2));
|
|
290
|
+
} else {
|
|
291
|
+
if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
|
|
292
|
+
if (run?.stderrTail) process.stderr.write(run.stderrTail);
|
|
293
|
+
if (run?.truncated) {
|
|
294
|
+
consoleImpl.error(`[conductor] output truncated; showing the tail only`);
|
|
295
|
+
}
|
|
296
|
+
if (run?.error) {
|
|
297
|
+
consoleImpl.error(`Error: ${run.error}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (run?.status === "running") {
|
|
302
|
+
if (pollError) {
|
|
303
|
+
consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
|
|
304
|
+
}
|
|
305
|
+
const why = interrupted ? "interrupted" : `still running on ${target} after ${waitedMs}ms`;
|
|
306
|
+
consoleImpl.error(
|
|
307
|
+
`[conductor] ${why}; it keeps going on ${target} — ` +
|
|
308
|
+
`resume with: conductor remote wait -t ${target} ${run.runId} ` +
|
|
309
|
+
`(add --kill-on-timeout to stop it instead)`,
|
|
310
|
+
);
|
|
311
|
+
return EXIT.CLI_ERROR;
|
|
312
|
+
}
|
|
313
|
+
if (run?.status === "cancelled") {
|
|
314
|
+
return EXIT.CLI_ERROR;
|
|
315
|
+
}
|
|
316
|
+
if (typeof run?.exitCode === "number") {
|
|
317
|
+
return run.exitCode;
|
|
318
|
+
}
|
|
319
|
+
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
320
|
+
}
|
|
321
|
+
|
|
232
322
|
export async function runRemoteExec(argv, deps = {}) {
|
|
233
323
|
const consoleImpl = deps.console || console;
|
|
234
324
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
@@ -278,12 +368,12 @@ export async function runRemoteExec(argv, deps = {}) {
|
|
|
278
368
|
return EXIT.CLI_ERROR;
|
|
279
369
|
}
|
|
280
370
|
|
|
371
|
+
const interrupt = interruptSignal(deps.process);
|
|
281
372
|
let run;
|
|
282
373
|
let pollError = null;
|
|
283
|
-
let basePath;
|
|
284
374
|
try {
|
|
285
375
|
let killError;
|
|
286
|
-
({ run, pollError, killError
|
|
376
|
+
({ run, pollError, killError } = await execRemote(config, target, command[0], {
|
|
287
377
|
args: command.slice(1),
|
|
288
378
|
workspace: options.workspace,
|
|
289
379
|
env: options.env,
|
|
@@ -292,46 +382,27 @@ export async function runRemoteExec(argv, deps = {}) {
|
|
|
292
382
|
sleep,
|
|
293
383
|
now,
|
|
294
384
|
killOnTimeout: options.killOnTimeout,
|
|
385
|
+
signal: interrupt.signal,
|
|
295
386
|
}));
|
|
296
387
|
if (killError) {
|
|
297
388
|
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
|
|
298
389
|
} else if (options.killOnTimeout && run?.status === "cancelled") {
|
|
299
|
-
|
|
390
|
+
const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
|
|
391
|
+
consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
|
|
300
392
|
}
|
|
301
393
|
} catch (error) {
|
|
302
394
|
consoleImpl.error(`Error: ${error.message}`);
|
|
303
395
|
return EXIT.CLI_ERROR;
|
|
396
|
+
} finally {
|
|
397
|
+
interrupt.release();
|
|
304
398
|
}
|
|
305
399
|
|
|
306
|
-
|
|
307
|
-
consoleImpl
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (run?.error) {
|
|
315
|
-
consoleImpl.error(`Error: ${run.error}`);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (run?.status === "running") {
|
|
320
|
-
if (pollError) {
|
|
321
|
-
consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
|
|
322
|
-
}
|
|
323
|
-
consoleImpl.error(
|
|
324
|
-
`[conductor] still running on ${target} after ${timeoutMs}ms; ` +
|
|
325
|
-
`it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
|
|
326
|
-
`stop it with DELETE on the same path, or use --kill-on-timeout`,
|
|
327
|
-
);
|
|
328
|
-
return EXIT.CLI_ERROR;
|
|
329
|
-
}
|
|
330
|
-
if (run?.status === "cancelled") {
|
|
331
|
-
return EXIT.CLI_ERROR;
|
|
332
|
-
}
|
|
333
|
-
if (typeof run?.exitCode === "number") {
|
|
334
|
-
return run.exitCode;
|
|
335
|
-
}
|
|
336
|
-
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
400
|
+
return reportRun(run, {
|
|
401
|
+
consoleImpl,
|
|
402
|
+
json: options.json,
|
|
403
|
+
target,
|
|
404
|
+
pollError,
|
|
405
|
+
waitedMs: timeoutMs,
|
|
406
|
+
interrupted: interrupt.signal.aborted,
|
|
407
|
+
});
|
|
337
408
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `conductor remote wait` — pick up a command started by `remote exec` that
|
|
3
|
+
* outlived its deadline (or the process that started it).
|
|
4
|
+
*
|
|
5
|
+
* `exec` prints the run id whenever it leaves a command behind; this verb
|
|
6
|
+
* polls that run the same way `exec` would have and reports it identically,
|
|
7
|
+
* so a caller can always finish what it started.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
|
|
12
|
+
import { EXIT, callApi, delay, loadCliConfig } from "./client.js";
|
|
13
|
+
import {
|
|
14
|
+
interruptSignal,
|
|
15
|
+
parseArgs,
|
|
16
|
+
parseTimeoutMs,
|
|
17
|
+
reportRun,
|
|
18
|
+
waitForRun,
|
|
19
|
+
} from "./exec.js";
|
|
20
|
+
|
|
21
|
+
export function showHelp(consoleImpl = console) {
|
|
22
|
+
consoleImpl.log(`conductor remote wait - wait for a command left running by \`remote exec\`
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
conductor remote wait --target <daemon> [options] <runId>
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
-t, --target <daemon> Daemon the command is running on (required)
|
|
29
|
+
--timeout <dur> How long to wait this time, e.g. 30s, 2m (default: 60s)
|
|
30
|
+
--json Print the raw run result as JSON
|
|
31
|
+
--kill-on-timeout Stop the remote command when --timeout is reached or
|
|
32
|
+
this CLI is interrupted (default: it keeps running)
|
|
33
|
+
--config-file <p> Conductor config file to authenticate with
|
|
34
|
+
-h, --help Show this help
|
|
35
|
+
|
|
36
|
+
Output and exit codes match \`conductor remote exec\`: the command's own exit
|
|
37
|
+
code is passed through, 255 means this CLI failed or the command is still running.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
conductor remote exec -t ubuntu --timeout 30s -- make build
|
|
41
|
+
# ... [conductor] still running on ubuntu after 30000ms; resume with:
|
|
42
|
+
conductor remote wait -t ubuntu --timeout 20m 3f9c1b2e-...
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runRemoteWait(argv, deps = {}) {
|
|
47
|
+
const consoleImpl = deps.console || console;
|
|
48
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
49
|
+
const env = deps.env || process.env;
|
|
50
|
+
const sleep = deps.sleep || delay;
|
|
51
|
+
const now = deps.now || (() => Date.now());
|
|
52
|
+
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = parseArgs(argv);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
58
|
+
return EXIT.CLI_ERROR;
|
|
59
|
+
}
|
|
60
|
+
const { options, command } = parsed;
|
|
61
|
+
|
|
62
|
+
if (options.help) {
|
|
63
|
+
showHelp(consoleImpl);
|
|
64
|
+
return EXIT.OK;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const target = typeof options.target === "string" ? options.target.trim() : "";
|
|
68
|
+
if (!target) {
|
|
69
|
+
consoleImpl.error("Error: --target <daemon> is required");
|
|
70
|
+
showHelp(consoleImpl);
|
|
71
|
+
return EXIT.CLI_ERROR;
|
|
72
|
+
}
|
|
73
|
+
if (command.length !== 1) {
|
|
74
|
+
consoleImpl.error(command.length === 0 ? "Error: no runId given" : "Error: expected exactly one runId");
|
|
75
|
+
showHelp(consoleImpl);
|
|
76
|
+
return EXIT.CLI_ERROR;
|
|
77
|
+
}
|
|
78
|
+
const runId = command[0];
|
|
79
|
+
|
|
80
|
+
let timeoutMs;
|
|
81
|
+
try {
|
|
82
|
+
timeoutMs = parseTimeoutMs(options.timeout);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
85
|
+
return EXIT.CLI_ERROR;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let config;
|
|
89
|
+
try {
|
|
90
|
+
config = deps.config || loadCliConfig(options.configFile, env);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
93
|
+
return EXIT.CLI_ERROR;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
97
|
+
const interrupt = interruptSignal(deps.process);
|
|
98
|
+
let run;
|
|
99
|
+
let pollError = null;
|
|
100
|
+
try {
|
|
101
|
+
const deadline = now() + timeoutMs;
|
|
102
|
+
run = await callApi(config, "GET", `${basePath}/runs/${encodeURIComponent(runId)}`, null, fetchImpl);
|
|
103
|
+
let killError;
|
|
104
|
+
({ run, pollError, killError } = await waitForRun(config, basePath, run, {
|
|
105
|
+
deadline,
|
|
106
|
+
fetchImpl,
|
|
107
|
+
sleep,
|
|
108
|
+
now,
|
|
109
|
+
killOnTimeout: options.killOnTimeout,
|
|
110
|
+
signal: interrupt.signal,
|
|
111
|
+
}));
|
|
112
|
+
if (killError) {
|
|
113
|
+
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
|
|
114
|
+
} else if (options.killOnTimeout && run?.status === "cancelled") {
|
|
115
|
+
const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
|
|
116
|
+
consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
120
|
+
return EXIT.CLI_ERROR;
|
|
121
|
+
} finally {
|
|
122
|
+
interrupt.release();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return reportRun(run, {
|
|
126
|
+
consoleImpl,
|
|
127
|
+
json: options.json,
|
|
128
|
+
target,
|
|
129
|
+
pollError,
|
|
130
|
+
waitedMs: timeoutMs,
|
|
131
|
+
interrupted: interrupt.signal.aborted,
|
|
132
|
+
});
|
|
133
|
+
}
|
package/src/serve-ai/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { loadServeAiRuntimeConfig } from "./config.js";
|
|
|
11
11
|
import { sendJson, sendOpenAiError } from "./errors.js";
|
|
12
12
|
import { materializeImageInputs } from "./image-handler.js";
|
|
13
13
|
import {
|
|
14
|
+
inferBuiltInRuntimeBackendFromCommand,
|
|
14
15
|
listAdvertisedBackends,
|
|
15
16
|
normalizeRuntimeBackendAlias,
|
|
16
17
|
normalizeRuntimeBackendName,
|
|
@@ -124,7 +125,7 @@ function extractModelOptionFromCommandLine(commandLine) {
|
|
|
124
125
|
return "";
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
|
|
128
|
+
export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
|
|
128
129
|
const normalizedBackend = normalizeRuntimeBackendName(backend);
|
|
129
130
|
const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
|
|
130
131
|
const envKeyByBackend = {
|
|
@@ -148,7 +149,21 @@ function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, s
|
|
|
148
149
|
: "";
|
|
149
150
|
const daemonCommand =
|
|
150
151
|
typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
|
|
151
|
-
|
|
152
|
+
|
|
153
|
+
// CONDUCTOR_CLI_COMMAND may be stale (e.g. leaked from a tmux server's
|
|
154
|
+
// global environment, left by another backend's task). Only honor the
|
|
155
|
+
// fallback when the command does not provably belong to a different built-in
|
|
156
|
+
// backend. Mirrors the same guard in bin/conductor-fire.js.
|
|
157
|
+
const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
|
|
158
|
+
const usableDaemonCommand =
|
|
159
|
+
daemonCommand &&
|
|
160
|
+
(!inferredDaemonBackend ||
|
|
161
|
+
inferredDaemonBackend === normalizedSessionBackend ||
|
|
162
|
+
inferredDaemonBackend === normalizedBackend)
|
|
163
|
+
? daemonCommand
|
|
164
|
+
: "";
|
|
165
|
+
|
|
166
|
+
const resolvedCommand = configuredCommand || usableDaemonCommand;
|
|
152
167
|
if (!resolvedCommand) {
|
|
153
168
|
return "";
|
|
154
169
|
}
|