acpmux: turn cap, so a session taking many agents' messages can end its turn

Merged
TE
7d ago
+859-14

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; 0 turns 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.
  • 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 returns None until the coordinator setting (P2) lands; that PR changes the body of this one function.
  • Past the cap (hub/turn_cap.rs, wired in turns.rs run_prompt_joined):
    • Agent messages to the running turn don't steer. "Agent message" means any prompt digest::agent_sender counts 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 interrupt waits like the rest and stops nothing.
    • A steerOnly agent message can't wait, so it is refused steer.unavailable with data.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.
  • 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_reaches checks this in steer_turn before the notice is logged. agent_prompt_hook (bound) checks again while it holds input_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_withdrawn in 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, harness end_turn ends 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_cap when 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 AbortHandle lives in Session::turn_cap_timer. The next turn's arm_turn_cap aborts 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.

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 the turn_cap provenance kind on user_message.
  • spec/acpmux-protocol.json and webviews/.../generated/methods.ts: regenerated with ACPMUX_PROTOCOL_BLESS=1 and bun run acpmux-protocol:codegen.
    • The regeneration also adds _acpmux/mesh_control, which base's committed IR was missing. protocol_ir_is_current fails that way on base too.
    • Those are the only two generated lines besides this PR's method.

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 interrupt stops 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_seam test seam), whose turn ends and the next one starts meanwhile, is refused, never written, and leaves steer_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):

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_turn is boxed inside the prompt future (see above), and base's #27577 boxes run_prompt_joined itself. No session has one by default, so behaviour changes only after the person sets turnCapMs (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