Skip to main content

Module agent_lock

Module agent_lock 

Source
Expand description

In-memory registry of PtyShell agent leases — the authoritative, hot-path answer to “is a human’s keystroke for this block currently locked out?”.

§Why this exists as its own thing rather than a meta read

The lease (term:agentlockuntil, docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md §10.3) is enforced in controllerinput (server/websocket.rs). That handler serves the Stop button’s SIGINT (useAgentCommands.ts) and the debounced PTY-width resizes usePtyWidth.ts sends — the latter in bursts while a pane is actively being dragged, which is precisely when the UI is most latency-sensitive. (It is NOT the keystroke path: terminal keystrokes go through the blockinput WS command instead — termViewModel.ts, AgentShellSubblock.tsx — see the gap note at the bottom of this comment.) The first implementation answered the is-it-leased question by reading the block out of Store:

ⓘ
mstore.get::<Block>(&cmd.blockid)  // synchronous SQLite, on every keystroke

Store is one process-wide SQLite connection behind one Mutex<Connection> (storage/store.rs’s own doc comment: “matching Go’s MaxOpenConns(1)”), and Store::get is a plain blocking call — so that read put a real disk query, serialized against every other Store user in the process, inside an async WS handler, inline on a Tokio worker thread with no block_in_place/spawn_blocking. That is the same failure class the sysinfo incident already cost this repo once (commit 0f34704a8, #1782). See docs/analysis/ANALYSIS_CROSS_PANE_INPUT_DELAY_REGRESSION_2026_09_15.md.

The lease is short-lived (AGENT_LOCK_WINDOW_MS, 4s), process-local, and recreated on every agent write. None of that needs durability — so it lives here instead: a HashMap behind an uncontended Mutex, held for nanoseconds with no I/O inside it. For the overwhelmingly common case (no agent is driving any shell) the map is empty and the check is a hash lookup that misses.

§Formerly a known gap, closed 2026-09-16

controllerinput is not the path a human’s keystrokes actually take. Both the Terminal pane (termViewModel.ts) and the agent pane’s drawer shell (AgentShellSubblock.tsx) send keystrokes via the blockinput WS command, which called blockcontroller::send_input directly with no lease check at all — so the server-side enforcement added in #3194 did not in fact cover the human-vs-agent write collision it was added for. The only thing standing in the way there was AgentShellSubblock’s own agentLocked() gate, i.e. exactly the eventually-consistent frontend check that enforcement was meant to backstop. This surfaced for real during a live vim investigation on 2026-09-16: an agent driving vim through PtyShellInput on its own composer-drawer shell (the same PTY a human’s blockinput keystrokes reach) had its writes interleave with other input on that pane, corrupting vim’s cursor-addressed alternate-screen redraw into a visibly garbled pane — not a hang, byte interleaving mid-escape-sequence. dispatch_blockinput (server/websocket.rs) now calls is_locked too, mirroring controllerinput’s check — see blockinput_is_dropped_while_an_agent_lock_is_active there for the regression test.

§Relationship to term:agentlockuntil meta

The meta key is still written, and is still what AgentShellSubblock.tsx renders its “Agent is using this shell” badge from — but it is now the UX layer only, exactly as that handler’s own comment already described it (“the frontend gate is the UX layer that keeps it from happening in the first place, not the correctness boundary”). This registry is the correctness boundary. They’re written together (lock_shell_for_agent) and cleared together (handle_pty_shell_stop, and shell exit — below).

§Fail-open on restart, deliberately

A restarted srv starts with an empty registry, so no lease survives it. That is the right direction to fail for a lock whose entire purpose is a UX-level write-collision guard, not a security boundary: the failure mode of a stale lease is a human who cannot type in their own terminal — at worst, cannot type exit to close the pane — which is strictly worse than an agent’s write interleaving with a human’s. Persisted meta could previously outlive the process that set it; memory cannot.

Statics§

LEASES 🔒
block_id → absolute unix-ms timestamp the lease expires at.

Functions§

is_locked
Is a human’s input for block_id currently locked out by an agent lease?
leases 🔒
lock_until
Lease block_id against human input until until_ms (absolute unix ms).
release
Release block_id’s lease immediately. Returns whether it was actually held (and unexpired) at the moment of the call — PtyShellStop reports that back to the agent as released.
release_and_clear_meta
Release the lease AND clear the term:agentlockuntil meta copy the frontend gates on, broadcasting the update so it takes effect now.