agentmux_srv/backend/blockcontroller/agent_lock.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! In-memory registry of PtyShell agent leases — the authoritative, hot-path
5//! answer to "is a human's keystroke for this block currently locked out?".
6//!
7//! # Why this exists as its own thing rather than a meta read
8//!
9//! The lease (`term:agentlockuntil`,
10//! `docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md` §10.3) is
11//! enforced in `controllerinput` (`server/websocket.rs`). That handler
12//! serves the Stop button's `SIGINT` (`useAgentCommands.ts`) and the
13//! debounced PTY-width resizes `usePtyWidth.ts` sends — the latter in bursts
14//! while a pane is actively being dragged, which is precisely when the UI is
15//! most latency-sensitive. (It is NOT the keystroke path: terminal
16//! keystrokes go through the `blockinput` WS command instead —
17//! `termViewModel.ts`, `AgentShellSubblock.tsx` — see the gap note at the
18//! bottom of this comment.) The first implementation answered the
19//! is-it-leased question by reading the block out of `Store`:
20//!
21//! ```ignore
22//! mstore.get::<Block>(&cmd.blockid) // synchronous SQLite, on every keystroke
23//! ```
24//!
25//! `Store` is one process-wide SQLite connection behind one `Mutex<Connection>`
26//! (`storage/store.rs`'s own doc comment: "matching Go's `MaxOpenConns(1)`"),
27//! and `Store::get` is a plain blocking call — so that read put a real disk
28//! query, serialized against every other `Store` user in the process, inside
29//! an async WS handler, inline on a Tokio worker thread with no
30//! `block_in_place`/`spawn_blocking`. That is the same failure class the
31//! sysinfo incident already cost this repo once (commit `0f34704a8`, #1782).
32//! See `docs/analysis/ANALYSIS_CROSS_PANE_INPUT_DELAY_REGRESSION_2026_09_15.md`.
33//!
34//! The lease is short-lived (`AGENT_LOCK_WINDOW_MS`, 4s), process-local, and
35//! recreated on every agent write. None of that needs durability — so it
36//! lives here instead: a `HashMap` behind an uncontended `Mutex`, held for
37//! nanoseconds with no I/O inside it. For the overwhelmingly common case
38//! (no agent is driving any shell) the map is empty and the check is a hash
39//! lookup that misses.
40//!
41//! # Formerly a known gap, closed 2026-09-16
42//!
43//! `controllerinput` is not the path a human's keystrokes actually take.
44//! Both the Terminal pane (`termViewModel.ts`) and the agent pane's drawer
45//! shell (`AgentShellSubblock.tsx`) send keystrokes via the `blockinput` WS
46//! command, which called `blockcontroller::send_input` directly with no
47//! lease check at all — so the server-side enforcement added in #3194 did
48//! not in fact cover the human-vs-agent write collision it was added for.
49//! The only thing standing in the way there was `AgentShellSubblock`'s own
50//! `agentLocked()` gate, i.e. exactly the eventually-consistent frontend
51//! check that enforcement was meant to backstop. This surfaced for real
52//! during a live vim investigation on 2026-09-16: an agent driving `vim`
53//! through `PtyShellInput` on its own composer-drawer shell (the same PTY
54//! a human's `blockinput` keystrokes reach) had its writes interleave with
55//! other input on that pane, corrupting vim's cursor-addressed
56//! alternate-screen redraw into a visibly garbled pane — not a hang, byte
57//! interleaving mid-escape-sequence. `dispatch_blockinput`
58//! (`server/websocket.rs`) now calls `is_locked` too, mirroring
59//! `controllerinput`'s check — see
60//! `blockinput_is_dropped_while_an_agent_lock_is_active` there for the
61//! regression test.
62//!
63//! # Relationship to `term:agentlockuntil` meta
64//!
65//! The meta key is still written, and is still what `AgentShellSubblock.tsx`
66//! renders its "Agent is using this shell" badge from — but it is now the UX
67//! layer only, exactly as that handler's own comment already described it
68//! ("the frontend gate is the UX layer that keeps it from happening in the
69//! first place, not the correctness boundary"). This registry is the
70//! correctness boundary. They're written together (`lock_shell_for_agent`)
71//! and cleared together (`handle_pty_shell_stop`, and shell exit — below).
72//!
73//! # Fail-open on restart, deliberately
74//!
75//! A restarted srv starts with an empty registry, so no lease survives it.
76//! That is the right direction to fail for a lock whose entire purpose is a
77//! UX-level write-collision guard, not a security boundary: the failure mode
78//! of a stale lease is *a human who cannot type in their own terminal* — at
79//! worst, cannot type `exit` to close the pane — which is strictly worse than
80//! an agent's write interleaving with a human's. Persisted meta could
81//! previously outlive the process that set it; memory cannot.
82
83use std::collections::HashMap;
84use std::sync::{Mutex, OnceLock};
85
86/// block_id → absolute unix-ms timestamp the lease expires at.
87static LEASES: OnceLock<Mutex<HashMap<String, i64>>> = OnceLock::new();
88
89fn leases() -> &'static Mutex<HashMap<String, i64>> {
90 LEASES.get_or_init(|| Mutex::new(HashMap::new()))
91}
92
93/// Lease `block_id` against human input until `until_ms` (absolute unix ms).
94///
95/// Called before every agent write (`PtyShellInput`/`PtyShellResize`), which
96/// is what makes the lease self-renewing while an agent is actively working
97/// and self-releasing ~`AGENT_LOCK_WINDOW_MS` after it stops. A lease that is
98/// already in the past is not stored (and clears any existing one) — there is
99/// no such thing as a lease that is expired the moment it's taken, and
100/// keeping it would only leave an entry for `is_locked` to prune later.
101pub fn lock_until(block_id: &str, until_ms: i64) {
102 let mut map = leases().lock().unwrap();
103 if until_ms <= agentmux_common::time::now_ms() {
104 map.remove(block_id);
105 return;
106 }
107 map.insert(block_id.to_string(), until_ms);
108}
109
110/// Is a human's input for `block_id` currently locked out by an agent lease?
111///
112/// The hot path — see the module doc. Prunes the entry when it finds an
113/// expired one, so the map stays bounded by the number of *currently* leased
114/// blocks (in practice: the number of agents driving a shell right now,
115/// normally zero) rather than by every block ever leased.
116pub fn is_locked(block_id: &str) -> bool {
117 let mut map = leases().lock().unwrap();
118 match map.get(block_id) {
119 None => false,
120 Some(&until_ms) => {
121 if until_ms > agentmux_common::time::now_ms() {
122 true
123 } else {
124 map.remove(block_id);
125 false
126 }
127 }
128 }
129}
130
131/// Release `block_id`'s lease immediately. Returns whether it was actually
132/// held (and unexpired) at the moment of the call — `PtyShellStop` reports
133/// that back to the agent as `released`.
134///
135/// Called from `PtyShellStop` (the explicit release) and from a shell's
136/// exit path (`shell/lifecycle.rs`) — an exited shell must never keep a
137/// lease alive, or the pane it belongs to would refuse the human's keystrokes
138/// for up to the rest of the window for a PTY that isn't there any more.
139pub fn release(block_id: &str) -> bool {
140 let mut map = leases().lock().unwrap();
141 match map.remove(block_id) {
142 Some(until_ms) => until_ms > agentmux_common::time::now_ms(),
143 None => false,
144 }
145}
146
147/// Release the lease AND clear the `term:agentlockuntil` meta copy the
148/// frontend gates on, broadcasting the update so it takes effect now.
149///
150/// Releasing only the in-memory copy is not enough on any path where the
151/// point is to give the human their keyboard back. Codex P1 / ReAgent P1 on
152/// PR #3249, and they're right: `AgentShellSubblock.tsx`'s `agentLocked()`
153/// derives its gate purely from that meta value. `blockinput` now also has
154/// its own server-side lease check (see the module doc's "Formerly a known
155/// gap" section), so a stale meta value no longer risks a swallowed
156/// keystroke reaching a live controller — but the frontend still reads
157/// meta for its own badge/gate, and clearing memory alone would leave that
158/// badge (and the client-side keystroke suppression) stuck for the rest of
159/// the 4s window, which is precisely the "typed `exit`, nothing visibly
160/// happened" case this is meant to fix.
161///
162/// Best-effort on the meta half (logs and continues), authoritative on the
163/// memory half. Mirrors `core::persist_session_id`'s established
164/// update-then-broadcast shape; no-ops on the meta half when `mstore` is
165/// `None`, as unit tests that don't wire a store expect.
166pub fn release_and_clear_meta(
167 block_id: &str,
168 mstore: &Option<std::sync::Arc<crate::backend::storage::store::Store>>,
169 event_bus: &Option<std::sync::Arc<crate::backend::eventbus::EventBus>>,
170) -> bool {
171 let was_locked = release(block_id);
172
173 let Some(store) = mstore else { return was_locked };
174 let oref_str = format!("block:{block_id}");
175 let mut meta_update = crate::backend::obj::MetaMapType::new();
176 meta_update.insert(
177 crate::server::META_KEY_AGENT_LOCK_UNTIL.to_string(),
178 serde_json::Value::Null,
179 );
180 if let Err(e) = crate::server::service::update_object_meta(store, &oref_str, &meta_update) {
181 tracing::warn!(block_id = %block_id, error = %e, "agent_lock: failed to clear lease meta");
182 return was_locked;
183 }
184 let Some(event_bus) = event_bus else { return was_locked };
185 super::core::broadcast_block_update(store, event_bus, block_id);
186 was_locked
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 /// Global state shared across the whole test binary — every test here
194 /// uses its own block ids so parallel tests can't observe each other.
195 fn id(suffix: &str) -> String {
196 format!("agent-lock-test-{suffix}")
197 }
198
199 fn entry_count(block_id: &str) -> usize {
200 leases().lock().unwrap().keys().filter(|k| *k == block_id).count()
201 }
202
203 #[test]
204 fn an_unleased_block_is_never_locked() {
205 assert!(!is_locked(&id("never-leased")));
206 }
207
208 #[test]
209 fn a_future_lease_locks_and_an_expired_one_does_not() {
210 let b = id("future-then-expired");
211 lock_until(&b, agentmux_common::time::now_ms() + 60_000);
212 assert!(is_locked(&b), "a lease ending in the future must lock");
213
214 lock_until(&b, agentmux_common::time::now_ms() - 1);
215 assert!(!is_locked(&b), "a lease ending in the past must not lock");
216 }
217
218 /// The expiry must be evaluated at read time, not stored as a boolean —
219 /// otherwise a lease taken while an agent worked would still read as
220 /// locked minutes later, which is exactly the "human can't type `exit`"
221 /// failure this whole mechanism has to avoid.
222 #[test]
223 fn a_lease_that_lapses_stops_locking_without_anyone_releasing_it() {
224 let b = id("lapses-on-its-own");
225 let now = agentmux_common::time::now_ms();
226 lock_until(&b, now + 40);
227 assert!(is_locked(&b));
228 std::thread::sleep(std::time::Duration::from_millis(60));
229 assert!(!is_locked(&b), "a lapsed lease must stop locking on its own");
230 }
231
232 #[test]
233 fn reading_a_lapsed_lease_prunes_it() {
234 let b = id("pruned-on-read");
235 lock_until(&b, agentmux_common::time::now_ms() + 20);
236 assert_eq!(entry_count(&b), 1);
237 std::thread::sleep(std::time::Duration::from_millis(40));
238 assert!(!is_locked(&b));
239 assert_eq!(entry_count(&b), 0, "a lapsed lease must not be retained after it's read");
240 }
241
242 /// `lock_until` with an already-past timestamp is a caller bug, but it
243 /// must not leave an entry behind for `is_locked` to trip over later.
244 #[test]
245 fn locking_into_the_past_stores_nothing() {
246 let b = id("past-lease");
247 lock_until(&b, agentmux_common::time::now_ms() - 5_000);
248 assert_eq!(entry_count(&b), 0);
249 assert!(!is_locked(&b));
250 }
251
252 #[test]
253 fn release_reports_whether_a_live_lease_was_held() {
254 let b = id("release-reporting");
255 assert!(!release(&b), "releasing an unleased block reports nothing was held");
256
257 lock_until(&b, agentmux_common::time::now_ms() + 60_000);
258 assert!(release(&b), "releasing a live lease reports it was held");
259 assert!(!is_locked(&b), "release must take effect immediately");
260 assert_eq!(entry_count(&b), 0);
261
262 lock_until(&b, agentmux_common::time::now_ms() + 30);
263 std::thread::sleep(std::time::Duration::from_millis(50));
264 assert!(!release(&b), "releasing an already-lapsed lease reports nothing was held");
265 }
266
267 /// One agent leasing its own pane's shell must not lock anyone else out —
268 /// the lease is per-block, and the registry is shared.
269 #[test]
270 fn leases_are_independent_per_block() {
271 let leased = id("independent-leased");
272 let other = id("independent-other");
273 lock_until(&leased, agentmux_common::time::now_ms() + 60_000);
274 assert!(is_locked(&leased));
275 assert!(!is_locked(&other));
276 release(&leased);
277 }
278
279 /// `release` alone clears only the copy `controllerinput` reads. The
280 /// copy that actually gates a human's keystrokes is the
281 /// `term:agentlockuntil` meta `AgentShellSubblock.tsx` reads — keystrokes
282 /// travel on `blockinput`, which has no server-side check — so releasing
283 /// without clearing meta leaves the human's input swallowed client-side
284 /// for the rest of the window. Codex P1 / ReAgent P1 on PR #3249.
285 #[test]
286 fn release_and_clear_meta_clears_the_copy_the_frontend_gates_on() {
287 use crate::backend::obj::Block;
288
289 let store = std::sync::Arc::new(
290 crate::backend::storage::store::Store::open_in_memory().expect("store"),
291 );
292 // A real UUID, not a readable label: `update_object_meta` parses the
293 // oref and rejects a non-UUID oid outright ("invalid object id"). A
294 // label would make this test pass for the wrong reason — the meta
295 // write would fail, `release_and_clear_meta` would log and move on,
296 // and the assertion below would be measuring nothing.
297 let block_id = uuid::Uuid::new_v4().to_string();
298 let mut block = Block {
299 oid: block_id.clone(),
300 meta: {
301 let mut m = crate::backend::obj::MetaMapType::new();
302 m.insert(
303 crate::server::META_KEY_AGENT_LOCK_UNTIL.to_string(),
304 serde_json::json!(agentmux_common::time::now_ms() + 60_000),
305 );
306 m
307 },
308 ..Default::default()
309 };
310 store.insert(&mut block).expect("insert");
311 lock_until(&block_id, agentmux_common::time::now_ms() + 60_000);
312
313 assert!(release_and_clear_meta(&block_id, &Some(store.clone()), &None));
314
315 assert!(!is_locked(&block_id), "the in-memory copy must be released");
316 let after: Block = store.must_get(&block_id).expect("block still exists");
317 let meta_val = after.meta.get(crate::server::META_KEY_AGENT_LOCK_UNTIL);
318 assert!(
319 meta_val.is_none() || meta_val == Some(&serde_json::Value::Null),
320 "the meta copy the frontend gates on must be cleared, got {meta_val:?}"
321 );
322 }
323
324 /// Re-leasing while a lease is live extends it rather than stacking —
325 /// this is what makes "refreshed on every agent write" work.
326 #[test]
327 fn relocking_extends_the_same_entry() {
328 let b = id("extend");
329 lock_until(&b, agentmux_common::time::now_ms() + 30);
330 lock_until(&b, agentmux_common::time::now_ms() + 60_000);
331 assert_eq!(entry_count(&b), 1);
332 std::thread::sleep(std::time::Duration::from_millis(50));
333 assert!(is_locked(&b), "the later lease must win, not the earlier expiry");
334 release(&b);
335 }
336}