acpmux: turn cap, so a session taking many agents' messages can end its turn
Why
Leo, 2026-10-29: "What are we gonna do with these 200 fucking messages? You never end a fucking turn. As soon as it comes in, it fucking, like, occupies you for another fucking turn." and "you can't you just deal with this? Like ACPmux can't be this shit. How do swarms work?"
A coordinator session gets messages from 30+ lanes. Each one steered into its running turn, so the turn never ended (one ran about 17 h). This is the turn cap from the swarm-inbox plan (P3): past the cap, a turn stops taking other agents' steers, so it can finish.
What
- Setting:
SessionMeta.turnCapMs(ms;0turns it off; absent means the default).- Set and read with
_acpmux/set_turn_cap {sessionId, turnCapMs?}. A number sets it, null clears it, and leaving it out reads it. The reply is{sessionId, turnCapMs, effectiveTurnCapMs}. - The CLI is
acpmux session turn-cap NAME [--minutes N | --off | --clear]. - Only the person can write it: the app, or the local unix socket with no agent caller token, proven or not. A session's agent can't lift any cap, including its own.
- A fork doesn't copy it.
- Set and read with
- Default: none for ordinary sessions.
hub::turn_cap::default_turn_cap_ms(&SessionMeta)is the single place that links the coordinator setting to a default cap (COORDINATOR_TURN_CAP_MS, 20 min). It returnsNoneuntil thecoordinatorsetting (P2) lands; that PR changes the body of this one function. - Past the cap (
hub/turn_cap.rs, wired inturns.rsrun_prompt_joined):- Agent messages to the running turn don't steer. "Agent message" means any prompt
digest::agent_sendercounts as another agent's:agent_message, sub-session messages, lane reports, or a proven caller. - They wait in the queue. The next turn takes them together through the existing digest join (
digest.rs). - An agent message sent with
interruptwaits like the rest and stops nothing. - A
steerOnlyagent message can't wait, so it is refusedsteer.unavailablewithdata.turnCap: true. - The person's own steers still go in. So do the queue's Send now (
PromptOptions::send_now, also for a held agent message), forum pushes, offer results and auto-resume.
- Agent messages to the running turn don't steer. "Agent message" means any prompt
- Notice: the hub steers one notice into the capped turn, once per turn:
- Text: "Turn cap reached (N min): finish the current step and end your turn; your inbox digest comes next turn."
- Provenance
{kind: "turn_cap", turnId, capMs}, hub-set only. - It is sent on the first agent message past the cap. If an agent message was already queued under the cap, the cap's timer sends it at the cap instead.
- A turn with nothing waiting gets no notice.
- The notice goes only into the turn it names.
turn_cap::notice_reacheschecks this insteer_turnbefore the notice is logged.agent_prompt_hook(bound) checks again while it holdsinput_handoff, which every prompt write takes, so no later turn's prompt can be written between the check and the notice. - A notice for a turn that has ended never reaches the next one. One that was logged but never written leaves
steer_withdrawnin the log. - The turn the notice went into is saved in the session's meta (
turnCapNoticed). It is set only while that turn still runs, so an attempt for a turn that has ended never replaces a later turn's marker. - The notice is sent with
resend, so the log also dedupes it: a turn adopted after a restart gets no second notice, even if the marker's save was lost. - An agent message that is queued as the turn crosses its cap re-checks the cap after it is queued. The timer's earlier look at the queue can't leave the turn without its notice.
- The hub never cancels the turn.
- Measured from: the daemon turn's
startedAt. Once the turn-boundary change (P0, harnessend_turnends the daemon turn) lands, the cap covers only one genuinely long harness turn. This PR doesn't touch P0's functions.
Timer: stop condition and cleanup
- What: one tokio task per session, armed in
arm_turn_capwhen a turn starts, and only when the session has a cap. - Stop condition: it sleeps once until the cap, checks once (is the same turn still running past its cap, with an agent message queued?), and ends. It never loops or re-arms itself.
- Cleanup:
- Its
AbortHandlelives inSession::turn_cap_timer. The next turn'sarm_turn_capaborts the previous timer before it arms a new one. - It holds the hub and the session through
Weak, so a closed session or a dropped hub makes it a no-op. - A turn that ends before the cap leaves the timer to wake once and exit.
- A turn restored by adoption after a restart has no timer. Its messages are still held by the per-message check, which reads the restored
startedAt.
- Its
Stack headroom
run_prompt_joined now awaits steer_turn through Box::pin. On base, the prompt future is already close to a debug test thread's stack limit. The turn cap's few extra lines pushed two hub_integration tests (steer_now::send_now_for_a_queued_prompt_steers_it_in_at_once, fork_through::btw::a_codex_side_session_runs_sandboxed_read_only_and_the_adapter_is_refused) into a stack overflow. Boxing the steer's future makes the prompt future smaller than it is on base. The cost is one allocation per steer.
Wire changes
docs/acpmux-schema.json:_acpmux/set_turn_cap, and theturn_capprovenance kind onuser_message.spec/acpmux-protocol.jsonandwebviews/.../generated/methods.ts: regenerated withACPMUX_PROTOCOL_BLESS=1andbun run acpmux-protocol:codegen.- The regeneration also adds
_acpmux/mesh_control, which base's committed IR was missing.protocol_ir_is_currentfails that way on base too. - Those are the only two generated lines besides this PR's method.
- The regeneration also adds
Tests (red first)
hub/turn_cap_tests.rs uses the fake agent steering the way Claude Code does:
- Under the cap, an agent message steers in, and no notice is sent.
- Past the cap:
- Agent messages are queued.
- Exactly one notice goes in, into the capped turn.
- A steer-only agent message is refused with the reason.
- The person's steer still goes in.
- The turn keeps running.
- The held messages reach the next turn as one joined prompt, delivered once. The next turn is under its own cap again, and past it gets its own single notice (the cap state resets).
- The timer sends the notice at the cap only when an agent message waits. A quiet turn past the cap gets none.
- Past the cap, the queue's Send now still steers a held agent message in, and an agent's
interruptstops nothing. - A notice naming another turn is refused, and a saved notice marker (as a restart restores it) sends no second notice.
- A notice the log holds is not sent again when its meta marker is lost.
- A notice held after it is logged (by the
copy_seamtest seam), whose turn ends and the next one starts meanwhile, is refused, never written, and leavessteer_withdrawn. - Also: the setting's precedence (its own value wins, 0 turns it off) and the CLI's request shapes and wording.
Runs (fast checks on Blacksmith 32 vCPU Linux):
- Red at 7ee5538 (tests and setting, cap not applied): https://github.com/manaflow-ai/cmux/actions/runs/40936010630. 3 failed (past the cap, next turn, timer), 9 passed.
- Green at the head,
a405b91(merged with base 1c243fe, which carries #27577's boxing ofrun_prompt_joined): https://github.com/manaflow-ai/cmux/actions/runs/40960102893- clippy
-D warningsis clean. - The 11 turn cap tests pass three times in a row.
durable_agentspasses 124/124, the same as base: https://github.com/manaflow-ai/cmux/actions/runs/40957537973. Its panics atlog_load.rs:148andhub/mod.rsare the debug-build test seamACPMUX_TEST_PANIC_IN_LOAD_LOGSand the thread scope that re-raises it, and they show on base too.send_steer,lane_reportsandsubsessions_deliverypass, and the full--libsuite passes (1949, run 40958992811).hub_integration: 553 passed, 2 failed. Both failures fail the same way on base:auto_resume::retry::stop_before_a_retrys_turn_starts_prevents_it("the turn was stopped before this prompt reached the agent") andtranscript_events::orchestration_fields_reach_subagent_events_and_session_summary(left Null, right 1234). Base runs: https://github.com/manaflow-ai/cmux/actions/runs/40947262818 and https://github.com/manaflow-ai/cmux/actions/runs/40936124287.held_queue::agent_messages_joined_to_a_send_whose_agent_cannot_start_are_not_heldfailed once under full-suite load at 040cb78. It passes 6/6 on its own both here and on base (run 40960162193), and passes in the head's full run.
- clippy
No capture: daemon-only change (the acpmux turn cap). Nothing the app draws changes; the generated webview method list gains one untyped entry.
Risk
- Medium. It changes steering for sessions that have a cap.
- Stack headroom:
steer_turnis boxed inside the prompt future (see above), and base's #27577 boxesrun_prompt_joineditself. No session has one by default, so behaviour changes only after the person setsturnCapMs(or after P2 turns it on for coordinators). - The new check on the prompt path is one meta read and one turn read, and only while a turn runs.
- What could go wrong: a capped session's agent messages wait for the turn to end. If the agent ignores the notice, they wait until it does. That is the intent, and the person can still steer or stop the turn.
This PR is about 800 changed lines: one setting, its RPC and CLI, the hold, and its tests.
Agent session: agent session
š¤ Generated with Claude Code