@love-moon/conductor-cli 0.2.20 → 0.2.21

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/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(__dirname, "..", "bin", "conductor-fire.js");
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 pid = parseInt(readFileSyncFn(LOCK_FILE, "utf-8"), 10);
406
- if (!Number.isNaN(pid)) {
516
+ const lockState = readLockState();
517
+ const pid = lockState?.pid;
518
+ if (pid) {
519
+ const handoffMatched = hasMatchingLockHandoff(lockState);
407
520
  try {
408
- const alive = isProcessAlive(pid);
409
- if (alive) {
410
- if (config.FORCE) {
411
- log(`Force enabled: stopping existing daemon PID ${pid}`);
412
- try {
413
- killFn(pid, "SIGTERM");
414
- } catch (killErr) {
415
- if (!killErr || killErr.code !== "ESRCH") {
416
- logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
417
- return exitAndReturn(1);
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
- try {
421
- if (isProcessAlive(pid)) {
422
- logError(`Existing daemon PID ${pid} is still running; please stop it manually.`);
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
- } catch (checkErr) {
426
- logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
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
- logError(`Daemon already running with PID ${pid}`);
433
- return exitAndReturn(1);
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
- logError(`Daemon already running with PID ${pid} (access denied)`);
441
- return exitAndReturn(1);
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 pid = parseInt(readFileSyncFn(LOCK_FILE, "utf-8"), 10);
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;