Skip to content

feat: own ACP room residency for OMP, Cursor and Copilot - #710

Closed
bandzalkin wants to merge 9 commits into
mainfrom
feat/host-idle-release-omp
Closed

bandzalkin wants to merge 9 commits into
mainfrom
feat/host-idle-release-omp

Conversation

@bandzalkin

@bandzalkin bandzalkin commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Each OMP, Cursor and Copilot room's residency (its omp/agent/copilot ACP process, connection and session) is owned by one adapter RoomRetention, built on the INT-1768 ownership prerequisite (#781). All three providers report ProviderActivity.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

  • Per-room ownership. ACPRoom (client_types.py) composes a RetainedRoom with the room's runtime, generation, session identity, bound tools and retired-session closes. Detached work is own()ed before it runs: Cursor turns and Copilot model switches. Prompts reserve the room, then bind tools through use(prepare=...). Rooms run independently; there is no global prompt or session lock.
  • Spawn under the lease. Acquisition only holds the room's next runtime; the turn spawns it under its own lease, so a slow agent start is not cut short by transition_timeout_s. A failed spawn retires that residency.
  • Strict wake vs fallback.
    • A deliberate wake calls restore_session_response, a strict session/load, with a fresh Band MCP endpoint and cwd. It then reapplies the room's model selection and verifies it.
    • If the load or the selection is refused, the wake fails and keeps both the session id and the selection.
    • The wake path can't be reached in production yet, because idle release is off. Tests drive it through the acquisition callback.
  • Recovery.
    • Session-setup errors, prompt errors and timeouts call recover(generation=...). The next turn starts a new process and reloads the same session.
    • If the agent no longer has that session, the turn falls back to a fresh session with a transcript replay. A refused selection fails the turn but keeps the session and the selection; only a bootstrap candidate is closed.
    • A recovery from a superseded generation has no authority.
  • Confirmed closure.
    • ACPRuntime keeps 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.
    • A failed or cancelled exit leaves the room owned; a retry kills and waits for the same process.
    • session/cancel is bounded (5s), so a wedged agent can't hold disposal.
  • Final cleanup.
    • Rooms and the shared Band MCP server share one transition_timeout_s budget.
    • The MCP server stays open while any room is held.
    • Cleanup is retry-safe after cancellation.
  • Cursor origin and turns.
    • Each room process gets its own 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.
    • Every accepted turn is registered before it can start. A turn queued behind the room's holder, or behind any earlier accepted turn, hands the message queue back at once, so /cursor replies stay reachable.
    • An interrupt or /stop cancels every accepted turn in the room, whether starting, queued or parked. A cancelled prompt sends session/cancel.
    • max_pending_decisions caps each room separately, so one room can't evict another's decision.
  • Reuses fix(integrations): pre-approve Cursor Band tools and surface agent crashes #750: token security, manual approval defaults, preapproval and revocation, controls, and crash reporting are unchanged. ACPRuntime's closure is reworked as described above, and the stdio test peer gains a pipe-holding descendant.
  • docs/acp.md gains a "Room ownership" section; the Cursor paragraph is updated.

Cursor turn and room states

The spec for the turn lifecycle. room.holder is set and cleared exactly with the room's lease; each row has a test.

# State Event Expected result
1 Accepted, room free, no earlier turn Turn starts Takes the lease and becomes room.holder; on_message stays attached
2 Accepted, holder or earlier turn ahead Turn starts Hands the message queue back at once; waits for the room
3 Holder, room has no process Agent starts within start_timeout_s Room is RESIDENT; the prompt runs
4 Holder, room has no process Start never answers Start fails at start_timeout_s; the turn's failure is reported once; the room recovers; the next turn is served on a new generation
5 Holder, room has no process Start raises (e.g. auth) Same as row 4, without waiting
6 Accepted, waiting or starting /stop Turn is settled and cancelled; a hung start still ends at start_timeout_s and recovers; the next message is served
7 Holder Decision opens Queue handed back; the turn stays room.holder while parked
8 Parked Answer, timeout, or /stop Decision resolved or cancelled; the turn continues or ends
9 Holder Prompt completes Turn judged while still holder; holder cleared when the lease ends; the next turn becomes holder
10 Holder Prompt fails _fail_turn reports once and requests recovery while still holder
11 Holder, detached Fails outside the prompt (reply delivery, replay fetch, session busy) Reported once as a generic turn failure, as the runtime reports a failed attached turn
12 Holder, detached Failure report is slow or fails The next turn cannot become holder until that report finishes
13 Waiting or parked Leave (on_cleanup) Turns closed, decisions cancelled; disposal settles the turns before the process closes
14 Any Shutdown All decisions cancelled; same as row 13
15 Holder on generation N Extension request from generation N−1 Answered cancelled; never opens a decision for the holder
16 Holder / no holder Notification without a session id Lands in the holder's session / dropped

Not 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

  • Band MCP calls and ACP permission/extension requests carry no prompt id. A call arriving after its turn is served with the room's current tools, so background prompt attribution is not established.
  • A dead agent whose stdout is still held by a descendant leaves its prompt hanging until turn_timeout_s, because the connection never sees EOF. This predates this PR.
  • Closure is confirmed at stdout EOF. A long-lived descendant that inherited the agent's stdout keeps its room owned: each close attempt ends at the transition timeout until that descendant exits.
  • The strict session/load wake path is tested but unreachable in production while idle release is off.

OMP qualification

  • Background job coverage, including suppressed or acknowledged work, is not qualified.
  • No paid/live acceptance run.

Cursor qualification

  • Whole-work quietness beyond subagent drain is not qualified.
  • Sessionless extensions are attributable per process and generation only, not per prompt.
  • No paid/live acceptance run.

