@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 +64 -5
- package/bin/port-picker.mjs +28 -0
- package/package.json +1 -1
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 &&
|
|
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 &&
|
|
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
|
-
|
|
4041
|
-
|
|
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);
|
package/bin/port-picker.mjs
CHANGED
|
@@ -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