Repository navigation
feat: on-demand port discovery — report what's actually listening - #34
Merged
Merged
Conversation
`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.
Adds src/discover.rs: one snapshot of every listening TCP socket (`lsof`) plus the process table (`ps`), with the process-tree join done in memory. `ecluse status` now classifies every native service against that shared snapshot instead of running `lsof`/`pgrep` per service. - EXPECTED/ACTUAL columns: ACTUAL lists what the service's own process tree is listening on. - `wrong port <n>` is reported only when the tree does not hold the assigned port but listens on another one. Extra sockets (HMR, inspector) on a healthy service are listed, never flagged. - When the wrong port falls in another slot's territory, the warning names that slot and session, says not to kill it, and points at `down --keep-worktree` + `up`. - Ports are attributed only to the service's own tree: its token-verified pid file, or for legacy tmux sessions its own window's pane. A recycled pid gets nothing, and a sibling pane's port is never blamed on a service. - Wrong-owner detection uses the same snapshot. - A missing `lsof` disables discovery with a one-time warning instead of silently reporting nothing. Discovery is read-only: state.json stays the source of truth and a discovered port is never written back.
`ecluse ls` shows the ports each session's process trees are listening on, from one shared snapshot for all sessions. Roots are the session's token-verified pid files plus its tmux pane pids. A trailing `!` marks a session where an assigned port is not held. Only the assigned ports of native services that ecluse spawns are compared. In hybrid mode, port_overrides also holds the Docker service ports, which the Docker daemon holds and the session's process tree never does, so comparing them flagged every hybrid session. Extra sockets (HMR, inspector) are listed but never flagged. `--json` adds `listening_ports` and `port_mismatch` per session.
Covers the LISTENING and ACTUAL columns and the cross-slot warning in the README, the commands and ports reference, the changelog and the agent skill. The skill entry sits in the existing cross-agent-collision troubleshooting section, because that is the failure this reporting is meant to prevent. It shows the wrong-port table an agent will see and explains that the process on ACTUAL is the agent's own, sitting on another slot's port. The fix is `down --keep-worktree` + `up`, not `kill`. The cost of discovery is described accurately: one `lsof` and one `ps` no matter how many sessions there are, plus the per-session pid-file and tmux lookups.
hefgi
force-pushed
the
feat/port-discovery
branch
from
October 2, 2026 15:01
467c393 to
eeb3af0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
ecluse assigns ports (
port = base_port + slot × stride) and treatsstate.jsonas truth. What it couldn't do was tell you where a service actually ended up. When something bound the wrong port,ecluse statusreported a bare✗ down— technically correct, but it gives an agent nothing to act on.That gap has a cost on record. In the 2026-06-09 cross-agent kill spiral, three parallel agents each saw "a process on a port near mine", inferred a stale leftover, and ran
lsof -ti | xargs killagainst a sibling's working service. Seven kills in seven minutes.whose-pidwas added afterwards so ownership could be checked — but nothing surfaced the mismatch unprompted, so no one thought to check.This adds the missing signal.
What
ecluse lsgains aLISTENINGcolumn;ecluse statusgainsACTUALbesideEXPECTED.Because ports derive from the slot, the formula inverts — a discovered port maps back to the slot that owns it. When the wrong port is another slot's territory, ecluse names that slot and its session, and says not to kill it. That's the one fact whose absence started the spiral.
Design decisions
No daemon. Discovery runs when a command is invoked. There's no sidebar to keep live and no PTY to scrape, so a resident scanner would burn
lsofevery few seconds to populate a view nobody's looking at.One snapshot.
lsof -iTCP -sTCP:LISTEN+ps -axo pid=,ppid=, then the process-tree join happens in memory, so the scan doesn't grow with session count. Identifying each session's root processes still costs a few cheap pid-file checks and tmux lookups per session;statusnow uses the same snapshot instead of its per-servicelsof/pgrepcalls. The per-PID approach already insync.rsforkslsof -ponce per process andpgrep -Ponce per tree level (30+ forks for 4 sessions, ~20-400ms each). Measured ~25ms per fork locally, which is what makes always-on affordable.Read-only — assignment stays truth. A discovered port is never written back over the assigned one. Auto-adopting would make
expected == actualand turn the row green, erasing the evidence that something bound the wrong port; it also wouldn't move the already-running process. This is the same trap as fix #4 in the incident (statusused to report a child's discovered port and hid a wrong-slot spawn behind a green check). Lesson #1 there: "State files are truth. Process trees lie."Only a missing assigned port counts as a mismatch. Dev servers open extra sockets (HMR, debug, inspector); those show up in
LISTENING/ACTUALwithout flagging anything, in bothlsandstatus.Per-service attribution.
statusblames a port only on the tmux window or pid file of the service that holds it (never a sibling pane), and drops pid files whose pid was recycled (start-time token check). Iflsofis missing, discovery is off with a one-time warning.Docker excluded. The daemon publishes container ports, not a host process tree —
find_docker_servicesalready reports the real mapping. Only natively spawned services are compared, so hybrid sessions' Docker ports never trigger a!.Verified end-to-end
Reproduced the incident on a scratch repo (
slot_stride = 10, so slot 1 → 4010, slot 2 → 4020), bound feat-a's service to 4020, and confirmed the output above plusexit 1.Tests
65 new unit tests. Covers the stateful
lsof -F pnrecord format, ports listed once per file descriptor, bracketed IPv6 ([::1]:27017),IGNORED_PORTS(22/80/443), grandchild attribution (sh → pnpm → node → vite), sibling non-attribution, ppid-cycle tolerance, theowning_slotinversion incl. off-stride ports that belong to nobody, thatmismatch_hintnever contains the wordkill, hybrid Docker-port exclusion, sibling-pane non-blame, and recycled-pid rejection.cargo fmt --checkandcargo clippy --all-targets -- -D warningsare clean at every commit.Also included
test(process): fixes a genuine flake inkill_nohup_kills_whole_process_group. It waited onchild_pid_file.exists()then immediately parsed — the shell creates the file on redirect and writes the pid a moment later, so it could read empty and panic withParseIntError { kind: Empty }. Always passed in isolation, only failed under full-suite load. Now waits on a successful parse.Docs
README,
docs/src/commands.md,docs/src/ports.md,skills/ecluse/SKILL.md(under the existing cross-agent-collision troubleshooting entry, since that's the failure this defuses), and a CHANGELOG entry.