Skip to content
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [Unreleased]

### Added
- Pending sessions: `up`/`down` reserve the session and release the state lock during provisioning/teardown, so parallel sessions never serialize on image pulls or hooks. `ls` shows `(pending)` (and `"status"` in `--json`), warns about entries pending >15 minutes after a crashed command, and `down <slug>` recovers them. Concurrent finalizes are guarded by per-operation ownership tokens — deleted sessions are never resurrected by a racing command.
- Rollback-on-failure is enforced by an undo-stack guard: any `bring_up` failure tears down exactly what was created (containers, overlays, worktree, spawned services), in reverse order. Failed re-ups of existing sessions keep their data volumes.
- PID files record the process start token; a recycled PID is never signaled, never attributed by `whose-pid`, and reports as down. Containers are matched by compose project label instead of name substrings.
- tmux services run as their window's own process with recorded pane PIDs; commands that exit within ~1.5s fail `ecluse up` with the exit status and last output. Dead panes are kept for inspection; window `shell` is a plain shell with the session env.
- `publish_primary` on `[[services]]`: explicit control over primary-port publication (the implicit "extra_port with container_port suppresses the primary" rule is deprecated; `validate` warns).
- Docker-gated end-to-end test suite (`tests/docker_e2e.rs`): lifecycle, rollback, multi-compose teardown, container mode — runs wherever a docker daemon is available, skips elsewhere.

### Changed
- `extra_ports` use the same per-slot spacing as primary ports (`base + slot*slot_stride`) and are probed before startup: occupied extras are a hard error under `strict_port` and a warning otherwise. With `slot_stride = 1` (the default) allocations are unchanged.
- `up --force` only kills processes owned by the session (verified via pid files/tmux panes), with TERM→KILL escalation; unowned listeners produce a warning naming the PID.
- Teardown dispatches on the session's recorded mode, so changing `mode` in `.ecluse.toml` no longer strands containers of existing sessions.
- `ModeHandler::bring_up` takes a `BringUpRequest`; container/hybrid share the docker-startup, worktree, port-allocation and spawn building blocks (was ~100 duplicated lines).
- Session state records explicit `(compose, overlay)` pairs; teardown no longer reconstructs compose paths from overlay filenames (ambiguous for hyphenated slugs). Legacy state files still tear down via the old path.
- Branch handling: `up <branch>` tracks `origin/<branch>` when it only exists on the remote (instead of forking a same-named branch from HEAD), and refuses to resume a slug that belongs to a different branch.
- The tmux env preamble is per-slug (`.ecluse/preambles/<slug>.sh`); a shared file leaked ports between parallel sessions.

### Fixed
- `kill` paths signal the whole process group with TERM→KILL grace — service children no longer survive `ecluse down` holding their port.
- A failing service spawn cleans up the services already spawned instead of orphaning them.
- `ecluse sync` no longer accepts any directory whose path merely contains the slug; the cwd must be a linked worktree of the repo. It also refuses sessions that are mid-operation.
- `worktree remove` verifies the directory is actually gone instead of treating `git worktree prune` success as removal success.
- Shared-lock acquisition reads the real state when `state.lock` is missing (previously reported "no sessions" while sessions ran); `ls` no longer panics on malformed timestamps; `.env.ecluse` is parsed by a single shared parser everywhere.
- Examples and README migrated off the deprecated `on_up`/`on_down` hook names (the README example also ran migrations in `pre_up`, before any env exists; now `post_up`).

## [0.2.17] — 2026-06-10

