Skip to content

Take session leases in CLI, ACP, and serve / CLI、ACP、serve 写绑定会话时接入会话租约#6034

Merged
SivanCola merged 3 commits into
esengine:main-v2from
SivanCola:feature/session-lease-cli-acp-serve
Jul 5, 2026
Merged

Take session leases in CLI, ACP, and serve / CLI、ACP、serve 写绑定会话时接入会话租约#6034
SivanCola merged 3 commits into
esengine:main-v2from
SivanCola:feature/session-lease-cli-acp-serve

Conversation

@SivanCola

Copy link
Copy Markdown
Collaborator

Summary

Phase 2b of #6027: put the session lease where the writers are. The desktop has held leases since v1.16, but CLI --resume/--continue, the chat TUI's in-session switches, serve, and ACP never acquired one — leaving "desktop + CLI on the same session" as the last real silent dual-writer entrance. Until now CAS only caught it after the fact by forking recovery copies; with this PR the second writer is refused up front with a clear way out.

Per the approved UX in #6027: conflict → refuse with a holder-identity line + escape hatch; never silent double-writing, never raw lease details.

What's new

control.SessionLeaseKeeper — a single-holder lease follower for the non-desktop frontends (one active session at a time). Rebind acquires the new path before releasing the old one, no-ops on the already-held canonical path (same canonical form as the lease registry — the #6023 lesson), releases on empty path, and on failure leaves the previous lease untouched. control.SessionInUseMessage renders the shared holder line:

this session is in use by another Reasonix process (pid 12345 on HOSTNAME, since 15:04)

pid/host/time are local diagnostics on the operator's own machine; the writer id (random nonce) and the session file path are deliberately never included.

CLI

  • reasonix [run] --resume/--continue on a held session fails fast before setup: holder line + "close the other Reasonix window or process, or rerun with --copy to continue in a duplicated session" (exit 1).
  • New --copy flag: duplicates the resume target beside the original — written through Session.Save so the copy is event-log-aware, carries parent/preview/turns/model meta and a " (copy)" title suffix, and starts with no lease/lock sidecars — then continues writable on the copy. --copy without a resume target exits 2.
  • In-TUI /resume and /switch refuse held targets and stay on the current session (holder line + close hint). The outgoing session is snapshotted before the lease moves; if /switch fails after the lease already moved, the lease is re-pointed at the session the controller still owns.
  • /new, /clear, /branch, /branch N, and rewind-fork follow the controller onto their fresh paths (fresh paths cannot be held; failure is theoretical but never silent). Leases release after the controller closes on exit — verified by tests that the sidecars are gone and reacquirable.

servePOST /resume on a held session returns 409 Conflict with the holder line and leaves serve's state unchanged; /new, /fork, and model-switch path rotations follow their new paths. Read-only endpoints unaffected.

ACPsession/new, session/load, session/resume guard their bind; held sessions surface a protocol error with the holder line (no copy hatch — that is an editor-side decision). session/close / session/delete / close-all release; delete releases before removing files so no locked sidecar survives on Windows.

Crash resilience needs no special casing: the OS lock is the sole arbiter (kernel releases it with the process), so a dead holder's session is simply acquirable and its stale lease.json gets overwritten — same self-healing the desktop relies on.

Deliberately not wired (documented for #6027 tracking)

  • The bot gateway's attached-session path — its concurrent build-then-reuse flow needs desktop-style lease handover semantics, worth its own design round.
  • Destructive-op guards for serve/ACP delete endpoints (TryAcquireSessionRemovalGuard exists in agent) — protection for deletes, not write-binds; separate concern.
  • A TUI read-only browse mode — refusal + --copy covers the need without inventing a new mode.

Tests

19 new regressions across four packages: keeper semantics (handover, canonical no-op, held-refusal keeps prior lease, empty-path release, idempotent release, holder-line content and non-leak assertions), CLI (held resume refusal with --copy hint and no path leak, --copy correctness — copy equals source transcript, source bytes untouched, copy's lease released on exit — normal resume releases on exit, TUI held-refusal / lease handover / fresh-path follow), serve (409 + unchanged state; successful resume moves the lease), ACP (held load refused and unregistered; close releases).

go test ./internal/cli ./internal/acp ./internal/serve ./internal/control ./internal/agent — all pass; new cases pass with -race; gofmt/go vet clean; GOOS=windows GOARCH=amd64 go build ./... clean; desktop module untouched (builds/vets clean).

Cache impact

Cache-impact: none. CLI/serve/ACP session binding, help text, and an exported wrapper (ResolveBranchRef) only; no provider-visible prompts, tool schemas, request serialization, or compaction changes.

Refs #6027.

…iting

Phase 2b of esengine#6027: the desktop has held session leases since v1.16, but
CLI --resume/--continue, the chat TUI's session switches, serve, and ACP
never acquired one — leaving desktop+CLI on the same session as the last
real silent dual-writer entrance (CAS only detected it after the fact by
forking recovery copies).

- control.SessionLeaseKeeper: single-holder lease follower for the
  non-desktop frontends. Rebind acquires the new path before releasing
  the old one, no-ops on the already-held canonical path, releases on
  empty path, and leaves the previous lease untouched on failure.
  control.SessionInUseMessage renders the shared holder line (pid, host,
  since; never the writer id or the session path).
- CLI: run/chat resume targets fail fast before setup when held, with
  the holder line plus the two ways out (close the holder, or rerun
  with --copy). New --copy flag duplicates the resume target beside the
  original (event-log-aware Session.Save; parent/preview/turns/model
  meta; ' (copy)' title suffix) and continues writable on the copy.
  In-TUI /resume, /switch refuse held targets and keep the current
  session; /switch re-points the lease if the switch itself fails after
  the lease moved. /new, /clear, /branch, and rewind-fork follow the
  controller onto fresh paths. Leases release after the controller
  closes on exit.
- serve: POST /resume returns 409 with the holder line when held (state
  unchanged); /new, /fork, and model-switch path rotations follow their
  new paths. Read-only endpoints are unaffected.
- ACP: session/new, session/load, and session/resume guard their bind;
  held sessions surface a protocol error with the holder line.
  session/close, session/delete, and closeAll release; delete releases
  before removing files so no lock sidecar is left behind on Windows.
- controller: export ResolveBranchRef (wrapper over resolveBranch) so
  the TUI can learn a /switch target's path before moving its lease.
- Crash leftovers need no special casing: the OS lock is the sole
  arbiter, so a dead holder's lease is simply acquirable.

Not wired in this round (documented in esengine#6027): the bot gateway's
attached-session path (needs desktop-style lease handover semantics)
and destructive-op guards for serve/ACP delete endpoints.

Refs esengine#6027.

Cache-impact: none - CLI/serve/ACP session binding and help text only;
no provider-visible prompts, tool schemas, or request serialization.
@SivanCola SivanCola requested a review from esengine as a code owner July 5, 2026 18:16
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development tui Terminal UI / CLI (internal/cli, internal/control) agent Core agent loop (internal/agent, internal/control) labels Jul 5, 2026
…utex

Review finding on this PR: /resume, /new, and /fork ran their
snapshot -> lease rebind -> controller bind sequences without any
cross-request serialization. net/http runs handlers concurrently and
one serve instance backs multiple browser tabs, so two interleaved
requests could leave the controller writing session B while the lease
keeper guards session C - B unprotected, C wrongly occupied - exactly
the split this PR exists to prevent. switchModel was serialized by its
own switchMu but nothing stopped the other handlers from rebinding the
same controller mid-switch.

Widen switchMu into bindMu and take it in every entry point that
changes the active session path (/resume, /new, /fork, switchModel).
The lock is acquired after body decoding and path validation, so a
slow client cannot hold the binding lock while uploading; lock order
stays bindMu -> s.mu with no reverse path.

Regressions: a deterministic interleave test parks the first resume
between its rebind and its Resume via a test hook (gated by an atomic
CAS, not sync.Once - Once.Do's internal mutex would accidentally
re-serialize the window) and asserts the second resume waits and both
land with controller and lease aligned; removing the guard makes it
fail. Plus two hammer tests (concurrent resume x resume and
resume x fork) asserting the same alignment invariant under -race.

Refs esengine#6034 review.
@SivanCola SivanCola enabled auto-merge July 5, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) tui Terminal UI / CLI (internal/cli, internal/control) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant