@bivy/bivy 0.1.0-staging.12 → 0.1.0-staging.13

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/bivy.mjs CHANGED
@@ -37,7 +37,7 @@ import { selectStaleSessions, sessionActivityMs } from "./prune-sessions.mjs";
37
37
  import { resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs";
38
38
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
39
39
  import { removeExcept } from "./uninstall-paths.mjs";
40
- import { findAvailablePort } from "./port-picker.mjs";
40
+ import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
41
41
 
42
42
  const selfScript = fileURLToPath(import.meta.url);
43
43
  const __dirname = path.dirname(selfScript);
@@ -2644,6 +2644,11 @@ async function installService(config) {
2644
2644
  console.log(c.yellow(`No background-service template for ${process.platform}. Use 'bivy start' instead.`));
2645
2645
  return false;
2646
2646
  }
2647
+ // Re-check the port before baking it into the unit: a second node on this
2648
+ // machine may have claimed the saved port since setup, and unlike `bivy setup`
2649
+ // this path used to write it in verbatim (so the node would fail to bind and
2650
+ // silently exit). reconcileNodePort persists any change into `config` first.
2651
+ await reconcileNodePort(config);
2647
2652
  fs.mkdirSync(path.dirname(file), { recursive: true });
2648
2653
  if (kind === "launchd") {
2649
2654
  fs.writeFileSync(file, plistContent(config));
@@ -2733,6 +2738,57 @@ function restartService() {
2733
2738
  return false;
2734
2739
  }
2735
2740
 
2741
+ // Is `config.port` currently held by *this install's own* node, as opposed to a
2742
+ // foreign node (a second OS user's, a staging+prod pair) or an unrelated
2743
+ // process? We ask whoever is listening for its data dir via /api/status and
2744
+ // compare to ours: our own node reports the same appDir; a foreign bivy node
2745
+ // reports a different one (or rejects our device token and throws); a non-bivy
2746
+ // process makes the request throw. Only a match counts as "ours", so port
2747
+ // reconciliation never relocates a node off a port it legitimately owns.
2748
+ async function portHeldByOwnNode(config) {
2749
+ try {
2750
+ const status = await localApi(config, "/api/status");
2751
+ return Boolean(status?.appDir) && path.resolve(status.appDir) === path.resolve(appDir);
2752
+ } catch {
2753
+ return false;
2754
+ }
2755
+ }
2756
+
2757
+ // Re-validate the saved node port before it is baked into a service unit or
2758
+ // restarted into, rolling it forward (and persisting the new value) if a second
2759
+ // node on this machine has claimed it since setup. An explicit `PORT=…` is
2760
+ // honored verbatim. Returns true when the port changed, so the caller knows to
2761
+ // rewrite the unit whose PORT env is now stale. See reconcilePort() for the
2762
+ // decision rules. Our own running node is never treated as a collision.
2763
+ async function reconcileNodePort(config) {
2764
+ const current = Number(config.port) || 4317;
2765
+ const chosen = await reconcilePort(current, nodeBindHost(), {
2766
+ explicitPort: Number(process.env.PORT),
2767
+ heldByOwnNode: () => portHeldByOwnNode(config),
2768
+ });
2769
+ if (chosen === current) return false;
2770
+ console.log(
2771
+ c.yellow(`Port ${current} is already in use by another node on this machine — moving this node to ${chosen}.`),
2772
+ );
2773
+ config.port = chosen;
2774
+ saveConfig(config);
2775
+ return true;
2776
+ }
2777
+
2778
+ // Restart the background service, but first make sure the port it will bind is
2779
+ // still free. If a foreign node grabbed it while ours was down, relocate: the
2780
+ // unit's baked-in PORT is now stale, so a full reinstall rewrites it (and
2781
+ // reloads/relaunches). Otherwise a plain restart. Returns true if the service
2782
+ // was (re)started. Used by `bivy restart` and `bivy update` — the paths that
2783
+ // previously trusted the saved port verbatim.
2784
+ async function restartServiceReconciled(config) {
2785
+ const { file } = servicePaths();
2786
+ if (fs.existsSync(file) && (await reconcileNodePort(config))) {
2787
+ return await installService(config);
2788
+ }
2789
+ return restartService();
2790
+ }
2791
+
2736
2792
  // How long a restart triggered by `bivy update`/`bivy restart` will wait for
2737
2793
  // in-flight agent turns to finish before restarting anyway. Restarting the
2738
2794
  // service SIGTERMs every open session's agent process; done mid-turn that
@@ -3555,7 +3611,7 @@ async function runUpdate(args = []) {
3555
3611
  await ensureBundledAgents();
3556
3612
  const config = loadConfig();
3557
3613
  await waitForIdleSessions(config, { skip: skipWait });
3558
- if (config.service && restartService()) {
3614
+ if (config.service && (await restartServiceReconciled(config))) {
3559
3615
  console.log(c.green("Updated and restarted the background service."));
3560
3616
  } else {
3561
3617
  console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
@@ -3591,7 +3647,7 @@ async function runUpdate(args = []) {
3591
3647
  await ensureBundledAgents();
3592
3648
  const config = loadConfig();
3593
3649
  await waitForIdleSessions(config, { skip: skipWait });
3594
- if (config.service && restartService()) {
3650
+ if (config.service && (await restartServiceReconciled(config))) {
3595
3651
  console.log(c.green("Updated and restarted the background service."));
3596
3652
  } else {
3597
3653
  console.log(c.green("Updated. Run 'bivy start' (or restart your service) to apply."));
@@ -4037,8 +4093,10 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4037
4093
  console.log("Usage: bivy restart [--force|--no-wait]\n\nRestart the background service. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait.");
4038
4094
  break;
4039
4095
  }
4040
- await waitForIdleSessions(loadConfig(), { skip: args.includes("--force") || args.includes("--no-wait") });
4041
- if (restartService()) {
4096
+ {
4097
+ const restartConfig = loadConfig();
4098
+ await waitForIdleSessions(restartConfig, { skip: args.includes("--force") || args.includes("--no-wait") });
4099
+ if (await restartServiceReconciled(restartConfig)) {
4042
4100
  console.log(c.green("Service restarted."));
4043
4101
  } else if (fs.existsSync(servicePaths().file)) {
4044
4102
  console.error(c.red("Failed to restart the background service."));
@@ -4051,6 +4109,7 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4051
4109
  console.log(c.yellow("No background service to restart. Run 'bivy setup' to install one so the node stays reachable."));
4052
4110
  process.exitCode = 1;
4053
4111
  }
4112
+ }
4054
4113
  break;
4055
4114
  case "status":
4056
4115
  await cmdStatus(args);
@@ -38,3 +38,31 @@ export async function findAvailablePort(preferred, host, isFree = portIsFree) {
38
38
  }
39
39
  return preferred;
40
40
  }
41
+
42
+ // Decide which port a node should (re)start on, re-validating a *saved* port
43
+ // right before it is baked into a service unit or restarted into. `bivy setup`
44
+ // already auto-avoids collisions, but `bivy service install`, `bivy restart` and
45
+ // `bivy update` used to trust the saved port verbatim — so a node whose 4317 had
46
+ // since been claimed by a second node on the same machine would fail to bind
47
+ // (EADDRINUSE) and silently exit. Rules, in order:
48
+ // - an explicit `PORT=…` override wins verbatim (the operator pinned it);
49
+ // - a free port is kept as-is (the common case — no collision);
50
+ // - a port still held by *our own* node is kept (a plain restart must not
51
+ // relocate a node off the port it already owns — `heldByOwnNode` tells ours
52
+ // apart from a foreign occupant);
53
+ // - otherwise roll forward to the first free port above it.
54
+ // `isFree` and `heldByOwnNode` are injectable so the decision is unit-testable
55
+ // without sockets or a live node. Returns the chosen port; callers compare it to
56
+ // the current one to decide whether to persist and rewrite the unit.
57
+ export async function reconcilePort(current, host, {
58
+ explicitPort = 0,
59
+ isFree = portIsFree,
60
+ heldByOwnNode = async () => false,
61
+ } = {}) {
62
+ const pinned = Number(explicitPort);
63
+ if (pinned) return pinned;
64
+ const port = Number(current) || 4317;
65
+ if (await isFree(port, host)) return port;
66
+ if (await heldByOwnNode(port, host)) return port;
67
+ return findAvailablePort(port + 1, host, isFree);
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.1.0-staging.12",
3
+ "version": "0.1.0-staging.13",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",