### Added
Expand Down
10 changes: 10 additions & 0 deletions docs/src/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,13 @@ If you interrupt `ecluse up` mid-flight, partial state may be left behind. Use `
## Platform support

macOS and Linux. Windows is not supported.

## Pending sessions

`up` and `down` reserve the session with a *pending* marker and release the
state lock while they work, so parallel sessions never serialize on slow
provisioning. `ecluse ls` shows `(pending)` during the operation and warns
when a pending entry is older than 15 minutes (the owning command crashed);
`ecluse down <slug>` takes such a session over and cleans it up. Commands that
need a settled session (`up`, `env`, `status`, `shell`, `sync`) refuse pending
sessions with an `operation in progress` error.
2 changes: 1 addition & 1 deletion examples/fastapi-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "alembic upgrade head"
post_up = "alembic upgrade head"
2 changes: 1 addition & 1 deletion examples/fastapi-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Postgres runs in a Docker container managed by ecluse. The FastAPI process and V

## Hooks

- `on_up`: runs `alembic upgrade head` to apply pending migrations against the slot's database.
- `post_up`: runs `alembic upgrade head` to apply pending migrations against the slot's database.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion examples/go-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ run = "docker"
base_port = 5432

[hooks]
on_up = "migrate -path ./migrations -database \"postgres://localhost:$ECLUSE_POSTGRES_PORT/myapp\" up"
post_up = "migrate -path ./migrations -database \"postgres://localhost:$ECLUSE_POSTGRES_PORT/myapp\" up"
2 changes: 1 addition & 1 deletion examples/go-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Postgres runs in a Docker container managed by ecluse. The Go binary runs native

## Hooks

- `on_up`: runs `migrate -path ./migrations -database "$DATABASE_URL" up` to apply pending migrations.
- `post_up`: runs `migrate -path ./migrations -database "$DATABASE_URL" up` to apply pending migrations.

Requires the [`migrate` CLI](https://github.com/golang-migrate/migrate) to be installed (`brew install golang-migrate`).

Expand Down
4 changes: 2 additions & 2 deletions examples/k3d/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ name = "https"
base_port = 8443

[hooks]
on_up = "k3d cluster create ecluse-$ECLUSE_SLUG --port \"$ECLUSE_HTTP_PORT:80@loadbalancer\" --port \"$ECLUSE_HTTPS_PORT:443@loadbalancer\""
on_down = "k3d cluster delete ecluse-$ECLUSE_SLUG"
post_up = "k3d cluster create ecluse-$ECLUSE_SLUG --port \"$ECLUSE_HTTP_PORT:80@loadbalancer\" --port \"$ECLUSE_HTTPS_PORT:443@loadbalancer\""
pre_down = "k3d cluster delete ecluse-$ECLUSE_SLUG"
4 changes: 2 additions & 2 deletions examples/k3d/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ brew install k3d helm helmfile

## Hooks

- `on_up`: `k3d cluster create ecluse-$ECLUSE_SLUG --port "$PORT:80@loadbalancer"` — provisions a fresh k3s cluster.
- `on_down`: `k3d cluster delete ecluse-$ECLUSE_SLUG` — destroys the cluster and all its resources.
- `post_up`: `k3d cluster create ecluse-$ECLUSE_SLUG --port "$PORT:80@loadbalancer"` — provisions a fresh k3s cluster.
- `pre_down`: `k3d cluster delete ecluse-$ECLUSE_SLUG` — destroys the cluster and all its resources.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion examples/mongo-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ run = "docker"
base_port = 27017

[hooks]
on_up = "npm run db:seed"
post_up = "npm run db:seed"
2 changes: 1 addition & 1 deletion examples/mongo-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const mongoUrl = `mongodb://localhost:${process.env.ECLUSE_MONGODB_PORT}/${proce

## Hooks

- `on_up`: runs `npm run db:seed` (optional) to seed initial data for this slot.
- `post_up`: runs `npm run db:seed` (optional) to seed initial data for this slot.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ run = "docker"
base_port = 5432

[hooks]
on_up = "npx prisma migrate deploy"
post_up = "npx prisma migrate deploy"
2 changes: 1 addition & 1 deletion examples/nextjs-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Postgres runs in a Docker container managed by ecluse. Next.js runs natively. Ea

## Hooks

- `on_up`: runs `npx prisma migrate deploy` against the slot's database.
- `post_up`: runs `npx prisma migrate deploy` against the slot's database.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion examples/node-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Node.js + Postgres fully containerized. Everything runs in Docker — no native processes.

ecluse manages a separate compose project per worktree, with all ports offset by slot. The Docker image entrypoint runs `prisma migrate deploy` before starting the server, so no `on_up` hook is required.
ecluse manages a separate compose project per worktree, with all ports offset by slot. The Docker image entrypoint runs `prisma migrate deploy` before starting the server, so no `post_up` hook is required.

## Mode

Expand Down
2 changes: 1 addition & 1 deletion examples/node-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "npm run db:migrate"
post_up = "npm run db:migrate"
2 changes: 1 addition & 1 deletion examples/node-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Postgres runs in a Docker container managed by ecluse. The Express API and React

## Hooks

- `on_up`: runs `npm run db:migrate` (wraps `prisma migrate deploy`) to apply pending migrations against the slot's database.
- `post_up`: runs `npm run db:migrate` (wraps `prisma migrate deploy`) to apply pending migrations against the slot's database.

## Usage

Expand Down
4 changes: 2 additions & 2 deletions examples/rails-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "bin/rails db:prepare"
on_down = "bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1"
post_up = "bin/rails db:prepare"
pre_down = "bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1"
4 changes: 2 additions & 2 deletions examples/rails-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Data services (postgres, redis) run in Docker containers managed by ecluse. The

## Hooks

- `on_up`: runs `bin/rails db:prepare` — creates and migrates the database for this slot.
- `on_down`: runs `bin/rails db:drop` — drops the database before tearing down.
- `post_up`: runs `bin/rails db:prepare` — creates and migrates the database for this slot.
- `pre_down`: runs `bin/rails db:drop` — drops the database before tearing down.

## Usage

