Repository navigation
feat: own ACP room residency for OMP, Cursor and Copilot - #710
bandzalkin wants to merge 9 commits into
Conversation
94dae38 to
1dec6ea
Compare
7aa21eb to
6fe9f2a
Compare
1dec6ea to
39aef30
Compare
5a29125 to
4d75666
Compare
436d30c to
5c18aca
Compare
3673a74 to
b15b718
Compare
b15b718 to
9799f06
Compare
…r and Copilot Each ACP room is an ACPRoom composed with a RetainedRoom. Detached work is owned before it runs, prompts bind tools under the room lease, and rooms progress independently. A deliberate wake strictly reloads the exact session and reapplies MCP and the model selection; refusals keep the identity. Closure keeps the process handle until __aexit__ succeeds, and final cleanup shares one transition budget with the shared Band MCP server. Cursor sessionless extension requests bind to the originating room process and generation. All three providers stay ProviderActivity.UNKNOWN, so idle release remains off pending native qualification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
- Spawn a room's process under its turn's lease, not inside the bounded acquisition, so a slow agent start no longer loops through recovery. - Report and recover session-setup failures like prompt failures. - Reloading a room's own session after a crash keeps the session and the selection on a configuration refusal; only bootstrap candidates close. - Confirm closure at stdout EOF, so a descendant holding the pipes keeps the room closing even when the agent exited first or the retry path runs. - Bound session/cancel so a wedged agent cannot hold disposal forever. - Cursor: register each turn before it can start, hand the message queue back when a turn must wait for its room, cancel every accepted turn on interrupt, and cap pending decisions per room. - Copilot switch maps a stopping retention to the mid-switch config error. - Tests: fix assertions that could not fail, run the pipe holder under the base interpreter on Windows, and cover each fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
A turn that started after the room's holder released the reservation, but before the queued turn ahead of it took it, saw a free lock and stayed on the room's message queue. If the turn ahead then parked on a decision, the answer could not arrive until the decision timed out. A turn now also waits off the queue whenever an earlier accepted turn is still ahead of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
f5a308e to
f950c4a
Compare
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Review of the ACP room-ownership rewrite, focused on how it uses the #781 lifecycle contract.
- Blocking: two reproduced flow bugs (a queued Cursor turn that fails before its prompt is never reported; a refused selection after crash recovery fails every later turn), one runtime-safety issue (unbounded, unlogged EOF wait at closure), and three test gaps (sessionless Cursor extension routing, the real-process failed-exit retry, the shared final-cleanup budget).
- Design: ordinary acquisition commits RESIDENT without spawning the process, which splits startup across two paths and duplicates the contract's recovery.
- The rest are suggestions and nits.
Outside the diff hunks: src/band/integrations/acp/client_runtime.py:182 — ensure_connection(can_respawn=True) lost its last production caller in this diff; the remaining spawn branch is a lazy, unleased start the plan forbids. Only test_client_runtime.py:1211 uses it.
- Start a room's ACP process inside its acquisition, so a start that outlasts transition_timeout_s is abandoned and recovered by the owner instead of a second startup path; the room's generation now reads from its RetainedRoom. - Report the failure of a detached Cursor turn that ended before its prompt, so a queued message is never lost silently. - Revert a refused remembered selection on a crash reload, as bootstrap does; only a deliberate wake keeps it. The room keeps its session identity. - Keep one capped decision registry per Cursor room. - Log a failed agent exit, a forced kill, and a descendant still holding the agent's stdout; own the closure and session-close tasks with spawn_owned. - Bound the strict session/load like the fallback load. - Remove the unused lazy respawn in ensure_connection and the test-only bind_session. - Tests: queued-turn start failure, crash reload revert, sessionless Cursor updates, real-process failed-exit retry, shared cleanup budget, restart refusal while rooms close, and a live (not yet run) ACP rejoin smoke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
|
Re the review body's out-of-hunk note ( |
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Cycle 2: review of the cycle-1 fixes only (f950c4a2..381a4b6d).
- Blocking: two reproduced regressions introduced by cycle-1 fixes (a failed detached Cursor turn reports after releasing the room; a start that never answers can no longer be stopped), plus test gaps on the new behavior and a leaking test wrapper.
- The rest are nits.
Outside the diff hunks:
src/band/integrations/acp/client_runtime.py:119(nit):start(respawn=...)and the "Respawning" log branch are dead now that acquisition is the only caller, and_stop_lockis taken only bystart().tests/integrations/acp/test_room_ownership.py:662(suggestion):test_an_interrupt_during_startup_ends_the_turnchecks the turn is cancelled but not that the room serves the next turn afterwards.
A Cursor turn now holds its room through a holder slot set and cleared inside its lease, and reports its failure before the lease passes on, so a queued turn never takes the room while the failed one still answers for it. Failures are posted as generic runtime turn failures, once. ACP agent startup is bounded by a new start_timeout_s setting (10s), because the room owner never cancels an acquisition its caller stopped waiting for; a start that never answers now fails and recovers. Also: one exit-failure traceback naming the pid, a held-stdout warning that covers the SDK's own exit, read-only decision lookups, removal of the dead respawn path, and tests for each (strict-load timeout, held stdout warning, exit/kill logs, leak-free failing-exit wrapper). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
… feat/acp-room-ownership-INT-1768
A model switch now takes the room's reservation before its lease, so it queues behind the room's running and queued prompts instead of changing the model under one; other rooms never wait on it, and a room's leave cancels a waiting switch. A detached Cursor turn that took the room reports its failure only inside its lease; a report lost there is not retried after the lease, so the next queued turn never starts while the failed one still reports. Turns that never took the room still report once. A direct stop closes the shared Band MCP server with the same owned, deadline-bounded close as final cleanup, and reopens it and the residency owner only after the close is confirmed, so a cancelled, failed or overdue close is retried rather than forgotten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
tests/integrations/acp/lifecycleprofile.py registers the three ACP profiles with the shared Room lifecycle suite from #709, which discovers it; that kit is not on main yet, so this bridge needs #709. Each room process is a real stdio ACP agent (workspacepeer.py) speaking the suite's roompeer vocabulary, started through the adapter's production runtime. Only the spawn is wrapped, to hold its start and hold or refuse its exit. A lost transport surfaces as ConnectionError after the turn reported it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC
|
Superseded by #709, which now merges this PR's head and its lifecycle-suite bridge with the Codex and Claude/ACP rewrites, plus the integration fixes. The branch is kept. |
Summary
Each OMP, Cursor and Copilot room's residency (its
omp/agent/copilotACP process, connection and session) is owned by one adapterRoomRetention, built on the INT-1768 ownership prerequisite (#781). All three providers reportProviderActivity.UNKNOWN, so idle release stays off: an idle request is an intact BUSY refusal. This PR adds the ownership, recovery and final-cleanup layer that qualified idle release would need. It does not qualify any provider.Changes
ACPRoom(client_types.py) composes aRetainedRoomwith the room's runtime, generation, session identity, bound tools and retired-session closes. Detached work isown()ed before it runs: Cursor turns and Copilot model switches. Prompts reserve the room, then bind tools throughuse(prepare=...). Rooms run independently; there is no global prompt or session lock.transition_timeout_s. A failed spawn retires that residency.restore_session_response, a strictsession/load, with a fresh Band MCP endpoint and cwd. It then reapplies the room's model selection and verifies it.recover(generation=...). The next turn starts a new process and reloads the same session.ACPRuntimekeeps its transport until__aexit__succeeds and the agent's stdout reaches EOF. A descendant that inherited stdout therefore keeps the room closing, even if the agent exited first.session/cancelis bounded (5s), so a wedged agent can't hold disposal.transition_timeout_sbudget.CursorACPClientProfile. Sessionless extension requests resolve against that room, its generation and the turn that holds the room. A replaced process can't open a decision on its successor./cursorreplies stay reachable./stopcancels every accepted turn in the room, whether starting, queued or parked. A cancelled prompt sendssession/cancel.max_pending_decisionscaps each room separately, so one room can't evict another's decision.ACPRuntime's closure is reworked as described above, and the stdio test peer gains a pipe-holding descendant.docs/acp.mdgains a "Room ownership" section; the Cursor paragraph is updated.Cursor turn and room states
The spec for the turn lifecycle.
room.holderis set and cleared exactly with the room's lease; each row has a test.room.holder;on_messagestays attachedstart_timeout_sstart_timeout_s; the turn's failure is reported once; the room recovers; the next turn is served on a new generation/stopstart_timeout_sand recovers; the next message is servedroom.holderwhile parked/stop_fail_turnreports once and requests recovery while still holderon_cleanup)cancelled; never opens a decision for the holderNot solved (explicit blockers)
Retention stays disabled: all three profiles report
ProviderActivity.UNKNOWN, so an idle request is an intact BUSY refusal. Generic ACP stays rejected.Shared by all three profiles
turn_timeout_s, because the connection never sees EOF. This predates this PR.session/loadwake path is tested but unreachable in production while idle release is off.OMP qualification
Cursor qualification
Copilot qualification
/afterloss is not covered; it is a required regression.Shared-suite integration (owned by the Codex lane)
FakeACPAgentand the stdio peer (tests/integrations/acp/acp_toolkit). Its send, wake, leave and shutdown verbs would map ontosession.send,_acquire_room(..., resume=True)(the localwoken()helper shows the shape),on_cleanupandcleanup_all(final=True).roompeer.py,test_workspace_lifecycle.pyanddocs/idle-room-release.mdare not touched here.Related Issues
INT-1768. Builds on #781, now merged; this branch is rebased onto
mainand replaces the earlier OMP-only implementation of this PR.Testing
uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ -qpassed: 8294 passed, 109 skipped.uv run ruff check .anduv run ruff format --check .passed.uv run pyrefly checkpassed with 0 errors.Markdown docs passed: 30 passed.
New
tests/integrations/acp/test_room_ownership.py(25 tests) covers:__aexit__and inherited pipes (real subprocess)It uses deterministic local peers only, with no paid LLM or live Band calls. Seventeen mutations of the key guards were each caught.
CI on
f950c4a2(basemain1b52e175): all 27 jobs pass (1 skipped: weekly coverage report), including Windowsteston Python 3.11–3.13, so the inherited-pipes test passes on Windows.🤖 Generated with Claude Code
https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC