acpmux: pause two agents that keep messaging each other without progress

Merged
TE
16d ago
+2590-175

Two agent sessions can message each other forever: nothing in acpmux stops a back-and-forth that makes no progress (prompt dedupe is by prompt id only, and #21858's send_to_session mints a new id per call). This adds a guard that pauses such a pair until the person resumes it: a loop without progress, or more than 20 messages between the two within 10 minutes with no prompt from the person.

What changes

Every agent message reaches a session through Hub::prompt_with (hub/turns.rs) as a session/prompt carrying an agent_message provenance. The new check Hub::agent_pair_check (hub/agent_pairs.rs) runs there, before the prompt is recorded, so it covers:

  • cmux agent message and #21647's agents_chat_send, which go through the cmux daemon's delivery pump.
  • #21858's send_to_session and start_session, which call acpmux::deliver::deliver directly.

Neither PR bypasses this point.

Who counts. Only a sender that is a session of this daemon counts, matched by exact id from provenance from (with any acp: prefix stripped). The pair is the unordered {sender, receiver} pair; it doesn't use threadId. These senders are never counted or paused: the person's own cmux agent message (stamped cli), terminals, and any other actor.

Detection. A message counts as an exchange once the receiver accepts it (queued or started); a message refused before that, such as steer.unavailable, isn't counted. Progress in either session since the pair's last exchange resets the pair. Progress means:

  • a tool call that isn't a messaging call, or
  • a file write (fs/write_text_file).

A call counts as messaging only when it's a cmux-subsessions tool other than start_session (send_message, list_sessions, get_session, wait_for_sessions, stop_session, set_session_limits, matched by server-qualified name), or agents_chat_send / agents_chat_read. cmux-subsessions send_message and start_session's first prompt (provenance subsession_message) count toward the same pair as cmux agent message. A shell call counts only when the whole command is a single cmux agent message or cmux agent inbox call. So cargo test && cmux agent message … counts as work, and so does an edit of send_to_session.rs.

Without progress, the pair trips on either of these:

  • 10 exchanges in a row that go back and forth;
  • 3 messages in a row from one sender to the same receiver that are near-identical. Bodies are compared without the cmux-rendered header, with case, digits and punctuation dropped, and match at a token Jaccard of 0.9 or higher;
  • the cap: more than 20 messages between the two within 10 minutes with no prompt from the person in either chat (reason cap), even while they make progress. A person's prompt starts the count over.

When tripped.

  • The tripping message and every later one in the pair, in either direction, are held, not delivered.
  • The sender's session/prompt is refused with agent_pair.paused, data {held: true, promptId, sessions, trip}. The text says the user resumes the pair and not to send it again.
  • A pair holds at most 50 messages. Past that, a message is refused with agent_pair.held_full, and the text says that message was not delivered.
  • Both transcripts get an agent_pair_paused record. The receiver's transcript gets an agent_pair_held record per held message, carrying the full prompt and its options.
  • Both chats are marked unread, and the daemon's notify_command fires.

Restarts. Held messages are persisted with a pair-wide order. restore_queue rebuilds a pause from every agent_pair_held record whose prompt never reached the receiver (no queued / user_message with its id) and that no agent_pair_released or agent_pair_dropped settled, merging both logs in the order the messages were held. A release resends with resend: true, so a message that already ran is answered from the log and doesn't run twice. If the daemon stops while a resume is still delivering, the next daemon goes on delivering the rest once it starts (resume_queues): the restore reads each pair's last agent_pair_paused or agent_pair_resumed record, so the rest is never held again behind a transcript that already reads "Resumed". A pair paused again after its resume (release_failed) stays paused.

Resume. Only the person can resume a pair, meaning a connection that proved the person key (hub/person.rs, cx-1l61). There are two ways:

  • _acpmux/agent_pair_resume {sessionId, peerSessionId?} (server/agent_messages.rs). It resumes only pairs that sessionId is in; peerSessionId only picks one of them. Any other caller is refused with agent_pair.resume_refused, and Web and Peer origins are refused before that in remote_guard.rs.
  • A prompt the person sends into either session. A prompt from a Peer never counts as the person.

Both sessions log agent_pair_resumed, and the held messages are delivered oldest first. A released message neither steers nor interrupts, whatever it was sent with: it waits its turn, which may be the person's own prompt by then. Each one is accepted (queued or started) before the next is sent, logged agent_pair_released, and messages that arrive meanwhile wait behind them. One that fails before acceptance stays held and pauses the pair again (release_failed); failing a second time it is dropped (agent_pair_dropped, in both logs). A held cmux-subsessions send_message with interrupt stops nothing: the interrupt only fires once the message is accepted. Once the queue drains, the pair starts over.

Defaults and toggles. The thresholds are 10 exchanges, 3 repeats, 90% similarity, and a cap of 20 messages per 600 seconds. They're read at daemon start from ACPMUX_AGENT_PAIR_ALTERNATIONS, ACPMUX_AGENT_PAIR_REPEATS, ACPMUX_AGENT_PAIR_SIMILARITY (a percent), ACPMUX_AGENT_PAIR_MAX_MESSAGES and ACPMUX_AGENT_PAIR_WINDOW_SECS (0 in either turns the cap off). Debug Settings: agent.pairGuard ("Pause agents messaging in a loop", on by default) gives the app's daemon ACPMUX_AGENT_PAIR_GUARD=0 when off; agent.pairCap (20) and agent.pairWindow (600 s) pass the cap. The guard ships on, by Leo's call: a runaway pair burns usage silently, and the toggle turns it off.

UI. The agent pane's pause row with held count and Resume is #22309, already on feat-cmux-next; it draws this PR's records and sends _acpmux/agent_pair_resume through direct.ts's resumeAgentPair, both now on base. This PR adds the rest of what the row needs:

  • The pane bridge allowlist (AcpmuxPaneMethods.swift) lists _acpmux/agent_pair_resume. It is session-scoped like _acpmux/queue_remove and _acpmux/archive: sessionId must be a chat in the pane's scope, peerSessionId is a known param that is only a selector (not scoped), and the request needs a fresh gesture (.always).
  • policy.json and its cases say the same.
  • direct.ts's resumeAgentPair(peer) came in with #22309, so after the base merge this PR changes nothing in direct.ts; the generated methods.ts gains the method.

Code layout. turns.rs, subsessions.rs, server/requests.rs, daemon.rs and hub/mod.rs are over the god-file limit on base, and this PR ends each at or below its base size: daemon.rs's notify_loop moved whole to daemon/notify.rs, and the model probe list (set_probe_only, allow_probes, probes, probe_only_from_env) moved unchanged from hub/mod.rs to hub/live_models.rs. prompt_with calls Hub::prompt_unless_held. The send_message interrupt is Hub::interrupt_once_queued. _acpmux/prompt_withdraw and _acpmux/agent_pair_resume moved to server/agent_messages.rs, along with the person's prompt resume. agent_pairs.rs keeps its unit tests in agent_pairs_tests.rs and its progress detection in agent_pair_progress.rs.

Limits.

  • The pair counts themselves live in memory; a restart only rebuilds the pauses.
  • The cmux daemon's store marks a held pump delivery failed, with the hold text as its error. The CLI and the agents_chat_send receipt show that text, but the state doesn't read held.

Startup. A daemon on base loads its logs in the background (Hub::new_deferred), and the pairs come back with them (restore_queue reads the agent_pair_* records, which are now in its QUEUE_KINDS). _acpmux/agent_pair_resume and the person's own prompt wait for the logs (wait_logs) before they touch a pair, so a resume in that window is not undone by the restore.

Evidence

The earlier red and green commits are in the PR history (6478f113acf red, 78326bae720 green; aaac37db63f red, d3209ceecba green).

This round's regressions:

  • a_restart_during_a_resume_keeps_delivering_what_was_held is committed red in ca001b9f615, and fixed in f78dc28d093 and ported in fd799114d43. Red was not run in CI; by reading, the pair came back paused and wait_ran timed out.
  • a_sub_session_and_its_parent_messaging_through_send_message_are_paused failed in CI run 39359625271. It was a test bug: the ninth and tenth exchanges both went parent to child. It is fixed in df60fb96294.

Base merge and follow-up (ebdba19229b, adopted from an absent owner)

  • Merged feat-cmux-next 184c4e5c16d into the branch (e975ea54cdb). Conflicts: hub/queue.rs (base's restore_queue reads only QUEUE_KINDS through scan_kinds; the five agent_pair_* kinds were added, or restore_agent_pairs would get nothing back), and two doc/property conflicts in AcpmuxDaemonLauncher.swift and AcpmuxEnvironment.swift (both sides kept).
  • ebdba19229b: the resume-before-logs-load fix above and the god-file moves.
  • Independent subagent reviews passed at e975ea54cdb (full PR) and ebdba19229b (that commit).
  • Observed: prepush-gate.py reports no new violations; cargo fmt --check -p acpmux is clean; acpmux-protocol-codegen.ts --check says the generated code is current.
  • Focused cmux-tui CI on ebdba19229b: agent_pair 39533641375, probe 39533646641, session_scope 39533650537, gestures 39533653307 (results in a comment below).
  • Not run: no new regression test for the resume-before-logs-load window (it needs a deferred hub with a paused pair on disk); no Swift build or test outside PR CI.

Observed / Not run (fd799114d43)

Observed, in focused cmux-tui CI on fd799114d43:

  • Run 39380518627 (agent_pair): test (linux) passed 7 unit tests and 19 agent_pairs integration tests, and the Rust god-file check and cargo fmt passed. cargo clippy stopped at crates/cmux-tui-core/src/release.rs:112 (uninlined_format_args), a file this PR doesn't touch. So clippy did not reach acpmux.
  • Run 39380529853 (params_rule) and run 39380532477 (gestures) passed.
  • Run 39380521523 (macos_hosts) and run 39380523925 (page_sends) are red on base drift that has nothing to do with this PR: policy.json lists _acpmux/ask and lacks _acpmux/btw, _acpmux/attachment and _acpmux/chat/route.set. The same drift is on c3879167ad6. _acpmux/agent_pair_resume matches on both sides.
  • Run 39380526413 (session_scope) failed on my new cases' request id, fixed in 91d456e540a. Re-runs on 91d456e540a: 39384414127 (agent_pair) and 39384417037 (session_scope).

Not run:

  • No Swift build or test. AgentPaneAgentPairResumeRelayTests and AcpmuxAgentPairGuardTests have not been executed; that's left to PR CI.
  • No webview typecheck or test for resumeAgentPair, which has no caller until #22309.
  • 290a99a6fe1 (a steer-only message into a paused pair is held) fixes a race and has no deterministic test.

Agent pane allowlist: security

_acpmux/agent_pair_resume is added to the agent pane's bridge allowlist. The AcpmuxPaneMethods.swift and policy.json headers ask for the protocol/origin lead's review (ad349); that review has not happened. Risk tier: scripts/ci/risk_tier.py grades this PR normal (no file matches a high rule), the same as the recent allowlist additions #20826 (_acpmux/btw) and #20672 (_acpmux/offer_respond), which merged on agent review.

  • Execute or open anything? No. It runs no command and opens no file, URL or window. Its params are two session ids. What it does is deliver prompts that the two agents already sent and the guard held. That runs turns in both chats of the pair, so it needs a fresh gesture.
  • Mutate unowned objects? Only pairs that the in-scope sessionId is in. A pair is exactly two sessions, so every held message is either to that chat or from it. Delivering one into the peer is delivering the in-scope chat's own outgoing message, which may run a turn in a peer chat outside the pane's scope. peerSessionId only selects among the chat's pairs. A request that names an out-of-scope sessionId is refused by the pane (transport.session_not_in_pane), even when its peer is in scope. One that names a session outside the pair resumes nothing (a_resume_acts_only_on_the_named_sessions_pairs).
  • Read local state? No. The reply is {sessionId, resumed, delivered}, which is counts only.
  • Who may call it. acpmux still requires a connection that proved the person key, on this machine (is_person() && !web_class()). The pane's LocalApp connection proves it with transport app (AgentPaneTransport provePerson). Web and Peer are refused in remote_guard.rs, the request is never forwarded to a peer, and an agent's own connection can't prove the person key.

Remote relay

There's no new remote allowlist entry. _acpmux/agent_pair_resume is refused for Web and Peer origins, with a test.

Review

Review: subagent correctness reviews passed at e975ea54cdb and ebdba19229b (see above). Earlier, the subagent correctness review at fd799114d43 found no blockers. Its should-fix on the steer-only race is fixed in 290a99a6fe1. Its other findings are follow-ups:

  • interrupt_once_queued's cancel can race a turn ending; queue.rs cancel_turn has the same race.
  • The order test can tie on a millisecond.
  • A crash between the two resume appends can lose the resume.
  • The person's prompt resumes before it is accepted.
  • release_failed counts reset at restart.
  • One trip fires two notifications.

The parallel lane's design calls in 3309231c7a1 and f8b55884c96 stand: the cap, and releases that never steer or interrupt.
Runtime test: focused cmux-tui CI runs above.

Changelog

Added: cmux NEXT pauses two agents that keep messaging each other without making progress, or that exchange more than 20 messages in 10 minutes without you, until you resume them.

🤖 Generated with Claude Code