@feigi/fleet-ctl 3.18.9 → 3.18.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.18.9",
3
+ "version": "3.18.11",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2045,11 +2045,17 @@ function muteHolder() {
2045
2045
  // degrade arm has to stay fatal, exactly as it was before #1585 introduced
2046
2046
  // scanning at all.
2047
2047
  //
2048
- // 8123 just has to be held by SOMEONE — us, or whatever already had it —
2049
- // so the blocker is a bare net server.
2050
- test("CLI: a derived port held by anything is fatal on the degrade arm — there is no identity to scan for", async () => {
2048
+ // The blocker is a bare net server, and it has to be OURS: a port some
2049
+ // outside process already holds can be let go between this bind and
2050
+ // board's own, and board then binds 8123 and serves (#2522). The degrade
2051
+ // arm derives no port but 8123, so there is none to move to: when an
2052
+ // outside process holds it, the row skips rather than ride that process's
2053
+ // timing.
2054
+ test("CLI: a derived port held by anything is fatal on the degrade arm — there is no identity to scan for", async (t) => {
2051
2055
  const blocker = createServer();
2052
- await new Promise((res) => { blocker.once("error", res); blocker.listen(8123, res); });
2056
+ const failure = await new Promise((res) => { blocker.once("error", res); blocker.listen(8123, () => res(null)); });
2057
+ if (failure?.code === "EADDRINUSE") return t.skip(heldOutside(8123));
2058
+ if (failure) throw failure;
2053
2059
  const nobin = tempDir("board-nobin-");
2054
2060
  const cwd = tempDir("board-serve-");
2055
2061
  try {
@@ -2621,19 +2627,47 @@ test("cockpitPorts: the window wraps at the top of the range rather than leaving
2621
2627
 
2622
2628
  // A holder serving `dir` on a real ephemeral port, through the same
2623
2629
  // server-creation seam the cockpit itself uses — no second I/O surface, in
2624
- // the tests either.
2630
+ // the tests either. A port someone else already holds comes back as
2631
+ // `port: null`; any other bind failure rejects.
2625
2632
  async function holderOn(dir, port = 0) {
2626
2633
  const server = createBoardServer(dir);
2627
- const bound = await new Promise((res) => { server.once("error", () => res(null)); server.listen(port, () => res(server.address().port)); });
2634
+ const bound = await new Promise((res, rej) => {
2635
+ server.once("error", (e) => (e.code === "EADDRINUSE" ? res(null) : rej(e)));
2636
+ server.listen(port, () => res(server.address().port));
2637
+ });
2628
2638
  return { server, port: bound };
2629
2639
  }
2630
2640
 
2641
+ // The skip reason for a row that could not bind its fixed port itself
2642
+ // (#2522): the outside process holding it can let go before the launch
2643
+ // binds, and the launch then takes the port the row needed held.
2644
+ function heldOutside(port) {
2645
+ return `port ${port} is held by a process outside this test, which can release it before the launch binds`;
2646
+ }
2647
+
2631
2648
  const boardDir = (payload) => {
2632
2649
  const dir = tempDir("board-holder-");
2633
2650
  if (payload !== undefined) writeFileSync(join(dir, "board.json"), payload);
2634
2651
  return dir;
2635
2652
  };
2636
2653
 
2654
+ // holderOn's other half: only EADDRINUSE reads as "someone else holds it". A
2655
+ // listen() that fails for any other reason must reject, not come back as
2656
+ // `port: null`, which the rows that skip on a null port would then report as
2657
+ // a port held from outside. A unix-socket path under a directory that does
2658
+ // not exist fails through the same 'error' event without privileges; the
2659
+ // code it fails with differs by platform, and that it is not EADDRINUSE is
2660
+ // the point.
2661
+ test("holderOn: a bind failure that is not EADDRINUSE rejects instead of reading as a held port", async () => {
2662
+ const dir = boardDir();
2663
+ try {
2664
+ await assert.rejects(
2665
+ holderOn(dir, join(dir, "no-such-dir", "s.sock")),
2666
+ (e) => e instanceof Error && e.code !== undefined && e.code !== "EADDRINUSE",
2667
+ );
2668
+ } finally { rmSync(dir, { recursive: true, force: true }); }
2669
+ });
2670
+
2637
2671
  // The half that must ACCEPT: a real board payload naming a workspace is the
2638
2672
  // one answer the probe has to believe, and everything below is a way of not
2639
2673
  // believing it. A probe that only ever returns null passes every negative
@@ -2908,13 +2942,14 @@ for (const [platform, launchers, ran, warns] of [
2908
2942
  // workspace field distinguishes it from the cockpit reused above. A
2909
2943
  // handshake that asks "did anyone answer" rather than "whose board is this"
2910
2944
  // adopts it, and this run gets no board of its own at all.
2911
- test("CLI: a holder reporting a different workspace is not adopted — the launch serves elsewhere", async () => {
2945
+ test("CLI: a holder reporting a different workspace is not adopted — the launch serves elsewhere", async (t) => {
2912
2946
  const bin = gitOnlyPath(), repo = gitRepo("board-ws-foreign-");
2913
2947
  const { port: derived } = resolveCockpitInstance({ cwd: repo, gitCommonDir: join(repo, ".git") });
2914
2948
  const dir = boardDir(JSON.stringify({ tickets: [], workspace: "/some/other/workspace" }));
2915
- const { server } = await holderOn(dir, derived);
2916
- const launch = serveProcess(repo, bin, ["--interval", "3600"]);
2949
+ const { server, port: held } = await holderOn(dir, derived);
2950
+ const launch = held === null ? null : serveProcess(repo, bin, ["--interval", "3600"]);
2917
2951
  try {
2952
+ if (!launch) return t.skip(heldOutside(derived));
2918
2953
  const url = await withTimeout(launch.url, 20000, "the launch to step over the foreign holder");
2919
2954
  assert.notEqual(url, `http://localhost:${derived}`, "the launch adopted a board belonging to another workspace");
2920
2955
  const window = cockpitPorts({ port: derived, derived: true });
@@ -2924,7 +2959,7 @@ test("CLI: a holder reporting a different workspace is not adopted — the launc
2924
2959
  await untilBoardJson(url);
2925
2960
  assert.equal((await (await fetch(`${url}/board.json`)).json()).workspace, realpathSync(repo));
2926
2961
  } finally {
2927
- launch.p.kill("SIGKILL");
2962
+ launch?.p.kill("SIGKILL");
2928
2963
  server.close(() => {});
2929
2964
  for (const d of [bin, repo, dir]) rmSync(d, { recursive: true, force: true });
2930
2965
  }
@@ -2944,16 +2979,20 @@ test("CLI: a holder reporting a different workspace is not adopted — the launc
2944
2979
  // PROBE_TIMEOUT_MS). That is the bound doing its job rather than a hang,
2945
2980
  // and it is why this row is the only slow one here — the rows that need a
2946
2981
  // holder to really answer use serveProcess(), which leaves the loop free.
2947
- test("CLI: an exhausted derived range exits non-zero and names the ports it tried", async () => {
2982
+ test("CLI: an exhausted derived range exits non-zero and names the ports it tried", async (t) => {
2948
2983
  const bin = gitOnlyPath(), repo = gitRepo("board-ws-full-");
2949
2984
  const instance = resolveCockpitInstance({ cwd: repo, gitCommonDir: join(repo, ".git") });
2950
2985
  const ports = cockpitPorts(instance);
2951
2986
  const dir = boardDir(undefined);
2952
2987
  const blockers = [];
2953
2988
  try {
2954
- // A port already held by something else is held either way — what this
2955
- // row needs is the range full, not our own socket on every port in it.
2956
- for (const p of ports) blockers.push((await holderOn(dir, p)).server);
2989
+ // The range full of OUR sockets: a port something else already holds
2990
+ // can be let go before the launch binds, and the launch then serves.
2991
+ for (const p of ports) {
2992
+ const holder = await holderOn(dir, p);
2993
+ if (holder.port === null) return t.skip(heldOutside(p));
2994
+ blockers.push(holder.server);
2995
+ }
2957
2996
  const r = serveSync(repo, bin, ["--interval", "3600"], 30000);
2958
2997
  assert.equal(r.status, 2, `an exhausted range must refuse, not hang and not succeed: ${r.stderr}`);
2959
2998
  for (const p of ports) assert.match(r.stderr, new RegExp(`\\b${p}\\b`), `the refusal does not name ${p}: ${r.stderr}`);
@@ -514,7 +514,7 @@ const FILES = [
514
514
  {
515
515
  path: ["scripts", "ledger.mjs"],
516
516
  stale: [/fleet-plugin-design\.md:200/, /candidates\.mjs:279/],
517
- live: ["fleet-plugin-design.md's", "refuseIfCapped"],
517
+ live: ["refuseIfCapped"],
518
518
  },
519
519
  {
520
520
  path: ["scripts", "ledger.test.mjs"],