Expand Down
4 changes: 2 additions & 2 deletions examples/rails-monorepo/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "bin/rails db:prepare"
on_down = "bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1"
post_up = "bin/rails db:prepare"
pre_down = "bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1"
4 changes: 2 additions & 2 deletions examples/t3-host/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ name = "app"
base_port = 3000

[hooks]
on_up = "npx prisma migrate deploy"
on_down = "npx prisma migrate reset --force"
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
4 changes: 2 additions & 2 deletions examples/t3-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ Each slot gets a distinct `PORT` so multiple worktrees can serve simultaneously.

## Hooks

- `on_up`: runs `npx prisma migrate deploy` to apply migrations.
- `on_down`: runs `npx prisma migrate reset --force` to wipe the slot's database on teardown (optional — remove if you want to keep data).
- `post_up`: runs `npx prisma migrate deploy` to apply migrations.
- `pre_down`: runs `npx prisma migrate reset --force` to wipe the slot's database on teardown (optional — remove if you want to keep data).

## .env setup

Expand Down
4 changes: 2 additions & 2 deletions examples/t3-monorepo/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "npx prisma migrate deploy"
on_down = "npx prisma migrate reset --force"
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
43 changes: 43 additions & 0 deletions skills/ecluse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,49 @@ RUST_LOG=debug ecluse up feat-foo

---

## Concurrency and recovery

`ecluse up`/`down` no longer hold the state lock while provisioning or tearing
down — sessions are reserved with a **pending** marker instead, so parallel
agents never block on each other's slow image pulls or hooks.

What this means for you:

- `ecluse ls` shows `<slug> (pending)` while an up/down is in flight
(`"status": "pending"` in `ls --json`). Other read commands keep working.
- Running `up`, `env`, `status`, `shell`, or `sync` against a pending session
fails with `operation in progress`. Wait for the owning command, or — if it
crashed — run `ecluse down <slug>` to take the session over and clean it up.
- `ls` warns when a session has been pending for more than 15 minutes; that
means the owning command died and the slot is leaked until you `down` it.
- If a session is removed (`down`, `flush`) while another command was still
provisioning it, the loser detects the takeover, tears down whatever it
created, and exits non-zero. State never resurrects deleted sessions.

### Service identity

Pid files record the process **start token** alongside the PID. A recycled PID
(same number, different process) is never killed, never attributed by
`whose-pid`, and reports as down in `status`. Containers are matched by their
compose project label, never by name substrings.

### tmux sessions

Services run as their tmux window's own process. A command that exits within
~1.5 s fails `ecluse up` with the exit status and last output — a "ready"
session means the services actually started. Dead panes are kept on screen
(`remain-on-exit`) so you can attach and read the error; window `shell` is a
plain shell with the session env loaded. Service commands must be
long-running: a command like `echo done` is treated as an instant failure
under tmux.

### --force and unowned ports

`ecluse up --force` only kills processes that **belong to the session**
(verified via pid files / tmux panes). A process squatting the session's port
that ecluse does not own produces a warning naming the PID instead of a kill —
inspect it with `ecluse whose-pid <pid>` and kill it manually if intended.

## Limits

What ecluse intentionally does not do in v0. These are design decisions, not bugs.
Expand Down
2 changes: 1 addition & 1 deletion skills/ecluse/examples/fastapi-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "alembic upgrade head"
post_up = "alembic upgrade head"
4 changes: 2 additions & 2 deletions skills/ecluse/examples/k3d/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ name = "https"
base_port = 8443

[hooks]
on_up = "k3d cluster create ecluse-$ECLUSE_SLUG --port \"$ECLUSE_HTTP_PORT:80@loadbalancer\" --port \"$ECLUSE_HTTPS_PORT:443@loadbalancer\""
on_down = "k3d cluster delete ecluse-$ECLUSE_SLUG"
post_up = "k3d cluster create ecluse-$ECLUSE_SLUG --port \"$ECLUSE_HTTP_PORT:80@loadbalancer\" --port \"$ECLUSE_HTTPS_PORT:443@loadbalancer\""
pre_down = "k3d cluster delete ecluse-$ECLUSE_SLUG"
4 changes: 2 additions & 2 deletions skills/ecluse/examples/t3-host/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ name = "app"
base_port = 3000

[hooks]
on_up = "npx prisma migrate deploy"
on_down = "npx prisma migrate reset --force"
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
4 changes: 2 additions & 2 deletions skills/ecluse/examples/t3-monorepo/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ run = "docker"
base_port = 6379

[hooks]
on_up = "npx prisma migrate deploy"
on_down = "npx prisma migrate reset --force"
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
1 change: 1 addition & 0 deletions src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ mod tests {
port_env: vec![],
debug_port: None,
extra_ports: vec![],
publish_primary: None,
host_port: None,
}
}
Expand Down
Loading
Loading