@love-moon/conductor-cli 0.13.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 +15 -0
- package/bin/conductor-fire.js +177 -9
- package/package.json +5 -5
- package/src/daemon.js +104 -3
- package/src/serve-ai/index.js +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
3
18
|
## 0.13.0
|
|
4
19
|
|
|
5
20
|
### 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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.13.
|
|
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.13.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.13.
|
|
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.13.
|
|
41
|
+
"@love-moon/chat-web": "0.13.1"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
package/src/daemon.js
CHANGED
|
@@ -891,6 +891,27 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
|
|
|
891
891
|
"CONDUCTOR_RESUME_CWD",
|
|
892
892
|
];
|
|
893
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
|
+
|
|
894
915
|
function stripPtyTaskScopedEnv(source) {
|
|
895
916
|
const env = {
|
|
896
917
|
...(source && typeof source === "object" ? source : {}),
|
|
@@ -1692,6 +1713,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1692
1713
|
tmuxEnvFlags.push("-e", `${key}=${stringValue}`);
|
|
1693
1714
|
}
|
|
1694
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
|
+
|
|
1695
1727
|
// Use a non-login `bash -c` here: node and the CLI script are passed by
|
|
1696
1728
|
// absolute path, so we don't need PATH from a login shell, and `-l`
|
|
1697
1729
|
// would slow startup and could leak unexpected stderr from user shell
|
|
@@ -2001,7 +2033,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2001
2033
|
// *live* replacement as killed, using the previous run's log tail. Worse,
|
|
2002
2034
|
// the daemon's own map would say running, so reconcile would never repair
|
|
2003
2035
|
// it. Re-check that the task is still absent before speaking.
|
|
2004
|
-
if (activeTaskProcesses.has(taskId)) {
|
|
2036
|
+
if (activeTaskProcesses.has(taskId) || pendingTaskStarts.has(taskId)) {
|
|
2005
2037
|
log(
|
|
2006
2038
|
`Task ${taskId} was restarted while its dead tmux session was being reported; dropping the stale terminal status`,
|
|
2007
2039
|
);
|
|
@@ -3815,6 +3847,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3815
3847
|
"project_agents_registry",
|
|
3816
3848
|
"restart_daemon",
|
|
3817
3849
|
"refresh_session_inplace",
|
|
3850
|
+
"persistent_round_v1",
|
|
3818
3851
|
"task_attachments_v1",
|
|
3819
3852
|
"backend_session_list",
|
|
3820
3853
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
@@ -6947,6 +6980,42 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6947
6980
|
return true;
|
|
6948
6981
|
}
|
|
6949
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
|
+
|
|
6950
7019
|
async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
|
|
6951
7020
|
const deadline = Date.now() + Math.max(timeoutMs, 0);
|
|
6952
7021
|
|
|
@@ -7537,6 +7606,23 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7537
7606
|
return;
|
|
7538
7607
|
}
|
|
7539
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
|
+
|
|
7540
7626
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
7541
7627
|
if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
|
|
7542
7628
|
log(
|
|
@@ -7679,7 +7765,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7679
7765
|
// even when the daemon was named through the config file.
|
|
7680
7766
|
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
7681
7767
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
7682
|
-
|
|
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,
|
|
7683
7775
|
};
|
|
7684
7776
|
if (AGENT_TOKEN) {
|
|
7685
7777
|
env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
|
|
@@ -7688,6 +7780,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7688
7780
|
env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
|
|
7689
7781
|
}
|
|
7690
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
|
+
|
|
7691
7789
|
// Sampled before the spawn so it is a true "everything after this is
|
|
7692
7790
|
// ours" watermark.
|
|
7693
7791
|
const logStartOffset = readLogSize(logPath);
|
|
@@ -8422,7 +8520,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8422
8520
|
CONDUCTOR_TASK_ID: normalizedTargetTaskId,
|
|
8423
8521
|
CONDUCTOR_DAEMON_NAME: AGENT_NAME,
|
|
8424
8522
|
CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
|
|
8425
|
-
|
|
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,
|
|
8426
8527
|
};
|
|
8427
8528
|
env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
|
|
8428
8529
|
if (AGENT_TOKEN) {
|
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
|
}
|