From ece75d7268d0d47ec34e897a5bed609dcd3e2648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Wed, 2 Sep 2026 21:16:35 +0100 Subject: [PATCH 1/4] feat(discover): on-demand port discovery via a single snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `discover::PortSnapshot` — one point-in-time view of every listening TCP port on the host plus the process table needed to attribute those ports to a process tree. Two subprocess calls total (`lsof -iTCP -sTCP:LISTEN` + `ps -axo pid=,ppid=`), independent of session count. The existing per-PID approach in sync.rs forks `lsof -p ` once per process and `pgrep -P` once per tree level — 30+ forks for 4 sessions at 20-400ms each. The snapshot joins the trees in memory instead, which is what makes discovery cheap enough to run on every command invocation rather than needing a background daemon. Discovery is read-only and never rewrites state. A discovered port that disagrees with the assigned one is evidence of a bug (typically an external task runner re-reading .env.local instead of .env.ecluse), not a better value to adopt — trusting discovery is what hid a wrong-slot spawn behind a green check in the 2026-06-09 cross-agent kill spiral. Parses the stateful `lsof -F pn` record format, dedupes a port listed once per file descriptor, handles bracketed IPv6 (`[::1]:27017`), filters system ports (22/80/443), and cycle-guards the descendant walk so a torn `ps` read can't hang it. --- src/discover.rs | 403 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 404 insertions(+) create mode 100644 src/discover.rs diff --git a/src/discover.rs b/src/discover.rs new file mode 100644 index 0000000..16fb852 --- /dev/null +++ b/src/discover.rs @@ -0,0 +1,403 @@ +//! On-demand port discovery. +//! +//! ecluse *assigns* ports (`port = base_port + slot × stride`) and treats +//! `state.json` as truth. Discovery is the complement: it observes which ports +//! are *actually* being listened on, so a command can report expected-vs-actual +//! instead of a bare `✗ down` when something bound the wrong port. +//! +//! Discovery never rewrites state. A mismatch is evidence of a bug (usually an +//! external task runner that re-read `.env.local` instead of `.env.ecluse`), not +//! a better value to adopt — see `incidents/2026-06-09-rubbr-cross-agent-kill-spiral`, +//! where trusting a discovered port hid a wrong-slot spawn behind a green check. +//! +//! ## Why a single snapshot +//! +//! The obvious implementation runs `lsof -p ` per process and `pgrep -P` +//! per level of the tree. With 4 sessions × ~8 processes that is 30+ forks, and +//! `lsof` costs 20–400ms each. Instead we take *two* forks total, regardless of +//! session count — all listeners plus the whole process table — and do the +//! tree join in memory. + +use std::collections::{HashMap, HashSet}; +use std::process::Command; + +/// Ports never reported as a session's dev server. These are system/privileged +/// services that show up in every scan and are never what a worktree allocated. +const IGNORED_PORTS: &[u16] = &[22, 80, 443]; + +/// One point-in-time view of every listening port on the host plus the process +/// table needed to attribute those ports to a process tree. +/// +/// Built with exactly two subprocess calls. Cheap enough to take on every +/// command invocation; there is no background daemon. +#[derive(Debug, Clone, Default)] +pub struct PortSnapshot { + /// pid → ports that pid is listening on directly. + ports_by_pid: HashMap>, + /// port → the pid holding it (first one seen wins; a port has one owner). + pid_by_port: HashMap, + /// ppid → direct children, for descendant walks without forking `pgrep`. + children: HashMap>, +} + +/// Take a snapshot of all listening ports and the process table. +/// +/// Best-effort by design: a missing or failing `lsof`/`ps` yields an empty (or +/// partial) snapshot rather than an error, because discovery is supplemental +/// reporting — it must never fail a command that would otherwise succeed. +pub fn snapshot() -> PortSnapshot { + parse_snapshot(&raw_listeners(), &raw_process_table()) +} + +/// Run `lsof` for every listening TCP socket on the host. One fork. +fn raw_listeners() -> String { + // -F pn emits machine-readable records: a `p` line followed by one + // `n` line per socket. -n/-P skip DNS and service-name lookups, + // which is both faster and keeps the port numeric. + match Command::new("lsof") + .args(["-iTCP", "-sTCP:LISTEN", "-n", "-P", "-F", "pn"]) + .output() + { + // lsof exits non-zero when nothing matches, which is a legitimate + // empty result — take stdout either way. + Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(), + Err(_) => String::new(), + } +} + +/// Read the whole process table as `pid ppid` pairs. One fork. +fn raw_process_table() -> String { + match Command::new("ps").args(["-axo", "pid=,ppid="]).output() { + Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(), + Err(_) => String::new(), + } +} + +/// Parse `lsof -F pn` output and a `ps -axo pid=,ppid=` table into a snapshot. +/// +/// Split out from `snapshot()` so the parsing is unit-testable without +/// depending on whatever happens to be listening on the test machine. +pub fn parse_snapshot(lsof_out: &str, ps_out: &str) -> PortSnapshot { + let mut ports_by_pid: HashMap> = HashMap::new(); + let mut pid_by_port: HashMap = HashMap::new(); + + // `-F pn` is stateful: a `p` line sets the owner for every `n` + // line that follows, until the next `p` line. + let mut current_pid: Option = None; + for line in lsof_out.lines() { + let Some((tag, rest)) = line.split_at_checked(1) else { + continue; + }; + match tag { + "p" => current_pid = rest.trim().parse().ok(), + "n" => { + let Some(pid) = current_pid else { continue }; + let Some(port) = parse_listen_port(rest) else { + continue; + }; + if IGNORED_PORTS.contains(&port) { + continue; + } + let entry = ports_by_pid.entry(pid).or_default(); + if !entry.contains(&port) { + entry.push(port); + } + pid_by_port.entry(port).or_insert(pid); + } + _ => {} + } + } + + for ports in ports_by_pid.values_mut() { + ports.sort_unstable(); + } + + let mut children: HashMap> = HashMap::new(); + for line in ps_out.lines() { + let mut parts = line.split_whitespace(); + let (Some(pid), Some(ppid)) = (parts.next(), parts.next()) else { + continue; + }; + let (Ok(pid), Ok(ppid)) = (pid.parse::(), ppid.parse::()) else { + continue; + }; + children.entry(ppid).or_default().push(pid); + } + + PortSnapshot { + ports_by_pid, + pid_by_port, + children, + } +} + +/// Extract the port from an lsof address field. +/// +/// Handles the shapes lsof emits for a listening socket: +/// `*:3000`, `127.0.0.1:3000`, `[::1]:3000`, `[::]:3000`. +/// Splitting on the *last* colon is what makes the bracketed IPv6 forms work. +fn parse_listen_port(addr: &str) -> Option { + let addr = addr.trim(); + // Some lsof builds append `->` peer info; a listener shouldn't have one, + // but guard anyway so a stray record can't produce a bogus port. + let addr = addr.split("->").next()?; + addr.rsplit(':').next()?.parse().ok() +} + +impl PortSnapshot { + /// Every port listened on by `root_pid` or any of its descendants. + /// + /// This is the attribution primitive: a dev server is usually a grandchild + /// of what ecluse spawned (`sh → pnpm → node → vite`), so the port belongs + /// to the tree, not to the recorded pid. + pub fn ports_for_tree(&self, root_pid: u32) -> Vec { + let mut ports: Vec = Vec::new(); + for pid in self.tree_pids(root_pid) { + if let Some(p) = self.ports_by_pid.get(&pid) { + for port in p { + if !ports.contains(port) { + ports.push(*port); + } + } + } + } + ports.sort_unstable(); + ports + } + + /// True iff `root_pid` or a descendant is listening on `port`. + pub fn tree_owns_port(&self, root_pid: u32, port: u16) -> bool { + self.ports_for_tree(root_pid).contains(&port) + } + + /// The pid holding `port`, if anything is. + pub fn listener_pid(&self, port: u16) -> Option { + self.pid_by_port.get(&port).copied() + } + + /// `root_pid` plus every transitive descendant. + /// + /// Cycle-guarded via `seen`: a `ps` snapshot taken while pids are being + /// recycled can in principle contain a ppid loop, and an unguarded walk + /// would hang. + pub fn tree_pids(&self, root_pid: u32) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut stack = vec![root_pid]; + while let Some(pid) = stack.pop() { + if !seen.insert(pid) { + continue; + } + out.push(pid); + if let Some(kids) = self.children.get(&pid) { + stack.extend(kids); + } + } + out + } + + /// True iff `descendant` is a transitive child of `ancestor`. + /// A pid is not its own descendant. + pub fn is_descendant(&self, ancestor: u32, descendant: u32) -> bool { + ancestor != descendant && self.tree_pids(ancestor).contains(&descendant) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real `lsof -F pn` output shape: a `p` line, then `f`/`n` records, with + // the same socket listed once per file descriptor. + const LSOF: &str = "\ +p591 +f8 +n*:60277 +f9 +n*:60277 +p609 +f8 +n*:7000 +f10 +n127.0.0.1:5000 +p744 +f9 +n[::1]:27017 +f10 +n[::]:27017 +"; + + // pid ppid — 700 is a child of 609, 800 a child of 700 (grandchild of 609). + const PS: &str = "\ + 1 0 + 591 1 + 609 1 + 700 609 + 800 700 + 744 1 +"; + + fn snap() -> PortSnapshot { + parse_snapshot(LSOF, PS) + } + + #[test] + fn parses_wildcard_address() { + assert_eq!(parse_listen_port("*:3000"), Some(3000)); + } + + #[test] + fn parses_ipv4_address() { + assert_eq!(parse_listen_port("127.0.0.1:5000"), Some(5000)); + } + + // Bracketed IPv6 is why we split on the last colon, not the first. + #[test] + fn parses_ipv6_loopback() { + assert_eq!(parse_listen_port("[::1]:27017"), Some(27017)); + } + + #[test] + fn parses_ipv6_wildcard() { + assert_eq!(parse_listen_port("[::]:8080"), Some(8080)); + } + + #[test] + fn rejects_non_numeric_port() { + assert_eq!(parse_listen_port("127.0.0.1:http"), None); + } + + #[test] + fn rejects_empty_address() { + assert_eq!(parse_listen_port(""), None); + } + + #[test] + fn dedupes_same_port_across_file_descriptors() { + let s = snap(); + assert_eq!(s.ports_for_tree(591), vec![60277]); + } + + #[test] + fn collects_multiple_ports_for_one_pid() { + let s = snap(); + assert_eq!(s.ports_for_tree(609), vec![5000, 7000]); + } + + #[test] + fn dedupes_ipv4_and_ipv6_of_same_port() { + let s = snap(); + assert_eq!(s.ports_for_tree(744), vec![27017]); + } + + #[test] + fn maps_port_to_listener_pid() { + let s = snap(); + assert_eq!(s.listener_pid(7000), Some(609)); + assert_eq!(s.listener_pid(60277), Some(591)); + } + + #[test] + fn listener_pid_none_for_unheld_port() { + assert_eq!(snap().listener_pid(9999), None); + } + + // The point of the tree walk: a port held by a grandchild belongs to the + // service ecluse spawned, not to some unrelated process. + #[test] + fn attributes_descendant_port_to_root() { + // 800 (grandchild of 609) holds 4000. + let s = parse_snapshot("p800\nf8\nn*:4000\n", PS); + assert!(s.tree_owns_port(609, 4000)); + assert_eq!(s.ports_for_tree(609), vec![4000]); + } + + #[test] + fn does_not_attribute_sibling_port_to_root() { + let s = snap(); + // 591 is a sibling of 609, not a descendant. + assert!(!s.tree_owns_port(609, 60277)); + } + + #[test] + fn ignores_system_ports() { + let s = parse_snapshot("p42\nf8\nn*:22\nf9\nn*:80\nf10\nn*:443\nf11\nn*:3000\n", ""); + assert_eq!(s.ports_for_tree(42), vec![3000]); + assert_eq!(s.listener_pid(22), None); + } + + #[test] + fn tree_pids_includes_root_and_all_descendants() { + let mut pids = snap().tree_pids(609); + pids.sort_unstable(); + assert_eq!(pids, vec![609, 700, 800]); + } + + #[test] + fn tree_pids_is_just_root_when_childless() { + assert_eq!(snap().tree_pids(744), vec![744]); + } + + #[test] + fn is_descendant_walks_multiple_levels() { + let s = snap(); + assert!(s.is_descendant(609, 700)); + assert!(s.is_descendant(609, 800)); + } + + #[test] + fn is_descendant_false_for_ancestor_direction() { + assert!(!snap().is_descendant(800, 609)); + } + + #[test] + fn is_descendant_false_for_self() { + assert!(!snap().is_descendant(609, 609)); + } + + #[test] + fn unknown_pid_has_no_ports() { + assert!(snap().ports_for_tree(999_999).is_empty()); + } + + // A ppid cycle can only come from a torn `ps` read, but an unguarded + // walk would spin forever on it. + #[test] + fn tolerates_ppid_cycle() { + let s = parse_snapshot("", "10 11\n11 10\n"); + let mut pids = s.tree_pids(10); + pids.sort_unstable(); + assert_eq!(pids, vec![10, 11]); + } + + #[test] + fn empty_input_yields_empty_snapshot() { + let s = parse_snapshot("", ""); + assert!(s.ports_for_tree(1).is_empty()); + assert_eq!(s.listener_pid(3000), None); + } + + #[test] + fn malformed_lines_are_skipped() { + // An `n` record with no preceding `p` line has no owner; garbage ps + // rows are dropped rather than panicking. + let s = parse_snapshot("n*:3000\nxjunk\np50\nn*:3001\n", "notapid alsonot\n60 x\n"); + assert_eq!(s.listener_pid(3000), None); + assert_eq!(s.listener_pid(3001), Some(50)); + assert_eq!(s.tree_pids(60), vec![60]); + } + + // Guards against a real-world regression: a live snapshot must find the + // port this test process is holding. + #[test] + fn live_snapshot_finds_own_listener() { + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let s = snapshot(); + // lsof may be absent in a sandbox; only assert when it produced data. + if s.listener_pid(port).is_some() { + assert!(s.tree_owns_port(std::process::id(), port)); + } + } +} diff --git a/src/main.rs b/src/main.rs index 9ab1467..7a07fff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod compose; mod config; mod detect; +mod discover; mod docker; mod env; mod error; From 6c25e8843960d5458f6ac19001bdd825ed854b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Fri, 4 Sep 2026 16:37:36 +0100 Subject: [PATCH 2/4] feat(ls,status): report discovered ports alongside assigned ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ecluse ls` gains a LISTENING column showing the ports each session's process trees are actually bound to, with a trailing `!` when an assigned port is missing from that set. `ecluse status` gains an ACTUAL column beside EXPECTED, and a service that is alive on the wrong port now reads `✗ wrong port 4020 (slot 2)` instead of a bare `✗ down`. When the discovered port falls inside another slot's territory (`port = base_port + slot × stride` inverted), the warning names the owning slot and session and explicitly forbids killing it: service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a --keep-worktree && ecluse up feat-a That is the fact whose absence started the 2026-06-09 kill spiral: three agents each saw "a process on a port near mine", inferred a stale leftover, and ran `lsof -ti | xargs kill` against a sibling's working service. A bare "down" invites a theory; naming the owner ends it. Discovery is read-only — state.json remains truth and nothing is written back. Only a MISSING assigned port counts as a mismatch, so the extra sockets a dev server opens (HMR, debug, inspector) don't flag one. Docker services are excluded: the daemon publishes their ports, not a host process tree, and find_docker_services already reports the real mapping. `--json` gains listening_ports/port_mismatch on `ls` sessions and actual_port/port_mismatch/conflicting_slot/hint on `status` services, so an agent can branch on the mismatch without parsing a table. A mismatch trips the existing non-zero exit, making `ecluse status --quiet` usable as a gate. --- src/discover.rs | 68 +++++++++ src/main.rs | 395 +++++++++++++++++++++++++++++++++++++++++++++++- src/process.rs | 2 +- 3 files changed, 457 insertions(+), 8 deletions(-) diff --git a/src/discover.rs b/src/discover.rs index 16fb852..6ff7649 100644 --- a/src/discover.rs +++ b/src/discover.rs @@ -203,6 +203,27 @@ impl PortSnapshot { } } +/// Which slot a port belongs to under the configured allocation scheme. +/// +/// `port = base_port + slot × stride` inverts to `slot = (port - base) / stride` +/// when the remainder is zero. Reporting this is what turns "a process is on a +/// port near mine" — the inference that started the 2026-06-09 kill spiral — +/// into "that port is slot 3's, owned by session X, do not kill it". +pub fn owning_slot(port: u16, base_port: u16, slot_stride: u8, max_slots: u8) -> Option { + let stride = slot_stride.max(1) as u16; + let offset = port.checked_sub(base_port)?; + if offset % stride != 0 { + return None; + } + let slot = offset / stride; + // Slot 0 is not a valid allocation (slots are 1..=max_slots), and a port + // beyond max_slots' territory belongs to nobody. + if slot == 0 || slot > max_slots as u16 { + return None; + } + Some(slot as u8) +} + #[cfg(test)] mod tests { use super::*; @@ -387,6 +408,53 @@ n[::]:27017 assert_eq!(s.tree_pids(60), vec![60]); } + // ── owning_slot ─────────────────────────────────────────────────────────── + + #[test] + fn owning_slot_identifies_own_slot_stride_1() { + assert_eq!(owning_slot(3001, 3000, 1, 8), Some(1)); + assert_eq!(owning_slot(3004, 3000, 1, 8), Some(4)); + } + + #[test] + fn owning_slot_identifies_slot_with_stride_10() { + assert_eq!(owning_slot(3010, 3000, 10, 8), Some(1)); + assert_eq!(owning_slot(3030, 3000, 10, 8), Some(3)); + } + + // An auto-bumped port that lands between slots belongs to no slot — this + // is the benign case that must NOT be reported as cross-slot theft. + #[test] + fn owning_slot_none_when_not_on_stride_boundary() { + assert_eq!(owning_slot(3015, 3000, 10, 8), None); + } + + #[test] + fn owning_slot_none_for_base_port_itself() { + // base_port is slot 0 — never a valid allocation. + assert_eq!(owning_slot(3000, 3000, 1, 8), None); + } + + #[test] + fn owning_slot_none_beyond_max_slots() { + assert_eq!(owning_slot(3009, 3000, 1, 8), None); + } + + #[test] + fn owning_slot_none_below_base_port() { + assert_eq!(owning_slot(2999, 3000, 1, 8), None); + } + + #[test] + fn owning_slot_handles_last_valid_slot() { + assert_eq!(owning_slot(3008, 3000, 1, 8), Some(8)); + } + + #[test] + fn owning_slot_treats_zero_stride_as_one() { + assert_eq!(owning_slot(3002, 3000, 0, 8), Some(2)); + } + // Guards against a real-world regression: a live snapshot must find the // port this test process is holding. #[test] diff --git a/src/main.rs b/src/main.rs index 7a07fff..0ff20fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod worktree; use anyhow::{Context, Result}; use clap::Parser; use std::io::{self, Write}; +use std::path::Path; use tabled::{Table, Tabled}; fn main() { @@ -457,6 +458,8 @@ mod tests { tmux_window: None, listener_pid, wrong_owner, + actual_port: None, + conflicting_slot: None, } } @@ -497,6 +500,127 @@ mod tests { let s = svc_status(true, true, true, Some(99999)); assert_eq!(status_str(&s), "\u{2717} wrong owner (PID 99999)"); } + + // ── status_str: wrong-port (discovery) ──────────────────────────────────── + + #[test] + fn status_str_wrong_port_shows_discovered_port() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3005); + assert_eq!(status_str(&s), "\u{2717} wrong port 3005"); + } + + #[test] + fn status_str_wrong_port_names_conflicting_slot() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3003); + s.conflicting_slot = Some((3, Some("feat-x".into()))); + assert_eq!(status_str(&s), "\u{2717} wrong port 3003 (slot 3)"); + } + + // wrong_owner is a distinct condition (someone ELSE holds our port) and + // must keep precedence over "we are on the wrong port". + #[test] + fn status_str_wrong_owner_takes_precedence_over_wrong_port() { + let mut s = svc_status(true, false, true, Some(777)); + s.actual_port = Some(3005); + assert_eq!(status_str(&s), "\u{2717} wrong owner (PID 777)"); + } + + #[test] + fn status_str_unmanaged_ignores_wrong_port() { + let mut s = svc_status(false, false, false, None); + s.actual_port = Some(3005); + assert_eq!(status_str(&s), "\u{2014}"); + } + + // ── actual_str ──────────────────────────────────────────────────────────── + + #[test] + fn actual_str_is_dash_when_no_mismatch() { + let s = svc_status(true, true, false, None); + assert_eq!(actual_str(&s), "\u{2014}"); + } + + #[test] + fn actual_str_shows_port_on_mismatch() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3005); + assert_eq!(actual_str(&s), "3005"); + } + + // ── mismatch_hint ───────────────────────────────────────────────────────── + + #[test] + fn mismatch_hint_none_without_discovered_port() { + let s = svc_status(true, false, false, None); + assert!(mismatch_hint("feat-a", &s).is_none()); + } + + #[test] + fn mismatch_hint_points_at_down_up_not_kill() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3005); + let hint = mismatch_hint("feat-a", &s).unwrap(); + assert!(hint.contains("ecluse down feat-a --keep-worktree")); + assert!(hint.contains("ecluse up feat-a")); + assert!(!hint.contains("kill")); + } + + // The single most important message in the feature: an agent that reads + // this must not kill the sibling session's process. + #[test] + fn mismatch_hint_names_owning_session_and_forbids_kill() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3003); + s.conflicting_slot = Some((3, Some("feat-x".into()))); + let hint = mismatch_hint("feat-a", &s).unwrap(); + assert!(hint.contains("slot 3")); + assert!(hint.contains("feat-x")); + assert!(hint.contains("do not kill")); + } + + #[test] + fn mismatch_hint_reports_unowned_slot_territory() { + let mut s = svc_status(true, false, false, None); + s.actual_port = Some(3004); + s.conflicting_slot = Some((4, None)); + let hint = mismatch_hint("feat-a", &s).unwrap(); + assert!(hint.contains("slot 4's territory")); + } + + // ── listening_summary ───────────────────────────────────────────────────── + + #[test] + fn listening_summary_dash_when_nothing_listening() { + assert_eq!(listening_summary(&[3001], &[]), "-"); + } + + #[test] + fn listening_summary_no_marker_when_all_expected_present() { + assert_eq!(listening_summary(&[3001, 5433], &[3001, 5433]), "3001 5433"); + } + + #[test] + fn listening_summary_marks_missing_expected_port() { + assert_eq!(listening_summary(&[3001], &[3005]), "3005 !"); + } + + // Extra sockets (HMR, debug, inspector) are normal and must not be + // reported as a mismatch — only a MISSING assigned port is. + #[test] + fn listening_summary_tolerates_extra_ports() { + assert_eq!( + listening_summary(&[3001], &[3001, 24678]), + "3001 24678", + "extra HMR socket must not flag a mismatch" + ); + } + + #[test] + fn listening_summary_marks_partial_match() { + assert_eq!(listening_summary(&[3001, 5433], &[3001]), "3001 !"); + } } /// Sanitize a branch name or slug into a valid ecluse slug + original branch pair. @@ -1525,6 +1649,10 @@ struct SessionRow { slot: u8, #[tabled(rename = "PORTS")] ports: String, + /// Ports actually being listened on by this session's process trees. + /// `!` marks a set that differs from PORTS — see `listening_summary`. + #[tabled(rename = "LISTENING")] + listening: String, #[tabled(rename = "TMUX")] tmux: String, #[tabled(rename = "BRANCH")] @@ -1533,6 +1661,81 @@ struct SessionRow { started: String, } +/// Root PIDs whose process trees belong to `session`: the token-verified pid +/// files written at spawn, plus tmux pane PIDs for tmux-managed sessions. +/// +/// A pid whose start token no longer matches was recycled by an unrelated +/// process; attributing its ports to this session would be a misattribution of +/// exactly the kind `whose_pid` guards against. +fn session_root_pids(root: &Path, session: &state::Session) -> Vec { + let mut pids = Vec::new(); + + let pid_dir = root.join(".ecluse").join("pids").join(&session.slug); + if let Ok(entries) = std::fs::read_dir(&pid_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("pid") { + continue; + } + if let Some((pid, token)) = process::read_pid_file(&path) { + if process::pid_file_alive(pid, &token) { + pids.push(pid); + } + } + } + } + + if let Some(ref tmux_session) = session.tmux_session { + pids.extend(process::tmux_session_pane_pids(tmux_session)); + } + + pids.sort_unstable(); + pids.dedup(); + pids +} + +/// Ports this session's process trees are actually listening on. +fn discovered_ports( + root: &Path, + session: &state::Session, + snap: &discover::PortSnapshot, +) -> Vec { + let mut ports: Vec = Vec::new(); + for pid in session_root_pids(root, session) { + for port in snap.ports_for_tree(pid) { + if !ports.contains(&port) { + ports.push(port); + } + } + } + ports.sort_unstable(); + ports +} + +/// Render the LISTENING column: the discovered ports, with a trailing `!` when +/// they don't match what ecluse assigned. +/// +/// Only ports ecluse allocated participate in the comparison. A dev server that +/// also opens an HMR or debug socket would otherwise show a permanent mismatch. +fn listening_summary(expected: &[u16], discovered: &[u16]) -> String { + if discovered.is_empty() { + return "-".into(); + } + let rendered = discovered + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(" "); + // Mismatch = an assigned port that nothing in the session is listening on. + // Extra ports beyond the assigned set are normal (HMR, debug, inspector). + let missing = expected.iter().any(|e| !discovered.contains(e)); + if missing { + format!("{} !", rendered) + } else { + rendered + } +} + fn cmd_ls(args: cli::LsArgs) -> Result<()> { let (_, root) = config::Config::find_and_load()?; let guard = state::StateGuard::acquire_shared(&root)?; @@ -1542,9 +1745,35 @@ fn cmd_ls(args: cli::LsArgs) -> Result<()> { return Ok(()); } + // One snapshot for every session — two forks regardless of session count. + let snap = discover::snapshot(); + if args.json { - let json = serde_json::to_string_pretty(&guard.state.sessions)?; - println!("{}", json); + // Sessions serialize as-is, plus the discovered view alongside the + // assigned one. Never merged into port_overrides: state stays truth. + let sessions_json: Vec = guard + .state + .sessions + .iter() + .map(|s| { + let mut expected: Vec = s.port_overrides.values().copied().collect(); + expected.sort_unstable(); + let listening = discovered_ports(&root, s, &snap); + let mut value = serde_json::to_value(s)?; + if let Some(obj) = value.as_object_mut() { + obj.insert("listening_ports".into(), serde_json::json!(listening)); + obj.insert( + "port_mismatch".into(), + serde_json::json!( + !listening.is_empty() + && expected.iter().any(|e| !listening.contains(e)) + ), + ); + } + Ok(value) + }) + .collect::>>()?; + println!("{}", serde_json::to_string_pretty(&sessions_json)?); return Ok(()); } @@ -1564,7 +1793,11 @@ fn cmd_ls(args: cli::LsArgs) -> Result<()> { } else { pairs.join(" ") }; + let mut expected: Vec = s.port_overrides.values().copied().collect(); + expected.sort_unstable(); + let listening = listening_summary(&expected, &discovered_ports(&root, s, &snap)); SessionRow { + listening, slug: if s.status == state::SessionStatus::Pending { format!("{} (pending)", s.slug) } else { @@ -1595,12 +1828,15 @@ fn cmd_ls(args: cli::LsArgs) -> Result<()> { use tabled::settings::{Modify, Width}; // Truncate PORTS (col 3) to 40 chars so long port lists don't wrap the header. table.with(Modify::new(Columns::single(3)).with(Width::truncate(40).suffix("…"))); + // Same for LISTENING (col 4). + table.with(Modify::new(Columns::single(4)).with(Width::truncate(40).suffix("…"))); } if !any_tmux { use tabled::settings::object::Columns; use tabled::settings::Disable; - // TMUX is column index 4 (SLUG=0, MODE=1, SLOT=2, PORTS=3, TMUX=4) - table.with(Disable::column(Columns::single(4))); + // TMUX is column index 5 + // (SLUG=0, MODE=1, SLOT=2, PORTS=3, LISTENING=4, TMUX=5) + table.with(Disable::column(Columns::single(5))); } println!("{}", table); @@ -2268,6 +2504,67 @@ struct ServiceStatus { /// hijacking the port — `ecluse status` reports the service as down /// even though something IS responding to requests. wrong_owner: bool, + /// The port this service's process tree is *actually* listening on, when + /// it differs from the assigned `port`. Populated by discovery; never + /// written back to state — a mismatch is a bug to report, not a value to + /// adopt (see `incidents/2026-06-09-rubbr-cross-agent-kill-spiral`). + actual_port: Option, + /// Set when `actual_port` falls inside another slot's territory. Names the + /// slot and, when a session holds it, that session's slug — the one fact + /// that stops an agent from killing a sibling's service. + conflicting_slot: Option<(u8, Option)>, +} + +/// Human-readable ACTUAL column: the discovered port, or `—` when it matches +/// the assigned one (nothing interesting to report). +fn actual_str(s: &ServiceStatus) -> String { + match s.actual_port { + Some(p) => p.to_string(), + None => "\u{2014}".into(), + } +} + +/// The remediation hint for a wrong-port service. +/// +/// Always points at `down --keep-worktree` + `up`, which is idempotent, only +/// touches this session's own services, and re-probes ports. Never suggests +/// `kill`: under parallel sessions the process on a neighbouring port is +/// almost always another agent's working service. +fn mismatch_hint(slug: &str, s: &ServiceStatus) -> Option { + let actual = s.actual_port?; + let reset = format!( + "run: ecluse down {} --keep-worktree && ecluse up {}", + slug, slug + ); + Some(match &s.conflicting_slot { + Some((slot, Some(owner))) => format!( + "service '{}' is listening on {} but ecluse assigned {}; {} belongs to slot {} \ + (session '{}') — do not kill it, {}", + s.name, + actual, + s.port.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), + actual, + slot, + owner, + reset + ), + Some((slot, None)) => format!( + "service '{}' is listening on {} but ecluse assigned {}; {} is slot {}'s territory — {}", + s.name, + actual, + s.port.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), + actual, + slot, + reset + ), + None => format!( + "service '{}' is listening on {} but ecluse assigned {}; {}", + s.name, + actual, + s.port.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), + reset + ), + }) } /// Human-readable status string for a service row. Extracted from cmd_status @@ -2283,6 +2580,14 @@ fn status_str(s: &ServiceStatus) -> String { Some(pid) => format!("\u{2717} wrong owner (PID {})", pid), None => "\u{2717} wrong owner".into(), } + } else if let Some(actual) = s.actual_port { + // Discovery found the service alive on a different port than the one + // ecluse assigned. Reporting a bare "down" here is what left agents + // guessing (and reaching for `kill`) in the 2026-06-09 incident. + match &s.conflicting_slot { + Some((slot, _)) => format!("\u{2717} wrong port {} (slot {})", actual, slot), + None => format!("\u{2717} wrong port {}", actual), + } } else if s.healthy { "\u{2713} up".into() } else { @@ -2296,8 +2601,10 @@ struct StatusRowTmux { service: String, #[tabled(rename = "TYPE")] kind: String, - #[tabled(rename = "PORT")] + #[tabled(rename = "EXPECTED")] port: String, + #[tabled(rename = "ACTUAL")] + actual: String, #[tabled(rename = "STATUS")] status: String, #[tabled(rename = "WINDOW")] @@ -2310,8 +2617,10 @@ struct StatusRowNohup { service: String, #[tabled(rename = "TYPE")] kind: String, - #[tabled(rename = "PORT")] + #[tabled(rename = "EXPECTED")] port: String, + #[tabled(rename = "ACTUAL")] + actual: String, #[tabled(rename = "STATUS")] status: String, #[tabled(rename = "PID")] @@ -2324,8 +2633,10 @@ struct StatusRowNone { service: String, #[tabled(rename = "TYPE")] kind: String, - #[tabled(rename = "PORT")] + #[tabled(rename = "EXPECTED")] port: String, + #[tabled(rename = "ACTUAL")] + actual: String, #[tabled(rename = "STATUS")] status: String, } @@ -2365,6 +2676,16 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { vec![] }; + // One snapshot shared by every service row. + let snap = discover::snapshot(); + // Slot → slug, so a cross-slot port can name the session that owns it. + let slot_owners: std::collections::HashMap = guard + .state + .sessions + .iter() + .map(|s| (s.slot, s.slug.clone())) + .collect(); + let mut statuses: Vec = Vec::new(); for svc in &native_svcs { @@ -2430,6 +2751,40 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { }; let healthy_with_owner_check = healthy && !wrong_owner; + // Discovery: where is this service's tree ACTUALLY listening? Only + // meaningful for a managed service that isn't already healthy on its + // assigned port — otherwise there's nothing to explain. + let (actual_port, conflicting_slot) = if managed && !healthy_with_owner_check { + let tree_ports: Vec = pid + .map(|p| snap.ports_for_tree(p)) + .unwrap_or_default() + .into_iter() + .chain( + session + .tmux_session + .iter() + .flat_map(|t| process::tmux_session_pane_pids(t)) + .flat_map(|p| snap.ports_for_tree(p)), + ) + .collect(); + // Pick the first listener that isn't the assigned port — that's + // the "wrong port" the service actually bound. + let actual = tree_ports.into_iter().find(|p| Some(*p) != port); + let conflict = actual.and_then(|a| { + discover::owning_slot( + a, + svc.host_port_base(), + config.slot_stride, + config.max_slots, + ) + .filter(|slot| *slot != session.slot) + .map(|slot| (slot, slot_owners.get(&slot).cloned())) + }); + (actual, conflict) + } else { + (None, None) + }; + statuses.push(ServiceStatus { name: svc.name.clone(), kind: "native", @@ -2440,6 +2795,8 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { tmux_window, listener_pid, wrong_owner, + actual_port, + conflicting_slot, }); } @@ -2460,6 +2817,11 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { tmux_window: None, listener_pid: None, wrong_owner: false, + // Docker publishes ports through the daemon, not a host process + // tree, so process-tree discovery doesn't apply. `find_docker_services` + // already reports the real published port. + actual_port: None, + conflicting_slot: None, }); } @@ -2479,6 +2841,12 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { "tmux_window": s.tmux_window, "listener_pid": s.listener_pid, "wrong_owner": s.wrong_owner, + "actual_port": s.actual_port, + "port_mismatch": s.actual_port.is_some(), + "conflicting_slot": s.conflicting_slot.as_ref().map(|(slot, owner)| { + serde_json::json!({ "slot": slot, "session": owner }) + }), + "hint": mismatch_hint(&session.slug, s), }) }) .collect(); @@ -2519,6 +2887,7 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { service: s.name.clone(), kind: s.kind.to_string(), port: port_str(s), + actual: actual_str(s), status: status_str(s), window: s.tmux_window.clone().unwrap_or_else(|| "-".into()), }) @@ -2532,6 +2901,7 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { service: s.name.clone(), kind: s.kind.to_string(), port: port_str(s), + actual: actual_str(s), status: status_str(s), pid: s.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), }) @@ -2545,6 +2915,7 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { service: s.name.clone(), kind: s.kind.to_string(), port: port_str(s), + actual: actual_str(s), status: status_str(s), }) .collect(); @@ -2561,6 +2932,16 @@ fn cmd_status(args: cli::StatusArgs) -> Result<()> { if down_count == 1 { "" } else { "s" } ); } + + // Explain every wrong-port service and name the safe remedy. This + // is the whole point of discovery: an agent that reads "wrong + // port, run down/up" doesn't invent a theory and reach for `kill`. + let log = log::StepLogger::new(false); + for s in &statuses { + if let Some(hint) = mismatch_hint(&session.slug, s) { + log.warn(&hint); + } + } } } diff --git a/src/process.rs b/src/process.rs index 659f7f4..87f48ad 100644 --- a/src/process.rs +++ b/src/process.rs @@ -592,7 +592,7 @@ fn kill_tmux(result: &SpawnResult) { } /// All pane PIDs across all windows of `session`. Empty on any tmux failure. -fn tmux_session_pane_pids(session: &str) -> Vec { +pub fn tmux_session_pane_pids(session: &str) -> Vec { let Ok(out) = Command::new("tmux") .args(["list-panes", "-s", "-t", session, "-F", "#{pane_pid}"]) .output() From eb1ec511d9d6c64e9dbdb3584111072ae34abf84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Fri, 4 Sep 2026 16:40:08 +0100 Subject: [PATCH 3/4] test(process): wait for the child pid to be readable, not just present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kill_nohup_kills_whole_process_group` waited on `child_pid_file.exists()` then immediately parsed the contents. The shell creates the file when it sets up the `> file` redirect and writes the pid a moment later, so the wait could return while the file was still empty — `parse().unwrap()` then panicked with `ParseIntError { kind: Empty }`. The window is small enough that the test always passed in isolation and only failed under full-suite load, which is the worst failure mode to leave in a tool whose premise is reliability under parallelism. Waits on a successful parse instead, and reports the path when it times out. --- src/process.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/process.rs b/src/process.rs index 87f48ad..c855b2c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1055,16 +1055,24 @@ mod tests { ) .unwrap(); + // Wait for the pid to be readable, not merely for the file to exist: + // the shell creates the file on redirect and writes to it a moment + // later, so an existence-only wait can read an empty file and panic on + // parse. Under full-suite load that window is wide enough to hit. + let read_child_pid = || -> Option { + std::fs::read_to_string(&child_pid_file) + .ok()? + .trim() + .parse() + .ok() + }; assert!( - wait_until(std::time::Duration::from_secs(5), || child_pid_file - .exists()), - "child pid file never appeared" + wait_until(std::time::Duration::from_secs(5), || read_child_pid() + .is_some()), + "child pid never appeared in {}", + child_pid_file.display() ); - let child_pid: u32 = std::fs::read_to_string(&child_pid_file) - .unwrap() - .trim() - .parse() - .unwrap(); + let child_pid = read_child_pid().expect("child pid readable after wait"); assert!(pid_alive(child_pid), "background child should be running"); kill_services(&ProcessManager::Nohup, &result); From 467c39326112e191575d06e7c390399facb89130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Fri, 4 Sep 2026 16:42:25 +0100 Subject: [PATCH 4/4] docs: document on-demand port discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the new LISTENING / ACTUAL columns and the cross-slot warning across README, the commands and ports reference, and the agent skill. The skill entry goes under the existing cross-agent-collision troubleshooting section, since that's the failure this reporting exists to defuse: it now shows the wrong-port table an agent will actually see and states plainly not to kill the process on the ACTUAL port. Emphasises throughout that discovery is read-only — assignment stays truth, and a mismatch means something bound the wrong port rather than that the assigned port was wrong. --- CHANGELOG.md | 3 +++ README.md | 18 ++++++++++++++++++ docs/src/commands.md | 25 +++++++++++++++++++++++++ docs/src/ports.md | 22 ++++++++++++++++++++++ skills/ecluse/SKILL.md | 15 +++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47c135..b0838ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- On-demand port discovery. `ecluse ls` gains a `LISTENING` column showing the ports each session's process trees are actually bound to (trailing `!` when an assigned port isn't among them), and `ecluse status` gains an `ACTUAL` column beside `EXPECTED`. A service alive on the wrong port now reads `✗ wrong port 4020 (slot 2)` instead of a bare `✗ down`, and when the discovered port falls inside another slot's territory the warning names the owning slot and session and forbids killing it — pointing at `down --keep-worktree` + `up` instead. That missing attribution is what let three agents mistake each other's services for their own stale leftovers in the 2026-06-09 cross-agent kill spiral. Discovery is read-only: `state.json` stays the source of truth and a discovered port is never written back over the assigned one. Only a *missing* assigned port counts as a mismatch, so the extra sockets a dev server opens (HMR, debug, inspector) don't flag one. Runs when a command is invoked — no background daemon — via a single snapshot of all listening sockets plus the process table (two subprocess calls total, independent of session count). `--json` gains `listening_ports`/`port_mismatch` on `ls` sessions and `actual_port`/`port_mismatch`/`conflicting_slot`/`hint` on `status` services; a mismatch trips the existing non-zero exit so `ecluse status --quiet` works as a gate. + ### Fixed - `ecluse down` in tmux mode now kills the entire pane process group, not just the pane's foreground shell. Previously, multi-level child chains (`sh → pnpm → node → vite`, plus anything that calls `setsid()` like Cloudflare workerd) survived as orphans adopted by `launchd`/`init`, holding their ports indefinitely. Each orphan held 4-8 ports; after a few `up`/`down` cycles the next `ecluse up` would silently land on a port already held by a zombie, serving a different worktree's content. The same TERM→KILL grace pattern that was applied to the nohup path in PR #18 now applies to tmux. (#30) - `ecluse flush` now sweeps every process whose cwd is inside a worktree (`lsof +d `) AND every listener on a configured port (`base_port + slot*slot_stride` and `extra_ports[].base_port + slot*slot_stride` across all `max_slots`), killing each with TERM→KILL grace. The flush confirmation prompt warns that editors/shells with files open in worktrees will be killed; `--yes` bypass for CI is unchanged. (#30) diff --git a/README.md b/README.md index 1ab7cb2..e0d3101 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,24 @@ ecluse env feat-foo # full JSON: worktree_path, slot, all ECLUSE_* vars ecluse env # auto-detects session if run from inside a worktree ``` +**Port discovery** — `ecluse ls` and `ecluse status` also report the port each service is +*actually* listening on next to the one ecluse assigned, so a service that bound the wrong +port shows up as wrong rather than merely down. Discovery runs on invocation; there's no daemon. + +``` +$ ecluse status feat-a +SERVICE TYPE EXPECTED ACTUAL STATUS +api native 4010 4020 ✗ wrong port 4020 (slot 2) + +warning: service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs +to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a +--keep-worktree && ecluse up feat-a +``` + +Assignment stays the source of truth — a discovered port is reported, never written back +over it. Under parallel sessions the process on a neighbouring port is almost always +another agent's working service, so the remedy is `down --keep-worktree` + `up`, not `kill`. + **Branch names as argument** — pass your git branch name directly; ecluse sanitizes it to a valid slug and uses the original as the branch: ```bash diff --git a/docs/src/commands.md b/docs/src/commands.md index 1f8739a..ddce22e 100644 --- a/docs/src/commands.md +++ b/docs/src/commands.md @@ -150,6 +150,16 @@ Lists active sessions. Use `--json` for machine-readable output. The table shows all allocated ports in a `PORTS` column as `name=value` pairs (e.g. `api=4445 postgres=5433 redis=6380`). A `TMUX` column appears when at least one session uses tmux — the value is the session name you can pass to `tmux attach -t ` or `ecluse shell `. +A `LISTENING` column shows the ports each session's processes are *actually* bound to, discovered at invocation time (no background daemon). A trailing `!` means an assigned port is not being listened on — usually a service that bound the wrong port. Run `ecluse status ` for the per-service breakdown: + +``` +SLUG MODE SLOT PORTS LISTENING BRANCH +feat-a host 1 api=4010 4020 ! feat-a +feat-b host 2 api=4020 4020 feat-b +``` + +Extra sockets a dev server opens (HMR, debug, inspector) appear in `LISTENING` but do not trigger the `!` — only a missing assigned port does. `--json` adds `listening_ports` and `port_mismatch` per session. + ## ecluse validate Validates port ranges in `.ecluse.toml` and checks for gaps or collisions. Use `--ports` to preview the full port allocation table across all slots. Also checks that the configured `process_manager` binary is installed (e.g. tmux or nohup). @@ -171,6 +181,21 @@ ecluse status # auto-detect slug from cwd (must be inside a wo For native services, ecluse matches running processes in the worktree by their command line. For docker services, it queries `docker ps` by container name. +The `EXPECTED` column is the port ecluse allocated; `ACTUAL` is the port the service's process tree is really listening on, shown only when the two differ. A service alive on the wrong port reads `✗ wrong port ` rather than a bare `✗ down`, and when that port falls inside another slot's territory the warning names the owning slot and session: + +``` +SERVICE TYPE EXPECTED ACTUAL STATUS +api native 4010 4020 ✗ wrong port 4020 (slot 2) + +warning: service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs +to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a +--keep-worktree && ecluse up feat-a +``` + +Discovery is read-only: `state.json` stays the source of truth and a discovered port is never written back over the assigned one. A mismatch means something bound the wrong port (usually an external task runner that read `.env.local` instead of `.env.ecluse`) — the fix is `down --keep-worktree` + `up`, never `kill`. `--json` adds `actual_port`, `port_mismatch`, `conflicting_slot`, and `hint` per service. + +Docker services are excluded from process-tree discovery: the daemon publishes their ports, so `docker ps` already reports the real mapping. + The last column and the session header adapt to the process manager: - **tmux** — header shows the tmux session name (`tmux=ecluse-`); last column is `WINDOW` showing the tmux window name for each native service. Health is verified by checking that a process in the pane's subtree owns the expected port — a port collision with an unrelated process correctly shows the service as down. diff --git a/docs/src/ports.md b/docs/src/ports.md index b6545ce..d0841be 100644 --- a/docs/src/ports.md +++ b/docs/src/ports.md @@ -80,6 +80,28 @@ Pin a specific service to a port for a session (useful when the auto-assigned po ecluse up feat-foo --port api=4001 --port postgres=5444 ``` +## Discovery: what is actually listening + +Assignment answers "which port should this service use". Discovery answers "which port is it really on". `ecluse ls` and `ecluse status` report both, so a service that bound the wrong port is visible instead of just looking down. + +Discovery runs when you invoke a command — there is no background daemon. It takes one snapshot of every listening TCP socket plus the process table (two subprocess calls total, regardless of how many sessions exist), then attributes each listener to the session whose process tree owns it. + +``` +$ ecluse status feat-a +SERVICE TYPE EXPECTED ACTUAL STATUS +api native 4010 4020 ✗ wrong port 4020 (slot 2) +``` + +Because ports are derived from the slot, the formula inverts: a discovered port can be mapped back to the slot that owns it. When the wrong port belongs to another slot, ecluse names that slot and its session, and says explicitly not to kill it — under parallel sessions the process on a neighbouring port is almost always another agent's working service. + +**Discovery never overwrites assignment.** `state.json` remains the source of truth. A discovered port that disagrees with the assigned one is evidence of a bug — typically an external task runner (`task`, `make`, `npm run`) that re-read `.env.local` instead of `.env.ecluse` — not a better value to adopt. Trusting discovery is what once hid a wrong-slot spawn behind a green check while three agents killed each other's services. The fix for a mismatch is always: + +```bash +ecluse down --keep-worktree && ecluse up +``` + +Only a *missing* assigned port counts as a mismatch. The extra sockets a dev server opens (HMR, debug, inspector) show up in the discovered set without flagging anything. + ## Known limitation Ports are checked, not reserved. ecluse finds a free port at `ecluse up` time and writes it to `.env.ecluse`. There is a small window between the check and when your process actually binds — if something else takes the port in between, the port in `.env.ecluse` will be wrong. The fix: diff --git a/skills/ecluse/SKILL.md b/skills/ecluse/SKILL.md index 4a3c246..b83dad1 100644 --- a/skills/ecluse/SKILL.md +++ b/skills/ecluse/SKILL.md @@ -566,6 +566,21 @@ Persistent conflict: change `base_port` in the relevant `[[services]]` block, or **Root cause:** an external task runner (`task`, `make`, `npm run`, `bin/dev`) was used as the service entry point instead of `command = "..."` in `.ecluse.toml`. External runners re-read `.env.local` and inherit the spawning shell's env — neither knows about `.env.ecluse`. Under parallel sessions the spawning shell can carry env from a *different* worktree's `source .env.ecluse`, so services bind to the wrong slot's ports. Agents then see "a process on a port adjacent to mine" and kill it, believing it's their own stale leftover. +**Detection:** `ecluse status` compares the port ecluse assigned against the port the service is actually listening on, and names the slot that owns the wrong one: + +``` +SERVICE TYPE EXPECTED ACTUAL STATUS +api native 4010 4020 ✗ wrong port 4020 (slot 2) + +warning: service 'api' is listening on 4020 but ecluse assigned 4010; 4020 belongs +to slot 2 (session 'feat-b') — do not kill it, run: ecluse down feat-a +--keep-worktree && ecluse up feat-a +``` + +`ecluse ls` shows the same thing per session: a `LISTENING` column with a trailing `!` when an assigned port isn't being listened on. + +**When you see this, do NOT kill the process on the ACTUAL port.** It belongs to another session. Run the recovery below — it only touches your own services. + **Recovery (do this in each affected session):** ```bash