@love-moon/conductor-cli 0.2.20 → 0.2.22
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-daemon.js +51 -0
- package/bin/conductor-fire.js +8 -0
- package/bin/conductor-update.js +15 -110
- package/bin/conductor.js +73 -52
- package/package.json +4 -4
- package/src/cli-update-notifier.js +241 -0
- package/src/daemon.js +534 -30
- package/src/version-check.js +240 -0
package/src/daemon.js
CHANGED
|
@@ -11,15 +11,32 @@ import yaml from "js-yaml";
|
|
|
11
11
|
import { ConductorWebSocketClient, ConductorConfig, loadConfig, ConfigFileNotFound } from "@love-moon/conductor-sdk";
|
|
12
12
|
import { DaemonLogCollector } from "./log-collector.js";
|
|
13
13
|
import { filterRuntimeSupportedAllowCliList, normalizeRuntimeBackendName } from "./runtime-backends.js";
|
|
14
|
+
import {
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
fetchLatestVersion,
|
|
17
|
+
isNewerVersion,
|
|
18
|
+
detectPackageManager,
|
|
19
|
+
parseUpdateWindow,
|
|
20
|
+
isInUpdateWindow,
|
|
21
|
+
isManagedInstallPath,
|
|
22
|
+
} from "./version-check.js";
|
|
14
23
|
|
|
15
24
|
dotenv.config();
|
|
16
25
|
|
|
17
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
27
|
const __dirname = path.dirname(__filename);
|
|
28
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
19
29
|
const moduleRequire = createRequire(import.meta.url);
|
|
20
|
-
const CLI_PATH = path.resolve(
|
|
30
|
+
const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
|
|
21
31
|
const DAEMON_LOG_DIR = path.join(os.homedir(), ".conductor", "logs");
|
|
22
32
|
const DAEMON_LOG_PATH = path.join(DAEMON_LOG_DIR, "conductor-daemon.log");
|
|
33
|
+
const CLI_VERSION = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
23
40
|
const PLAN_LIMIT_MESSAGES = {
|
|
24
41
|
manual_fire_active_task: "Free plan limit reached: only 1 active fire task is allowed.",
|
|
25
42
|
app_active_task: "Free plan limit reached: only 1 active app task is allowed.",
|
|
@@ -200,6 +217,16 @@ function normalizeOptionalString(value) {
|
|
|
200
217
|
return normalized || null;
|
|
201
218
|
}
|
|
202
219
|
|
|
220
|
+
function normalizeStringArray(value) {
|
|
221
|
+
if (!Array.isArray(value)) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
return value
|
|
225
|
+
.filter((entry) => typeof entry === "string")
|
|
226
|
+
.map((entry) => entry.trim())
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
function normalizePositiveInt(value, fallback) {
|
|
204
231
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
205
232
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
@@ -329,6 +356,43 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
329
356
|
// Get allow_cli_list from config
|
|
330
357
|
const ALLOW_CLI_LIST = getAllowCliList(userConfig);
|
|
331
358
|
const SUPPORTED_BACKENDS = Object.keys(ALLOW_CLI_LIST);
|
|
359
|
+
const fetchLatestVersionFn = deps.fetchLatestVersion || fetchLatestVersion;
|
|
360
|
+
const isNewerVersionFn = deps.isNewerVersion || isNewerVersion;
|
|
361
|
+
const detectPackageManagerFn = deps.detectPackageManager || detectPackageManager;
|
|
362
|
+
const parseUpdateWindowFn = deps.parseUpdateWindow || parseUpdateWindow;
|
|
363
|
+
const isInUpdateWindowFn = deps.isInUpdateWindow || isInUpdateWindow;
|
|
364
|
+
const isManagedInstallPathFn = deps.isManagedInstallPath || isManagedInstallPath;
|
|
365
|
+
const installedPackageRoot = deps.packageRoot || PACKAGE_ROOT;
|
|
366
|
+
const cliVersion = deps.cliVersion || CLI_VERSION;
|
|
367
|
+
const isBackgroundProcess = deps.isBackgroundProcess ?? !process.stdout.isTTY;
|
|
368
|
+
const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
|
|
369
|
+
const autoUpdateSupportedInstall =
|
|
370
|
+
autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
|
|
371
|
+
const lockHandoffToken =
|
|
372
|
+
normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
|
|
373
|
+
normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
|
|
374
|
+
const lockHandoffFromPid = normalizePositiveInt(
|
|
375
|
+
config.LOCK_HANDOFF_FROM_PID || process.env.CONDUCTOR_LOCK_HANDOFF_FROM_PID,
|
|
376
|
+
null,
|
|
377
|
+
);
|
|
378
|
+
const restartLauncherScript = normalizeOptionalString(config.RESTART_LAUNCHER_SCRIPT);
|
|
379
|
+
const restartLauncherArgs = normalizeStringArray(config.RESTART_LAUNCHER_ARGS);
|
|
380
|
+
const normalizedVersionCheckArgs = normalizeStringArray(config.VERSION_CHECK_ARGS);
|
|
381
|
+
const versionCheckScript =
|
|
382
|
+
normalizeOptionalString(config.VERSION_CHECK_SCRIPT) || restartLauncherScript;
|
|
383
|
+
const versionCheckArgs =
|
|
384
|
+
normalizedVersionCheckArgs.length > 0
|
|
385
|
+
? normalizedVersionCheckArgs
|
|
386
|
+
: ["--version"];
|
|
387
|
+
|
|
388
|
+
// Auto-update configuration
|
|
389
|
+
const AUTO_UPDATE_ENABLED =
|
|
390
|
+
autoUpdateSupportedInstall &&
|
|
391
|
+
(process.env.CONDUCTOR_AUTO_UPDATE !== "false") &&
|
|
392
|
+
(userConfig.auto_update !== false);
|
|
393
|
+
const UPDATE_WINDOW = parseUpdateWindowFn(
|
|
394
|
+
process.env.CONDUCTOR_UPDATE_WINDOW || userConfig.update_window || "02:00-04:00"
|
|
395
|
+
);
|
|
332
396
|
|
|
333
397
|
const spawnFn = deps.spawn || spawn;
|
|
334
398
|
const mkdirSyncFn = deps.mkdirSync || fs.mkdirSync;
|
|
@@ -392,6 +456,53 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
392
456
|
DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
|
|
393
457
|
);
|
|
394
458
|
|
|
459
|
+
const readLockState = () => {
|
|
460
|
+
const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
|
|
461
|
+
if (!raw) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const pid = Number.parseInt(raw, 10);
|
|
466
|
+
if (!Number.isNaN(pid) && pid > 0) {
|
|
467
|
+
return {
|
|
468
|
+
pid,
|
|
469
|
+
handoffFromPid: null,
|
|
470
|
+
handoffToken: null,
|
|
471
|
+
handoffExpiresAt: null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const parsed = JSON.parse(raw);
|
|
477
|
+
const parsedPid = normalizePositiveInt(parsed?.pid, null);
|
|
478
|
+
const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
|
|
479
|
+
return {
|
|
480
|
+
pid: parsedPid ?? parsedHandoffFromPid,
|
|
481
|
+
handoffFromPid: parsedHandoffFromPid,
|
|
482
|
+
handoffToken: normalizeOptionalString(parsed?.handoff_token),
|
|
483
|
+
handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
|
|
484
|
+
};
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const hasMatchingLockHandoff = (lockState) => {
|
|
491
|
+
if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
if (lockState.handoffToken !== lockHandoffToken) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
if (lockState.handoffFromPid !== lockHandoffFromPid) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
if (lockState.handoffExpiresAt && lockState.handoffExpiresAt < Date.now()) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
};
|
|
505
|
+
|
|
395
506
|
try {
|
|
396
507
|
mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
|
|
397
508
|
} catch (err) {
|
|
@@ -402,44 +513,57 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
402
513
|
const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
|
|
403
514
|
try {
|
|
404
515
|
if (existsSyncFn(LOCK_FILE)) {
|
|
405
|
-
const
|
|
406
|
-
|
|
516
|
+
const lockState = readLockState();
|
|
517
|
+
const pid = lockState?.pid;
|
|
518
|
+
if (pid) {
|
|
519
|
+
const handoffMatched = hasMatchingLockHandoff(lockState);
|
|
407
520
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
521
|
+
if (handoffMatched) {
|
|
522
|
+
log(`Taking over daemon lock from PID ${pid} via handoff`);
|
|
523
|
+
} else {
|
|
524
|
+
const alive = isProcessAlive(pid);
|
|
525
|
+
if (alive) {
|
|
526
|
+
if (config.FORCE) {
|
|
527
|
+
log(`Force enabled: stopping existing daemon PID ${pid}`);
|
|
528
|
+
try {
|
|
529
|
+
killFn(pid, "SIGTERM");
|
|
530
|
+
} catch (killErr) {
|
|
531
|
+
if (!killErr || killErr.code !== "ESRCH") {
|
|
532
|
+
logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
|
|
533
|
+
return exitAndReturn(1);
|
|
534
|
+
}
|
|
418
535
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
536
|
+
try {
|
|
537
|
+
if (isProcessAlive(pid)) {
|
|
538
|
+
logError(`Existing daemon PID ${pid} is still running; please stop it manually.`);
|
|
539
|
+
return exitAndReturn(1);
|
|
540
|
+
}
|
|
541
|
+
} catch (checkErr) {
|
|
542
|
+
logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
|
|
423
543
|
return exitAndReturn(1);
|
|
424
544
|
}
|
|
425
|
-
|
|
426
|
-
|
|
545
|
+
log("Removing lock file after force stop");
|
|
546
|
+
unlinkSyncFn(LOCK_FILE);
|
|
547
|
+
} else {
|
|
548
|
+
logError(`Daemon already running with PID ${pid}`);
|
|
427
549
|
return exitAndReturn(1);
|
|
428
550
|
}
|
|
429
|
-
log("Removing lock file after force stop");
|
|
430
|
-
unlinkSyncFn(LOCK_FILE);
|
|
431
551
|
} else {
|
|
432
|
-
|
|
433
|
-
|
|
552
|
+
log("Removing stale lock file");
|
|
553
|
+
unlinkSyncFn(LOCK_FILE);
|
|
434
554
|
}
|
|
435
|
-
} else {
|
|
436
|
-
log("Removing stale lock file");
|
|
437
|
-
unlinkSyncFn(LOCK_FILE);
|
|
438
555
|
}
|
|
439
556
|
} catch (e) {
|
|
440
|
-
|
|
441
|
-
|
|
557
|
+
if (handoffMatched) {
|
|
558
|
+
log(`Taking over daemon lock from PID ${pid} via handoff`);
|
|
559
|
+
} else {
|
|
560
|
+
logError(`Daemon already running with PID ${pid} (access denied)`);
|
|
561
|
+
return exitAndReturn(1);
|
|
562
|
+
}
|
|
442
563
|
}
|
|
564
|
+
} else {
|
|
565
|
+
log("Removing malformed lock file");
|
|
566
|
+
unlinkSyncFn(LOCK_FILE);
|
|
443
567
|
}
|
|
444
568
|
}
|
|
445
569
|
writeFileSyncFn(LOCK_FILE, process.pid.toString());
|
|
@@ -451,8 +575,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
451
575
|
const cleanupLock = () => {
|
|
452
576
|
try {
|
|
453
577
|
if (existsSyncFn(LOCK_FILE)) {
|
|
454
|
-
const
|
|
455
|
-
if (pid === process.pid) {
|
|
578
|
+
const lockState = readLockState();
|
|
579
|
+
if (lockState?.pid === process.pid) {
|
|
456
580
|
unlinkSyncFn(LOCK_FILE);
|
|
457
581
|
}
|
|
458
582
|
}
|
|
@@ -461,6 +585,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
461
585
|
}
|
|
462
586
|
};
|
|
463
587
|
|
|
588
|
+
const writeLockHandoff = ({ handoffToken, handoffFromPid, handoffExpiresAt }) => {
|
|
589
|
+
writeFileSyncFn(
|
|
590
|
+
LOCK_FILE,
|
|
591
|
+
JSON.stringify({
|
|
592
|
+
pid: handoffFromPid,
|
|
593
|
+
handoff_from_pid: handoffFromPid,
|
|
594
|
+
handoff_token: handoffToken,
|
|
595
|
+
handoff_expires_at: handoffExpiresAt,
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
};
|
|
599
|
+
|
|
464
600
|
process.on("exit", cleanupLock);
|
|
465
601
|
const signalExitCode = (signal) => (signal === "SIGINT" ? 130 : 143);
|
|
466
602
|
const handleSignal = (signal) => {
|
|
@@ -544,6 +680,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
544
680
|
let watchdogLastPresenceMismatchAt = 0;
|
|
545
681
|
let watchdogAwaitingHealthySignalAt = null;
|
|
546
682
|
let watchdogTimer = null;
|
|
683
|
+
|
|
684
|
+
// --- Auto-update state ---
|
|
685
|
+
const VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
686
|
+
let lastVersionCheckAt = 0;
|
|
687
|
+
let latestKnownVersion = null;
|
|
688
|
+
let updateAvailable = false;
|
|
689
|
+
let autoUpdateInProgress = false;
|
|
690
|
+
|
|
547
691
|
let rtcImplementationPromise = null;
|
|
548
692
|
let rtcAvailabilityLogKey = null;
|
|
549
693
|
const logCollector = createLogCollector(BACKEND_HTTP);
|
|
@@ -553,6 +697,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
553
697
|
"x-conductor-host": AGENT_NAME,
|
|
554
698
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
555
699
|
"x-conductor-capabilities": "pty_task",
|
|
700
|
+
"x-conductor-version": cliVersion,
|
|
556
701
|
},
|
|
557
702
|
onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
|
|
558
703
|
wsConnected = true;
|
|
@@ -610,8 +755,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
610
755
|
logError(`Failed to connect: ${err}`);
|
|
611
756
|
});
|
|
612
757
|
|
|
758
|
+
if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
|
|
759
|
+
log("[auto-update] Disabled for local/dev install; set CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL=true to override");
|
|
760
|
+
}
|
|
761
|
+
|
|
613
762
|
watchdogTimer = setInterval(() => {
|
|
614
763
|
void runDaemonWatchdog();
|
|
764
|
+
// Auto-update checks (internally throttled)
|
|
765
|
+
void checkForUpdate().catch(() => {});
|
|
766
|
+
void tryAutoUpdate().catch(() => {});
|
|
615
767
|
}, DAEMON_WATCHDOG_INTERVAL_MS);
|
|
616
768
|
if (typeof watchdogTimer?.unref === "function") {
|
|
617
769
|
watchdogTimer.unref();
|
|
@@ -769,6 +921,260 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
769
921
|
}
|
|
770
922
|
}
|
|
771
923
|
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Auto-update: periodic version check (P1) + safe-window update (P2)
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
929
|
+
|
|
930
|
+
async function checkForUpdate() {
|
|
931
|
+
if (!AUTO_UPDATE_ENABLED || cliVersion === "unknown") return;
|
|
932
|
+
const now = Date.now();
|
|
933
|
+
if (now - lastVersionCheckAt < VERSION_CHECK_INTERVAL_MS) return;
|
|
934
|
+
lastVersionCheckAt = now;
|
|
935
|
+
try {
|
|
936
|
+
const latest = await fetchLatestVersionFn();
|
|
937
|
+
if (!latest || !SEMVER_RE.test(latest)) return; // reject malformed versions
|
|
938
|
+
if (isNewerVersionFn(latest, cliVersion)) {
|
|
939
|
+
if (latestKnownVersion !== latest) {
|
|
940
|
+
log(`[auto-update] New version available: ${cliVersion} -> ${latest}`);
|
|
941
|
+
}
|
|
942
|
+
latestKnownVersion = latest;
|
|
943
|
+
updateAvailable = true;
|
|
944
|
+
} else {
|
|
945
|
+
updateAvailable = false;
|
|
946
|
+
latestKnownVersion = latest;
|
|
947
|
+
}
|
|
948
|
+
} catch {
|
|
949
|
+
/* silent — non-critical */
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const AUTO_UPDATE_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour cooldown after failure
|
|
954
|
+
let lastAutoUpdateFailAt = 0;
|
|
955
|
+
|
|
956
|
+
function hasActiveTasks() {
|
|
957
|
+
return activeTaskProcesses.size > 0 || activePtySessions.size > 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function tryAutoUpdate() {
|
|
961
|
+
if (!AUTO_UPDATE_ENABLED || !updateAvailable || !latestKnownVersion) return;
|
|
962
|
+
if (autoUpdateInProgress || daemonShuttingDown) return;
|
|
963
|
+
if (hasActiveTasks()) return;
|
|
964
|
+
if (!isInUpdateWindowFn(UPDATE_WINDOW)) return;
|
|
965
|
+
if (Date.now() - lastAutoUpdateFailAt < AUTO_UPDATE_COOLDOWN_MS) return;
|
|
966
|
+
|
|
967
|
+
autoUpdateInProgress = true;
|
|
968
|
+
log(`[auto-update] Starting: ${cliVersion} -> ${latestKnownVersion}`);
|
|
969
|
+
try {
|
|
970
|
+
await performAutoUpdate(latestKnownVersion);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
logError(`[auto-update] Failed: ${err?.message || err}`);
|
|
973
|
+
lastAutoUpdateFailAt = Date.now();
|
|
974
|
+
} finally {
|
|
975
|
+
autoUpdateInProgress = false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function runInstallCommand(pm, pkgSpec) {
|
|
980
|
+
return new Promise((resolve) => {
|
|
981
|
+
let cmd, args;
|
|
982
|
+
switch (pm) {
|
|
983
|
+
case "pnpm":
|
|
984
|
+
cmd = "pnpm";
|
|
985
|
+
args = ["add", "-g", pkgSpec];
|
|
986
|
+
break;
|
|
987
|
+
case "yarn":
|
|
988
|
+
cmd = "yarn";
|
|
989
|
+
args = ["global", "add", pkgSpec];
|
|
990
|
+
break;
|
|
991
|
+
default:
|
|
992
|
+
cmd = "npm";
|
|
993
|
+
args = ["install", "-g", pkgSpec];
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
let stdout = "";
|
|
997
|
+
let stderr = "";
|
|
998
|
+
const child = spawnFn(cmd, args, {
|
|
999
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1000
|
+
});
|
|
1001
|
+
const timer = setTimeout(() => {
|
|
1002
|
+
try {
|
|
1003
|
+
child.kill("SIGTERM");
|
|
1004
|
+
} catch {
|
|
1005
|
+
/* ignore */
|
|
1006
|
+
}
|
|
1007
|
+
}, 120_000);
|
|
1008
|
+
child.stdout?.on("data", (d) => {
|
|
1009
|
+
if (stdout.length < 4000) stdout += d.toString().slice(0, 2000);
|
|
1010
|
+
});
|
|
1011
|
+
child.stderr?.on("data", (d) => {
|
|
1012
|
+
if (stderr.length < 4000) stderr += d.toString().slice(0, 2000);
|
|
1013
|
+
});
|
|
1014
|
+
child.on("close", (code) => {
|
|
1015
|
+
clearTimeout(timer);
|
|
1016
|
+
resolve({ success: code === 0, code, stdout, stderr });
|
|
1017
|
+
});
|
|
1018
|
+
child.on("error", (err) => {
|
|
1019
|
+
clearTimeout(timer);
|
|
1020
|
+
resolve({ success: false, code: -1, stdout, stderr: err.message });
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function runCommand(command, args, timeoutMs = 120_000) {
|
|
1026
|
+
return new Promise((resolve) => {
|
|
1027
|
+
let stdout = "";
|
|
1028
|
+
let stderr = "";
|
|
1029
|
+
const child = spawnFn(command, args, {
|
|
1030
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1031
|
+
env: { ...process.env },
|
|
1032
|
+
});
|
|
1033
|
+
const timer = setTimeout(() => {
|
|
1034
|
+
try {
|
|
1035
|
+
child.kill("SIGTERM");
|
|
1036
|
+
} catch {
|
|
1037
|
+
/* ignore */
|
|
1038
|
+
}
|
|
1039
|
+
}, timeoutMs);
|
|
1040
|
+
child.stdout?.on("data", (chunk) => {
|
|
1041
|
+
if (stdout.length < 4000) stdout += chunk.toString().slice(0, 2000);
|
|
1042
|
+
});
|
|
1043
|
+
child.stderr?.on("data", (chunk) => {
|
|
1044
|
+
if (stderr.length < 4000) stderr += chunk.toString().slice(0, 2000);
|
|
1045
|
+
});
|
|
1046
|
+
child.on("close", (code) => {
|
|
1047
|
+
clearTimeout(timer);
|
|
1048
|
+
resolve({ success: code === 0, code, stdout, stderr });
|
|
1049
|
+
});
|
|
1050
|
+
child.on("error", (err) => {
|
|
1051
|
+
clearTimeout(timer);
|
|
1052
|
+
resolve({ success: false, code: -1, stdout, stderr: err.message || String(err) });
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function readInstalledCliVersion() {
|
|
1058
|
+
const commandAttempts = versionCheckScript
|
|
1059
|
+
? [{
|
|
1060
|
+
command: process.execPath,
|
|
1061
|
+
args: [versionCheckScript, ...versionCheckArgs],
|
|
1062
|
+
}]
|
|
1063
|
+
: [{
|
|
1064
|
+
command: "conductor",
|
|
1065
|
+
args: ["--version"],
|
|
1066
|
+
}];
|
|
1067
|
+
|
|
1068
|
+
for (const attempt of commandAttempts) {
|
|
1069
|
+
const commandResult = await runCommand(attempt.command, attempt.args, 15_000);
|
|
1070
|
+
const combinedOutput = `${commandResult.stdout}\n${commandResult.stderr}`.trim();
|
|
1071
|
+
const match = combinedOutput.match(/conductor version ([^\s]+)/);
|
|
1072
|
+
if (match?.[1]) {
|
|
1073
|
+
return match[1];
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
let pkgPath;
|
|
1078
|
+
try {
|
|
1079
|
+
pkgPath = moduleRequire.resolve(`${PACKAGE_NAME}/package.json`);
|
|
1080
|
+
} catch {
|
|
1081
|
+
pkgPath = path.join(installedPackageRoot, "package.json");
|
|
1082
|
+
}
|
|
1083
|
+
const newPkg = JSON.parse(readFileSyncFn(pkgPath, "utf-8"));
|
|
1084
|
+
return newPkg.version || null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function performAutoUpdate(targetVersion) {
|
|
1088
|
+
const pm = detectPackageManagerFn({
|
|
1089
|
+
launcherPath: restartLauncherScript || versionCheckScript,
|
|
1090
|
+
packageRoot: installedPackageRoot,
|
|
1091
|
+
});
|
|
1092
|
+
const pkgSpec = `${PACKAGE_NAME}@${targetVersion}`;
|
|
1093
|
+
log(`[auto-update] Installing ${pkgSpec} via ${pm}...`);
|
|
1094
|
+
|
|
1095
|
+
// Step 1: install
|
|
1096
|
+
const result = await runInstallCommand(pm, pkgSpec);
|
|
1097
|
+
if (!result.success) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`Install failed (exit ${result.code}): ${(result.stderr || result.stdout).slice(0, 200)}`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Step 2: re-check active tasks — a task may have arrived during the install
|
|
1104
|
+
if (hasActiveTasks()) {
|
|
1105
|
+
log("[auto-update] Active tasks appeared during install; aborting restart (will retry later)");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Step 3: verify installed version using the globally resolved CLI entry point.
|
|
1110
|
+
try {
|
|
1111
|
+
const installedVersion = await readInstalledCliVersion();
|
|
1112
|
+
if (installedVersion !== targetVersion) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
`Version mismatch after install: expected ${targetVersion}, got ${installedVersion}`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
} catch (verifyErr) {
|
|
1118
|
+
throw new Error(`Version verification failed: ${verifyErr?.message || verifyErr}`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
log(`[auto-update] Verified ${targetVersion}. Restarting daemon...`);
|
|
1122
|
+
|
|
1123
|
+
let logFd = null;
|
|
1124
|
+
if (isBackgroundProcess) {
|
|
1125
|
+
if (!restartLauncherScript) {
|
|
1126
|
+
throw new Error("Missing daemon restart launcher script");
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
mkdirSyncFn(DAEMON_LOG_DIR, { recursive: true });
|
|
1130
|
+
} catch {
|
|
1131
|
+
/* ignore */
|
|
1132
|
+
}
|
|
1133
|
+
logFd = fs.openSync(DAEMON_LOG_PATH, "a");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Step 4: graceful shutdown
|
|
1137
|
+
await shutdownDaemon("auto-update");
|
|
1138
|
+
|
|
1139
|
+
// Step 5: re-spawn (only in background/nohup mode)
|
|
1140
|
+
if (isBackgroundProcess) {
|
|
1141
|
+
const handoffToken = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1142
|
+
const handoffExpiresAt = Date.now() + 15_000;
|
|
1143
|
+
try {
|
|
1144
|
+
writeLockHandoff({
|
|
1145
|
+
handoffToken,
|
|
1146
|
+
handoffFromPid: process.pid,
|
|
1147
|
+
handoffExpiresAt,
|
|
1148
|
+
});
|
|
1149
|
+
const child = spawnFn(process.execPath, [restartLauncherScript, ...restartLauncherArgs], {
|
|
1150
|
+
detached: true,
|
|
1151
|
+
stdio: ["ignore", logFd, logFd],
|
|
1152
|
+
env: {
|
|
1153
|
+
...process.env,
|
|
1154
|
+
CONDUCTOR_LOCK_HANDOFF_TOKEN: handoffToken,
|
|
1155
|
+
CONDUCTOR_LOCK_HANDOFF_FROM_PID: String(process.pid),
|
|
1156
|
+
CONDUCTOR_LOCK_HANDOFF_EXPIRES_AT: String(handoffExpiresAt),
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
child.unref();
|
|
1160
|
+
log(`[auto-update] New daemon spawned (PID ${child.pid})`);
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
cleanupLock();
|
|
1163
|
+
exitFn(1);
|
|
1164
|
+
throw new Error(`Restart failed after shutdown: ${error?.message || error}`);
|
|
1165
|
+
} finally {
|
|
1166
|
+
if (typeof logFd === "number") {
|
|
1167
|
+
fs.closeSync(logFd);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
log(
|
|
1172
|
+
`[auto-update] Updated to ${targetVersion}. Foreground mode — please restart the daemon.`
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
exitFn(0);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
772
1178
|
const getActiveTaskIds = () => [
|
|
773
1179
|
...new Set([...activeTaskProcesses.keys(), ...activePtySessions.keys()]),
|
|
774
1180
|
];
|
|
@@ -942,6 +1348,61 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
942
1348
|
});
|
|
943
1349
|
}
|
|
944
1350
|
|
|
1351
|
+
function rejectCreateTaskDuringShutdown(payload, { sendAck = true } = {}) {
|
|
1352
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1353
|
+
const projectId = payload?.project_id ? String(payload.project_id) : "";
|
|
1354
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
1355
|
+
log(`Rejecting create_task for ${taskId || "unknown"}: daemon is shutting down`);
|
|
1356
|
+
if (sendAck) {
|
|
1357
|
+
sendAgentCommandAck({
|
|
1358
|
+
requestId,
|
|
1359
|
+
taskId,
|
|
1360
|
+
eventType: "create_task",
|
|
1361
|
+
accepted: false,
|
|
1362
|
+
}).catch(() => {});
|
|
1363
|
+
}
|
|
1364
|
+
if (!taskId || !projectId) {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
client
|
|
1368
|
+
.sendJson({
|
|
1369
|
+
type: "task_status_update",
|
|
1370
|
+
payload: {
|
|
1371
|
+
task_id: taskId,
|
|
1372
|
+
project_id: projectId,
|
|
1373
|
+
status: "KILLED",
|
|
1374
|
+
summary: "daemon shutting down",
|
|
1375
|
+
},
|
|
1376
|
+
})
|
|
1377
|
+
.catch((err) => {
|
|
1378
|
+
logError(`Failed to report shutdown rejection for ${taskId}: ${err?.message || err}`);
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function rejectCreatePtyTaskDuringShutdown(payload, { sendAck = true } = {}) {
|
|
1383
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1384
|
+
const projectId = payload?.project_id ? String(payload.project_id) : "";
|
|
1385
|
+
const ptySessionId = payload?.pty_session_id ? String(payload.pty_session_id) : null;
|
|
1386
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
1387
|
+
log(`Rejecting create_pty_task for ${taskId || "unknown"}: daemon is shutting down`);
|
|
1388
|
+
if (sendAck) {
|
|
1389
|
+
sendAgentCommandAck({
|
|
1390
|
+
requestId,
|
|
1391
|
+
taskId,
|
|
1392
|
+
eventType: "create_pty_task",
|
|
1393
|
+
accepted: false,
|
|
1394
|
+
}).catch(() => {});
|
|
1395
|
+
}
|
|
1396
|
+
sendTerminalEvent("terminal_error", {
|
|
1397
|
+
task_id: taskId || undefined,
|
|
1398
|
+
project_id: projectId || undefined,
|
|
1399
|
+
pty_session_id: ptySessionId,
|
|
1400
|
+
message: "daemon shutting down",
|
|
1401
|
+
}).catch((err) => {
|
|
1402
|
+
logError(`Failed to report PTY shutdown rejection for ${taskId || "unknown"}: ${err?.message || err}`);
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
945
1406
|
function sendPtyTransportSignal(payload) {
|
|
946
1407
|
return client.sendJson({
|
|
947
1408
|
type: "pty_transport_signal",
|
|
@@ -1430,6 +1891,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1430
1891
|
return;
|
|
1431
1892
|
}
|
|
1432
1893
|
|
|
1894
|
+
if (daemonShuttingDown) {
|
|
1895
|
+
rejectCreatePtyTaskDuringShutdown(payload);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1433
1899
|
if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
|
|
1434
1900
|
log(`Duplicate create_pty_task ignored for ${taskId}: task already active`);
|
|
1435
1901
|
sendAgentCommandAck({
|
|
@@ -1442,6 +1908,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1442
1908
|
}
|
|
1443
1909
|
|
|
1444
1910
|
let boundPath = await getProjectLocalPath(projectId);
|
|
1911
|
+
if (daemonShuttingDown) {
|
|
1912
|
+
rejectCreatePtyTaskDuringShutdown(payload);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1445
1915
|
let taskDir = normalizeOptionalString(launchConfig.cwd) || boundPath;
|
|
1446
1916
|
if (!taskDir) {
|
|
1447
1917
|
const now = new Date();
|
|
@@ -1534,6 +2004,20 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1534
2004
|
cwd: launchSpec.cwd,
|
|
1535
2005
|
env,
|
|
1536
2006
|
});
|
|
2007
|
+
if (daemonShuttingDown) {
|
|
2008
|
+
try {
|
|
2009
|
+
if (typeof pty?.kill === "function") {
|
|
2010
|
+
pty.kill("SIGTERM");
|
|
2011
|
+
}
|
|
2012
|
+
} catch (killError) {
|
|
2013
|
+
logError(`Failed to stop PTY task ${taskId} during shutdown: ${killError?.message || killError}`);
|
|
2014
|
+
}
|
|
2015
|
+
if (logStream) {
|
|
2016
|
+
logStream.end();
|
|
2017
|
+
}
|
|
2018
|
+
rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
1537
2021
|
const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
|
|
1538
2022
|
|
|
1539
2023
|
const startedAt = new Date().toISOString();
|
|
@@ -1800,6 +2284,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1800
2284
|
return;
|
|
1801
2285
|
}
|
|
1802
2286
|
|
|
2287
|
+
if (daemonShuttingDown) {
|
|
2288
|
+
if (event.type === "create_task") {
|
|
2289
|
+
rejectCreateTaskDuringShutdown(event.payload);
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (event.type === "create_pty_task") {
|
|
2293
|
+
rejectCreatePtyTaskDuringShutdown(event.payload);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
1803
2298
|
if (event.type === "create_task") {
|
|
1804
2299
|
handleCreateTask(event.payload);
|
|
1805
2300
|
return;
|
|
@@ -2101,6 +2596,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2101
2596
|
return;
|
|
2102
2597
|
}
|
|
2103
2598
|
|
|
2599
|
+
if (daemonShuttingDown) {
|
|
2600
|
+
rejectCreateTaskDuringShutdown(payload);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2104
2604
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
2105
2605
|
if (existingTaskRecord?.child) {
|
|
2106
2606
|
log(
|
|
@@ -2168,6 +2668,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2168
2668
|
|
|
2169
2669
|
// Check if project has a bound local path for this daemon
|
|
2170
2670
|
const boundPath = await getProjectLocalPath(projectId);
|
|
2671
|
+
if (daemonShuttingDown) {
|
|
2672
|
+
rejectCreateTaskDuringShutdown(payload, { sendAck: false });
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2171
2675
|
let taskDir;
|
|
2172
2676
|
let logPath;
|
|
2173
2677
|
let runTimestampPart = null;
|