Copilot qualification

  • Scheduled /after loss is not covered; it is a required regression.
  • No paid/live acceptance run.

Shared-suite integration (owned by the Codex lane)

  • Requested, not included here: an ACP provider fixture for the shared suite, built on FakeACPAgent and the stdio peer (tests/integrations/acp/acp_toolkit). Its send, wake, leave and shutdown verbs would map onto session.send, _acquire_room(..., resume=True) (the local woken() helper shows the shape), on_cleanup and cleanup_all(final=True).
  • roompeer.py, test_workspace_lifecycle.py and docs/idle-room-release.md are not touched here.

Related Issues

INT-1768. Builds on #781, now merged; this branch is rebased onto main and replaces the earlier OMP-only implementation of this PR.

Testing

  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ -q passed: 8294 passed, 109 skipped.

  • uv run ruff check . and uv run ruff format --check . passed.

  • uv run pyrefly check passed with 0 errors.

  • Markdown docs passed: 30 passed.

  • New tests/integrations/acp/test_room_ownership.py (25 tests) covers:

    • strict-load rejection
    • failed __aexit__ and inherited pipes (real subprocess)
    • restoring settings, tools and MCP
    • late old-generation extensions
    • two-room origin routing
    • pinning a parked prompt
    • failed and cancelled final cleanup
    • independent room progress
    • slow starts past the transition timeout
    • session-setup failure recovery
    • reload with a refused selection
    • superseded-generation recovery
    • Cursor interrupt during startup or before a decision
    • a queued Cursor message never blocking a decision reply
    • a Cursor message accepted as the room frees still handing the queue back

    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 (base main 1b52e175): all 27 jobs pass (1 skipped: weekly coverage report), including Windows test on Python 3.11–3.13, so the inherited-pipes test passes on Windows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC

@bandzalkin bandzalkin changed the title feat(omp): release idle ACP sessions that advertise session/load feat(integrations): release idle OMP sessions that advertise session/load Sep 29, 2026
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from 94dae38 to 1dec6ea Compare September 30, 2026 04:59
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-codex branch from 7aa21eb to 6fe9f2a Compare September 30, 2026 06:40
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from 1dec6ea to 39aef30 Compare September 30, 2026 06:40
@AlexanderZ-Band
AlexanderZ-Band added this pull request to stack #767 October 10, 2026 06:21
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-codex branch from 5a29125 to 4d75666 Compare October 10, 2026 07:01
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from 436d30c to 5c18aca Compare October 10, 2026 07:02
@AlexanderZ-Band
AlexanderZ-Band removed this pull request from stack #767 October 12, 2026 10:49
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from 3673a74 to b15b718 Compare October 12, 2026 11:01
@AlexanderZ-Band AlexanderZ-Band changed the title feat(integrations): release idle OMP sessions that advertise session/load feat: own ACP room residency for OMP, Cursor and Copilot Oct 12, 2026
@AlexanderZ-Band
AlexanderZ-Band changed the base branch from feat/host-idle-release-codex to feat/retention-ownership-prerequisite-INT-1768 October 12, 2026 11:01
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from b15b718 to 9799f06 Compare October 12, 2026 11:59
Base automatically changed from feat/retention-ownership-prerequisite-INT-1768 to main October 12, 2026 15:17
AlexanderZ-Band and others added 3 commits October 13, 2026 08:53
…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
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/host-idle-release-omp branch from f5a308e to f950c4a Compare October 13, 2026 05:59

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/band/adapters/cursor_acp.py
Comment thread src/band/integrations/acp/client_adapter.py
Comment thread src/band/integrations/acp/client_adapter.py
Comment thread src/band/integrations/acp/client_runtime.py Outdated
Comment thread src/band/integrations/acp/client_runtime.py Outdated
Comment thread src/band/adapters/cursor_acp.py Outdated
Comment thread src/band/integrations/acp/client_types.py Outdated
Comment thread src/band/integrations/acp/client_profiles.py Outdated
Comment thread src/band/integrations/acp/client_adapter.py Outdated
Comment thread docs/acp.md Outdated
- 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
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

Re the review body's out-of-hunk note (client_runtime.py:182, ensure_connection(can_respawn=True) left as an unleased lazy start): fixed in 381a4b6. ensure_connection() no longer takes can_respawn and never spawns; only the room's acquisition starts a process. The respawn test is removed and test_an_unstarted_runtime_rejects_calls_without_spawning pins the rejection.

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_lock is taken only by start().
  • tests/integrations/acp/test_room_ownership.py:662 (suggestion): test_an_interrupt_during_startup_ends_the_turn checks the turn is cancelled but not that the room serves the next turn afterwards.

Comment thread src/band/adapters/cursor_acp.py Outdated
Comment thread src/band/integrations/acp/client_adapter.py
Comment thread src/band/adapters/cursor_acp.py Outdated
Comment thread src/band/integrations/acp/client_runtime.py Outdated
Comment thread src/band/adapters/cursor_acp.py Outdated
Comment thread src/band/integrations/acp/sessions.py
Comment thread tests/integrations/acp/acp_toolkit/peer.py Outdated
Comment thread tests/e2e/baseline/smoke/behavior/test_acp_room_rejoin.py Outdated
Comment thread tests/adapters/test_cursor_acp_adapter.py Outdated
Comment thread tests/adapters/test_cursor_acp_adapter.py Outdated
@linear-code

linear-code Bot commented Oct 13, 2026

Copy link
Copy Markdown

INT-1779

AlexanderZ-Band and others added 3 commits October 13, 2026 11:06
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
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
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants