Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed
- `ecluse down --keep-worktree` and `shutdown --keep-worktrees` now mark the session `Stopped` instead of dropping it from state. The slot stays reserved, so the next `ecluse up` resumes at the same slot and ports. (#32)
- `ecluse up <slug>`, or a bare `ecluse up` from inside the kept worktree, detects a stopped session and resumes it, re-probing for free ports. `--reuse-worktree` is no longer needed.
- `ecluse ls` shows stopped sessions as `<slug> (stopped)`.
- `ecluse env`, `shell`, `status`, and `sync` reject a stopped session with a hint to run `ecluse up`, so they no longer report ports for services that aren't running.
- Stopped sessions hold their slot until the worktree is deleted. The `SlotsExhausted` error now points to `ecluse down <slug> --delete-worktree`.
- `state.json` files that contain stopped sessions can't be read by older ecluse versions.

### 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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,12 @@ ecluse up --skip api # skip api; start everything else
**Soft restart** — tear down services without losing your worktree, then spin them up fresh:

```bash
ecluse down feat-foo --keep-worktree # services torn down, worktree + branch kept
ecluse up feat-foo --reuse-worktree # new slot, fresh ports, worktree reused
ecluse down feat-foo --keep-worktree # services torn down, worktree + branch kept, slot reserved
ecluse up feat-foo # resumes at the same slot; ports are re-probed (stopped session auto-detected)
```

A stopped session keeps its slot until its worktree is deleted. Running `--keep-worktree` again does not free it. If you hit "all N slots are in use", check `ecluse ls` for `(stopped)` sessions and run `ecluse down <slug> --delete-worktree` on the ones you no longer need. When you are done with a branch, use `--delete-worktree` rather than `--keep-worktree`.

**Port override** — pin a specific service to a port for this session (useful when the auto-assigned port conflicts with something ecluse can't detect):

```bash
Expand Down Expand Up @@ -260,8 +262,8 @@ Hooks run as shell commands with all `.env.ecluse` variables pre-loaded (except
**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 is to tear down and recreate the session:

```bash
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktree
ecluse down feat-foo --keep-worktree # session shows as `feat-foo (stopped)` in `ecluse ls` until the next up
ecluse up feat-foo # resumes the stopped session and re-probes for free ports
```

Or pin a specific port manually:
Expand All @@ -270,7 +272,7 @@ Or pin a specific port manually:
ecluse up feat-foo --port api=4001
```

**Process management is spawn-and-kill only.** For `host` and `hybrid` modes, services with `command` are spawned on `up` and killed on `down`. ecluse does not monitor or restart crashed processes — `ecluse ls` warns if a nohup-managed process has died. For a fresh start, use `ecluse down feat-foo --keep-worktree && ecluse up feat-foo --reuse-worktree`.
**Process management is spawn-and-kill only.** For `host` and `hybrid` modes, services with `command` are spawned on `up` and killed on `down`. ecluse does not monitor or restart crashed processes — `ecluse ls` warns if a nohup-managed process has died. For a fresh start, use `ecluse down feat-foo --keep-worktree && ecluse up feat-foo` (the stopped session is auto-detected on the next `up`).

**`command` only works if the app reads its port from the environment.** ecluse injects the full `.env.ecluse` contents (all `ECLUSE_*` vars, `PORT`, `port_env` aliases) directly into the spawned process environment — no separate sourcing needed. It cannot help if:
- The port is **hardcoded in source code** — the app must be changed to read `$PORT`.
Expand Down
26 changes: 15 additions & 11 deletions skills/ecluse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Bash({"command": "ecluse down feat-foo --delete-worktree"})
Bash({"command": "ecluse down feat-foo --keep-worktree"})
```

`--keep-worktree` keeps the slot reserved: the session stays in state as stopped (`feat-foo (stopped)` in `ecluse ls`) so the next `ecluse up` resumes at the same slot and ports. Use it only for a soft restart. When you are done with a task, use `--delete-worktree`, which frees the slot.

Note: `ecluse shell` spawns an interactive subshell — agents cannot use it. Use `ecluse up --json` or `ecluse env <slug>` to get the worktree path and env, then operate directly.

### What `ecluse up` does
Expand Down Expand Up @@ -115,7 +117,7 @@ ecluse up --force --skip db # kill + restart all except db
### Common first-time failures

- **"run `ecluse init` first"** — no `.ecluse.toml` found; run `ecluse init` from repo root
- **"all N slots in use"** — `ecluse ls` then `ecluse down <slug>` to free one
- **"all N slots in use"** — `ecluse ls` then `ecluse down <slug> --delete-worktree` to free one. Stopped sessions (`(stopped)` in `ecluse ls`) still hold their slots
- **Docker not running** — `open -a OrbStack` or `open -a Docker`

---
Expand Down Expand Up @@ -264,7 +266,7 @@ The canonical fix for misbehaving services is `ecluse down` + `ecluse up`, not `

```bash
ecluse down <your-slug> --keep-worktree
ecluse up <your-slug> --reuse-worktree
ecluse up <your-slug> # stopped session auto-detected; resumes at the same slot
```

This tears down only **your** services and respawns them with the right env. Idempotent, safe under parallel sessions, never touches another agent's work. Reach for this 95% of the time.
Expand Down Expand Up @@ -573,7 +575,7 @@ Persistent conflict: change `base_port` in the relevant `[[services]]` block, or
ecluse down <your-slug> --keep-worktree

# 2. Restart with ecluse spawning the services directly
ecluse up <your-slug> --reuse-worktree
ecluse up <your-slug> # stopped session auto-detected; resumes at the same slot

# 3. Verify ports are correct
ecluse status <your-slug>
Expand Down Expand Up @@ -624,13 +626,15 @@ docker info # verify

### Slot exhaustion

**Error:** `all 8 slots are in use; run ecluse ls to see active sessions`
**Error:** `all 8 slots are in use; run ecluse down <slug> --delete-worktree to free one (stopped sessions shown in ecluse ls keep their slot until their worktree is deleted)`

```bash
ecluse ls
ecluse down <stale-slug>
ecluse ls # look for stale slugs and `(stopped)` sessions
ecluse down <stale-slug> --delete-worktree
```

A session left by `ecluse down --keep-worktree` is stopped. It still holds its slot. Running `--keep-worktree` on it again does not free the slot. Run `ecluse down <slug> --delete-worktree` on each stopped session you no longer need.

Or increase `max_slots` in `.ecluse.toml` directly.

### Stale state after manual worktree deletion
Expand Down Expand Up @@ -706,7 +710,7 @@ RUST_LOG=debug ecluse up feat-foo
| Error | Cause | Fix |
|---|---|---|
| `SlugInvalid` | Slug doesn't match `^[a-z0-9][a-z0-9-]{0,60}[a-z0-9]$` | Lowercase letters, numbers, hyphens; 2–62 chars |
| `SlotsExhausted` | All slots in use | `ecluse ls` then `ecluse down <slug>` |
| `SlotsExhausted` | All slots in use, including stopped sessions | `ecluse ls` then `ecluse down <slug> --delete-worktree` |
| `SessionNotFound` | Slug not in state | Check `ecluse ls` |
| `LockTimeout` | Another process holds lock | Check processes; remove stale lock |
| `ConfigMissing` | No `.ecluse.toml` found | `ecluse init` |
Expand Down Expand Up @@ -766,7 +770,7 @@ inspect it with `ecluse whose-pid <pid>` and kill it manually if intended.

What ecluse intentionally does not do in v0. These are design decisions, not bugs.

- **Ports are checked, not reserved** — ecluse finds a free port at `up` time and writes it to `.env.ecluse`. There is a small window between that check and when your process actually binds. If another process takes the port in between, the value in `.env.ecluse` will be wrong. Fix: `ecluse down feat-foo --keep-worktree` then `ecluse up feat-foo --reuse-worktree`, or pin a specific port with `--port name=value`.
- **Ports are checked, not reserved** — ecluse finds a free port at `up` time and writes it to `.env.ecluse`. There is a small window between that check and when your process actually binds. If another process takes the port in between, the value in `.env.ecluse` will be wrong. Fix: `ecluse down feat-foo --keep-worktree` then `ecluse up feat-foo` (stopped session auto-detected, re-probes for a free port), or pin a specific port with `--port name=value`.
- **No process lifecycle management beyond spawn/kill** — ecluse can spawn native services on `up` (via `command` + `process_manager`) and kill them on `down`, but cannot auto-restart a crashed process. If a service dies, `ecluse up` (idempotent — slug auto-detected from cwd) starts only the downed services. `ecluse up --force` kills everything on allocated ports and restarts fresh. `ecluse ls` and `ecluse env` warn about dead nohup processes.
- **`command` requires the app to expose a port entry point** — ecluse injects the full `.env.ecluse` contents (`PORT`, `ECLUSE_SLOT`, `ECLUSE_SLUG`, `ECLUSE_MODE`, all `ECLUSE_<NAME>_PORT` vars, and any `port_env` aliases) directly into the spawned process environment — no separate sourcing step needed. If the port is hardcoded or set in a config file, resolve it via `.ecluse.toml` first: pass it as a CLI flag (`command = "vite --port $ECLUSE_WEB_PORT"`), or use `port_env` to inject it under the var name the app already reads. Modifying app source code is the last resort — see [Port wiring](#port-wiring--exhaust-eclusetoml-options-before-touching-app-code) above.
- **Mode is set at `init`, not re-detected on `up`** — to change: `ecluse init --mode <new>`
Expand Down Expand Up @@ -931,11 +935,11 @@ ecluse status --quiet # exit-code only (0 = all up, 1 = any down)
**Soft restart** — tear down services without losing the git worktree, then spin up fresh:

```bash
ecluse down feat-foo --keep-worktree # stops services, removes session from state, keeps worktree on disk
ecluse up feat-foo --reuse-worktree # allocates a new slot, skips worktree creation
ecluse down feat-foo --keep-worktree # stops services, marks the session Stopped (slot reserved), keeps worktree on disk
ecluse up feat-foo # resumes at the same slot (stopped session auto-detected; --reuse-worktree not needed)
```

Use this when a service failed to bind after `up` and you want a fresh start without losing changes in the worktree.
Use this when a service failed to bind after `up` and you want a fresh start without losing changes in the worktree. While stopped, `ecluse env`, `ecluse shell`, and `ecluse status` on that session error with a hint to run `ecluse up` — they will not surface stale, no-longer-running ports.

**Port override** — pin a service to a specific port for this session:

Expand Down
6 changes: 4 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ pub struct DownArgs {
#[arg(long)]
pub keep_branch: bool,

/// Tear down services but keep the git worktree on disk
/// Tear down services but keep the git worktree on disk; the session is
/// marked stopped and its slot stays reserved until the worktree is deleted
#[arg(long)]
pub keep_worktree: bool,

Expand Down Expand Up @@ -177,7 +178,8 @@ pub struct ShutdownArgs {
#[arg(long)]
pub keep_volumes: bool,

/// Tear down services but keep git worktrees on disk
/// Tear down services but keep git worktrees on disk; sessions are marked
/// stopped and their slots stay reserved until the worktrees are deleted
#[arg(long)]
pub keep_worktrees: bool,

Expand Down
14 changes: 13 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ mod tests {
assert!(e.to_string().contains("8"));
}

#[test]
fn slots_exhausted_message_points_at_stopped_sessions() {
let msg = EcluseError::SlotsExhausted(8).to_string();
assert!(msg.contains("stopped sessions"), "got: {msg}");
assert!(
msg.contains("ecluse down <slug> --delete-worktree"),
"got: {msg}"
);
}

#[test]
fn session_exists_message_contains_slug() {
let e = EcluseError::SessionExists("my-feat".into());
Expand Down Expand Up @@ -197,7 +207,9 @@ pub enum EcluseError {
#[error("invalid slug '{0}': must match ^[a-z0-9][a-z0-9-]{{0,30}}[a-z0-9]$")]
SlugInvalid(String),

#[error("all {0} slots are in use; run `ecluse down <slug>` to free one")]
#[error(
"all {0} slots are in use; run `ecluse down <slug> --delete-worktree` to free one (stopped sessions shown in `ecluse ls` keep their slot until their worktree is deleted)"
)]
SlotsExhausted(u8),

#[error("session '{0}' already exists; use a different slug")]
Expand Down
Loading
Loading