Conversation
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 <pid>` 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.
`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.
`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.
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.
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, two forks.
lsof -iTCP -sTCP:LISTEN+ps -axo pid=,ppid=, then the process-tree join happens in memory — cost is independent of session count. 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
LISTENINGwithout flagging anything.Docker excluded. The daemon publishes container ports, not a host process tree —
find_docker_servicesalready reports the real mapping.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
80 new unit tests (457 → 537 in the bin suite). 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, and thatmismatch_hintnever contains the wordkill.cargo fmt --checkandcargo clippy --all-targets -- -D warningsare clean.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.Known pre-existing flake (not from this PR)
down_during_slow_up_does_not_resurrect_the_sessionfails intermittently — a locked-worktree race indown-during-up. Reproduced on cleanmainat the same rate (2 of 6 runs there, 2 of 4 here). Left alone as out of scope; worth its own issue.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.