-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(LAC-1432): parallel background agent sessions — registry wiring, input arbiter, per-session cursors, roster UI, notifications #474
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lacymorrow
merged 14 commits into
main
from
LAC-1432/session-registry-and-input-arbiter
Jul 24, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
4d12e53
wip(LAC-1432): session registry and input arbiter scaffolding
lacymorrow d891dc2
feat(LAC-1432): expose parallel agent-session registry to frontend
lacymorrow 6fc7d60
feat(LAC-1432): register agent runs in parallel-session registry
lacymorrow 09f1f2e
merge: resolve state.rs conflict with main (keep AgentSessionRegistry…
lacymorrow b54eac1
feat(LAC-1432): agent session switcher UI + fix dropped event constant
lacymorrow 2ed1069
refactor(LAC-1432): address Gemini code review — sync mutex for focus…
lacymorrow 116d0d5
feat(LAC-1432): parallel sessions — submit_query wiring, arbiter rout…
lacymorrow 6325062
Merge remote-tracking branch 'origin/main' into LAC-1432/session-regi…
lacymorrow 1efb145
fix(LAC-1432): InputArbiter::held_by deadlocked while a guard was held
lacymorrow 89de83b
fix(LAC-1432): address PR #474 code review findings
lacymorrow 92fd8b5
fix(LAC-1432): InputArbiter::default() used 50ms instead of DEFAULT_C…
lacymorrow c52a00b
fix(LAC-1432): close focus-reassignment race in AgentSessionRegistry:…
lacymorrow 7d5eaef
feat(LAC-1432): wire NeedsInput status + agent-session-needs-input em…
lacymorrow 9635a9f
style(LAC-1432): cargo fmt on needs-input session test
lacymorrow File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| use std::sync::{Arc, Mutex as StdMutex}; | ||
| use std::time::{Duration, Instant}; | ||
|
|
||
| use tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard}; | ||
| use tracing::{debug, trace}; | ||
|
|
||
| /// Serializes coordinate-based physical input across all agent sessions. | ||
| /// | ||
| /// macOS exposes exactly one hardware pointer, so N parallel agents cannot | ||
| /// execute CGEvent-based clicks, drags, or typing simultaneously. Every | ||
| /// call site that emits a physical input event must acquire a | ||
| /// [`PhysicalInputGuard`] first — the guard blocks other sessions until it | ||
| /// is dropped, and enforces a small cooldown between actions so we never | ||
| /// fire pointer events faster than macOS reliably delivers them. | ||
| /// | ||
| /// AX-grounded actions (`AXPress` via the accessibility API) do NOT go | ||
| /// through this arbiter. They do not move the physical pointer, so multiple | ||
| /// agents can invoke them concurrently — that is Juno's parallelism moat. | ||
| /// Default cooldown between coordinate-based input actions. | ||
| /// | ||
| /// 500 ms gives macOS time to process one event before the next lands. | ||
| /// Callers that need tighter pacing can construct an [`InputArbiter`] with | ||
| /// a custom [`Duration`], but this constant should be preferred for | ||
| /// production agent sessions. | ||
| pub const DEFAULT_COOLDOWN: Duration = Duration::from_millis(500); | ||
|
|
||
| pub struct InputArbiter { | ||
| inner: Arc<TokioMutex<InputArbiterInner>>, | ||
| /// Observable holder id, kept OUTSIDE the input mutex so observers can | ||
| /// ask "who holds the arbiter?" while a guard is held. Storing it inside | ||
| /// `inner` would deadlock any `held_by()` call made during a hold. | ||
| holder: Arc<StdMutex<Option<String>>>, | ||
| cooldown: Duration, | ||
| } | ||
|
|
||
| #[derive(Default)] | ||
| struct InputArbiterInner { | ||
| last_action_at: Option<Instant>, | ||
| } | ||
|
|
||
| impl InputArbiter { | ||
| pub fn new(cooldown: Duration) -> Self { | ||
| Self { | ||
| inner: Arc::new(TokioMutex::new(InputArbiterInner::default())), | ||
| holder: Arc::new(StdMutex::new(None)), | ||
| cooldown, | ||
| } | ||
| } | ||
|
|
||
| pub fn cooldown(&self) -> Duration { | ||
| self.cooldown | ||
| } | ||
|
|
||
| fn set_holder(&self, session_id: Option<&str>) -> Option<String> { | ||
| let held = session_id.map(|s| s.to_string()); | ||
| *self | ||
| .holder | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) = held.clone(); | ||
| held | ||
| } | ||
|
|
||
| /// Acquire exclusive access to physical input. | ||
| /// | ||
| /// Blocks until any current holder releases, then sleeps for the | ||
| /// remainder of the cooldown if the previous action was too recent. | ||
| /// The returned guard tracks the caller's session id for observability; | ||
| /// pass `None` for internal/system callers that are not agent-scoped. | ||
| pub async fn acquire(&self, session_id: Option<&str>) -> PhysicalInputGuard { | ||
| let guard = self.inner.clone().lock_owned().await; | ||
| if let Some(last) = guard.last_action_at { | ||
| let elapsed = last.elapsed(); | ||
| if elapsed < self.cooldown { | ||
| let sleep_for = self.cooldown - elapsed; | ||
| trace!( | ||
| "InputArbiter cooldown sleep {:?} for session {:?}", | ||
| sleep_for, | ||
| session_id | ||
| ); | ||
| tokio::time::sleep(sleep_for).await; | ||
| } | ||
| } | ||
| let held_by = self.set_holder(session_id); | ||
| debug!("InputArbiter acquired by session {:?}", session_id); | ||
| PhysicalInputGuard { | ||
| guard, | ||
| holder: self.holder.clone(), | ||
| held_by, | ||
| } | ||
| } | ||
|
|
||
| /// Try to acquire without blocking. Returns `None` if another session holds it. | ||
| /// | ||
| /// Does NOT enforce the cooldown — callers using try_acquire opt into | ||
| /// firing as soon as they win the lock. Prefer [`acquire`] for normal | ||
| /// agent input paths. | ||
| pub async fn try_acquire(&self, session_id: Option<&str>) -> Option<PhysicalInputGuard> { | ||
| match self.inner.clone().try_lock_owned() { | ||
| Ok(guard) => { | ||
| let held_by = self.set_holder(session_id); | ||
| Some(PhysicalInputGuard { | ||
| guard, | ||
| holder: self.holder.clone(), | ||
| held_by, | ||
| }) | ||
| } | ||
| Err(_) => None, | ||
| } | ||
| } | ||
|
|
||
| /// Session id currently holding the arbiter, if any. For observability | ||
| /// only. Safe to call while a guard is held — the holder id lives | ||
| /// outside the input mutex. | ||
| pub fn held_by(&self) -> Option<String> { | ||
| self.holder | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| .clone() | ||
| } | ||
| } | ||
|
|
||
| impl Default for InputArbiter { | ||
| fn default() -> Self { | ||
| Self::new(DEFAULT_COOLDOWN) | ||
| } | ||
| } | ||
|
|
||
| /// RAII guard for exclusive physical input access. | ||
| /// | ||
| /// Records the release time on drop so the cooldown applies to the next | ||
| /// caller regardless of exit path (success, error via `?`, panic). | ||
| pub struct PhysicalInputGuard { | ||
| guard: OwnedMutexGuard<InputArbiterInner>, | ||
| holder: Arc<StdMutex<Option<String>>>, | ||
| held_by: Option<String>, | ||
| } | ||
|
|
||
| impl PhysicalInputGuard { | ||
| pub fn held_by(&self) -> Option<&str> { | ||
| self.held_by.as_deref() | ||
| } | ||
| } | ||
|
|
||
| impl Drop for PhysicalInputGuard { | ||
| fn drop(&mut self) { | ||
| self.guard.last_action_at = Some(Instant::now()); | ||
| *self | ||
| .holder | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| use std::time::Duration; | ||
|
|
||
| #[tokio::test] | ||
| async fn serializes_concurrent_acquire() { | ||
| let arbiter = Arc::new(InputArbiter::new(Duration::from_millis(0))); | ||
| let counter = Arc::new(AtomicUsize::new(0)); | ||
| let observed_max = Arc::new(AtomicUsize::new(0)); | ||
|
|
||
| let mut handles = Vec::new(); | ||
| for i in 0..8 { | ||
| let arbiter = arbiter.clone(); | ||
| let counter = counter.clone(); | ||
| let observed_max = observed_max.clone(); | ||
| handles.push(tokio::spawn(async move { | ||
| let _guard = arbiter.acquire(Some(&format!("s{i}"))).await; | ||
| let current = counter.fetch_add(1, Ordering::SeqCst) + 1; | ||
| observed_max.fetch_max(current, Ordering::SeqCst); | ||
| tokio::time::sleep(Duration::from_millis(2)).await; | ||
| counter.fetch_sub(1, Ordering::SeqCst); | ||
| })); | ||
| } | ||
| for h in handles { | ||
| h.await.expect("task ok"); | ||
| } | ||
|
|
||
| assert_eq!( | ||
| observed_max.load(Ordering::SeqCst), | ||
| 1, | ||
| "arbiter must serialize physical input across sessions" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn enforces_cooldown_between_actions() { | ||
| let cooldown = Duration::from_millis(30); | ||
| let arbiter = InputArbiter::new(cooldown); | ||
|
|
||
| { | ||
| let _g = arbiter.acquire(None).await; | ||
| } | ||
| let start = Instant::now(); | ||
| { | ||
| let _g = arbiter.acquire(None).await; | ||
| } | ||
| let elapsed = start.elapsed(); | ||
| assert!( | ||
| elapsed >= cooldown, | ||
| "second acquire completed too fast ({:?}, cooldown {:?})", | ||
| elapsed, | ||
| cooldown | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn try_acquire_returns_none_while_held() { | ||
| let arbiter = Arc::new(InputArbiter::new(Duration::from_millis(0))); | ||
| let _held = arbiter.acquire(Some("holder")).await; | ||
| assert!(arbiter.try_acquire(Some("other")).await.is_none()); | ||
| // held_by() must not deadlock while the guard is held — the holder | ||
| // id lives outside the input mutex precisely for this. | ||
| assert_eq!(arbiter.held_by().as_deref(), Some("holder")); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn guard_drop_releases_and_clears_holder() { | ||
| let arbiter = Arc::new(InputArbiter::new(Duration::from_millis(0))); | ||
| { | ||
| let _g = arbiter.acquire(Some("first")).await; | ||
| assert_eq!(arbiter.held_by().as_deref(), Some("first")); | ||
| } | ||
| // Holder is cleared once the guard drops. | ||
| assert_eq!(arbiter.held_by(), None); | ||
| // Second caller wins immediately, and holder is reset. | ||
| let second = arbiter.acquire(Some("second")).await; | ||
| assert_eq!(second.held_by(), Some("second")); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maintainability: Centralize default configuration values
According to the general rules, default configuration values should be centralized as public constants within their specific implementation modules to avoid redundancy and maintain consistency across the codebase.
Let's define a public constant for the default cooldown duration.
References
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 92fd8b5.
DEFAULT_COOLDOWN(500ms,Duration) already existed as the module's public constant; the real bug was thatDefault::default()hardcoded 50ms — a silent 10× discrepancy — and theAppStateconstruction site duplicated the 500ms literal. Both now referenceDEFAULT_COOLDOWN.