Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <worktree>`) 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)
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/src/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` or `ecluse shell <slug>`.

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 <slug>` 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).
Expand All @@ -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 <n>` 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-<slug>`); 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.
Expand Down
22 changes: 22 additions & 0 deletions docs/src/ports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug> --keep-worktree && ecluse up <slug>
```

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:
Expand Down
15 changes: 15 additions & 0 deletions skills/ecluse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading