Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e |
@@ -10,7 +10,7 @@ use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -102,8 +102,12 @@ pub struct SessionRecord {
|
||||
/// outside the sessions lock. Daemon-local; never rides the wire.
|
||||
pub acp: Option<Arc<AcpSession>>,
|
||||
/// Open log plus its post-append live sender. Their shared lock is the
|
||||
/// replay/live seam used by the structured WebSocket in #138.
|
||||
/// replay/live seam used by the structured WebSocket in #138. Callers may
|
||||
/// clone this handle under `sessions`, but must never hold either guard
|
||||
/// while acquiring the other.
|
||||
acp_events: Option<Arc<Mutex<AcpEventStream>>>,
|
||||
/// Latest activity snapshot applied, keyed to its originating stream.
|
||||
structured_activity_watermark: Option<StructuredActivityWatermark>,
|
||||
/// Completion signal for the task draining ACP notifications into the
|
||||
/// event log. Shutdown waits for it before returning.
|
||||
acp_event_pump: Option<watch::Receiver<bool>>,
|
||||
@@ -118,6 +122,12 @@ impl SessionRecord {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StructuredActivityWatermark {
|
||||
stream: Weak<Mutex<AcpEventStream>>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AcpEventStream {
|
||||
log: EventLog,
|
||||
@@ -129,6 +139,14 @@ struct AcpEventStream {
|
||||
/// claimed: it is queued onto ACP before another operation can proceed.
|
||||
operation: Arc<Mutex<()>>,
|
||||
dispatch: AcpDispatchState,
|
||||
activity_sequence: u64,
|
||||
}
|
||||
|
||||
impl AcpEventStream {
|
||||
fn activity_snapshot(&mut self) -> (u64, ActivityState) {
|
||||
self.activity_sequence += 1;
|
||||
(self.activity_sequence, self.projection.activity())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -1220,6 +1238,7 @@ impl SessionManager {
|
||||
pending_approvals: HashMap::new(),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
activity_sequence: 0,
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1323,6 +1342,7 @@ impl SessionManager {
|
||||
awaiting_suppressed: false,
|
||||
acp,
|
||||
acp_events,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: event_pump,
|
||||
},
|
||||
);
|
||||
@@ -1534,6 +1554,7 @@ impl SessionManager {
|
||||
// daemon; only tmux-backed sessions can be adopted.
|
||||
acp: None,
|
||||
acp_events: None,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: None,
|
||||
},
|
||||
);
|
||||
@@ -1655,6 +1676,7 @@ impl SessionManager {
|
||||
pending_approvals: HashMap::new(),
|
||||
operation: Arc::new(Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
activity_sequence: 0,
|
||||
})))
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -1702,6 +1724,7 @@ impl SessionManager {
|
||||
awaiting_suppressed: false,
|
||||
acp,
|
||||
acp_events,
|
||||
structured_activity_watermark: None,
|
||||
acp_event_pump: None,
|
||||
},
|
||||
);
|
||||
@@ -2122,57 +2145,59 @@ async fn sync_structured_activity_from_stream(
|
||||
id: Uuid,
|
||||
events: Arc<Mutex<AcpEventStream>>,
|
||||
) {
|
||||
let event_stream = events.lock().await;
|
||||
let raw = event_stream.projection.activity();
|
||||
// Stream state must stay fixed until its catalog transition is applied;
|
||||
// every other path releases `sessions` before awaiting this stream lock.
|
||||
apply_structured_activity(inner, id, Some(&events), raw).await;
|
||||
let (sequence, raw) = events.lock().await.activity_snapshot();
|
||||
apply_structured_activity(inner, id, Some((&events, sequence)), raw).await;
|
||||
}
|
||||
|
||||
async fn apply_structured_activity(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
expected_events: Option<&Arc<Mutex<AcpEventStream>>>,
|
||||
expected_events: Option<(&Arc<Mutex<AcpEventStream>>, u64)>,
|
||||
raw: ActivityState,
|
||||
) {
|
||||
let updated = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
let Some(record) = sessions.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
if record.session.view != SessionView::Structured {
|
||||
return;
|
||||
}
|
||||
let current_events = record.acp_events.as_ref();
|
||||
let stream_matches = match (expected_events, current_events) {
|
||||
(Some(expected), Some(current)) => Arc::ptr_eq(expected, current),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !stream_matches {
|
||||
return;
|
||||
}
|
||||
let raw = if record.session.status == SessionStatus::Dead {
|
||||
ActivityState::Error
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let activity = crate::activity::apply_focus_suppression(
|
||||
raw,
|
||||
record.focused_since,
|
||||
std::time::Instant::now(),
|
||||
&mut record.awaiting_suppressed,
|
||||
);
|
||||
if record.session.activity == activity {
|
||||
None
|
||||
} else {
|
||||
record.session.activity = activity;
|
||||
record.session.activity_since = Utc::now();
|
||||
Some(record.session.clone())
|
||||
}
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
let Some(record) = sessions.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
if record.session.view != SessionView::Structured {
|
||||
return;
|
||||
}
|
||||
let current_events = record.acp_events.as_ref();
|
||||
match (expected_events, current_events) {
|
||||
(Some((expected, sequence)), Some(current)) if Arc::ptr_eq(expected, current) => {
|
||||
let stream = Arc::downgrade(current);
|
||||
if record
|
||||
.structured_activity_watermark
|
||||
.as_ref()
|
||||
.is_some_and(|watermark| {
|
||||
watermark.stream.ptr_eq(&stream) && sequence < watermark.sequence
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
record.structured_activity_watermark =
|
||||
Some(StructuredActivityWatermark { stream, sequence });
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return,
|
||||
}
|
||||
let raw = if record.session.status == SessionStatus::Dead {
|
||||
ActivityState::Error
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let activity = crate::activity::apply_focus_suppression(
|
||||
raw,
|
||||
record.focused_since,
|
||||
std::time::Instant::now(),
|
||||
&mut record.awaiting_suppressed,
|
||||
);
|
||||
if record.session.activity != activity {
|
||||
record.session.activity = activity;
|
||||
record.session.activity_since = Utc::now();
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated {
|
||||
session: record.session.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2849,16 +2874,17 @@ mod tests {
|
||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
ToolCallUpdate, ToolCallUpdateFields,
|
||||
};
|
||||
use maestro_protocol::{ActivityState, CreateSessionRequest, SessionView};
|
||||
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
|
||||
|
||||
use super::{
|
||||
cancel_pending_approvals, permission_outcome, record_permission_request,
|
||||
run_structured_activity_pass, sync_structured_activity_from_stream, AcpDispatchState,
|
||||
AcpEventStream, ApprovalDecision, RecordOutcome, SessionManager,
|
||||
apply_structured_activity, cancel_pending_approvals, permission_outcome,
|
||||
record_permission_request, run_structured_activity_pass,
|
||||
sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
|
||||
RecordOutcome, SessionManager,
|
||||
};
|
||||
use crate::activity::FOCUS_CLEAR_HOLD;
|
||||
use crate::panes::FakePanes;
|
||||
use crate::projection::Projection;
|
||||
use crate::projection::{Projection, TurnLifecycle};
|
||||
use crate::sidecar::{
|
||||
EventCursor, EventLog, FaultyLogSink, PersistedEvent, SharedBuf, SidecarStore,
|
||||
};
|
||||
@@ -2887,6 +2913,7 @@ mod tests {
|
||||
pending_approvals: std::collections::HashMap::new(),
|
||||
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||
dispatch: AcpDispatchState::default(),
|
||||
activity_sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3043,6 +3070,87 @@ mod tests {
|
||||
.expect("terminal resize remains available");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_activity_releases_stream_before_waiting_for_catalog() {
|
||||
let (_state, manager, _panes, structured_id, events, _terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let sessions = manager.inner.sessions.lock().await;
|
||||
let sync =
|
||||
sync_structured_activity_from_stream(&manager.inner, structured_id, events.clone());
|
||||
tokio::pin!(sync);
|
||||
assert!(matches!(futures_util::poll!(sync.as_mut()), Poll::Pending));
|
||||
|
||||
assert!(
|
||||
events.try_lock().is_ok(),
|
||||
"activity sync held the event stream while waiting for the catalog"
|
||||
);
|
||||
|
||||
drop(sessions);
|
||||
tokio::time::timeout(Duration::from_secs(1), sync)
|
||||
.await
|
||||
.expect("activity sync did not finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newer_structured_activity_snapshot_cannot_be_overtaken() {
|
||||
let (_state, manager, _panes, structured_id, events, _terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let mut catalog = manager.events();
|
||||
manager
|
||||
.inner
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&structured_id)
|
||||
.unwrap()
|
||||
.focused_since = None;
|
||||
|
||||
let (older_sequence, older_activity) = events.lock().await.activity_snapshot();
|
||||
let (newer_sequence, newer_activity) = {
|
||||
let mut event_stream = events.lock().await;
|
||||
let turn = event_stream.projection.active_turn().unwrap();
|
||||
event_stream.projection.clear_approval();
|
||||
event_stream
|
||||
.projection
|
||||
.turn_lifecycle(TurnLifecycle::Finish, turn);
|
||||
event_stream.activity_snapshot()
|
||||
};
|
||||
|
||||
apply_structured_activity(
|
||||
&manager.inner,
|
||||
structured_id,
|
||||
Some((&events, newer_sequence)),
|
||||
newer_activity,
|
||||
)
|
||||
.await;
|
||||
apply_structured_activity(
|
||||
&manager.inner,
|
||||
structured_id,
|
||||
Some((&events, older_sequence)),
|
||||
older_activity,
|
||||
)
|
||||
.await;
|
||||
|
||||
let CatalogEvent::SessionUpdated { session } = catalog.try_recv().unwrap() else {
|
||||
panic!("unexpected catalog event");
|
||||
};
|
||||
assert_eq!(session.activity, ActivityState::Idle);
|
||||
assert!(matches!(
|
||||
catalog.try_recv(),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
|
||||
));
|
||||
assert_eq!(
|
||||
manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|session| session.id == structured_id)
|
||||
.unwrap()
|
||||
.activity,
|
||||
ActivityState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
||||
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||
@@ -3076,6 +3184,73 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_accepts_new_activity_after_a_high_old_watermark() {
|
||||
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||
manager_with_structured_stream().await;
|
||||
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
||||
manager
|
||||
.inner
|
||||
.sidecar
|
||||
.open_events(uuid::Uuid::new_v4())
|
||||
.expect("open replacement event log"),
|
||||
)));
|
||||
manager
|
||||
.inner
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&structured_id)
|
||||
.unwrap()
|
||||
.focused_since = None;
|
||||
let _ = old_events.lock().await.activity_snapshot();
|
||||
let (old_high_sequence, old_high_activity) = old_events.lock().await.activity_snapshot();
|
||||
apply_structured_activity(
|
||||
&manager.inner,
|
||||
structured_id,
|
||||
Some((&old_events, old_high_sequence)),
|
||||
old_high_activity,
|
||||
)
|
||||
.await;
|
||||
let (delayed_old_sequence, delayed_old_activity) =
|
||||
old_events.lock().await.activity_snapshot();
|
||||
|
||||
{
|
||||
let mut sessions = manager.inner.sessions.lock().await;
|
||||
let record = sessions.get_mut(&structured_id).unwrap();
|
||||
record.acp_events = Some(replacement.clone());
|
||||
record.session.activity = ActivityState::Error;
|
||||
}
|
||||
|
||||
let (new_sequence, new_activity) = replacement.lock().await.activity_snapshot();
|
||||
assert!(new_sequence < old_high_sequence);
|
||||
apply_structured_activity(
|
||||
&manager.inner,
|
||||
structured_id,
|
||||
Some((&replacement, new_sequence)),
|
||||
new_activity,
|
||||
)
|
||||
.await;
|
||||
apply_structured_activity(
|
||||
&manager.inner,
|
||||
structured_id,
|
||||
Some((&old_events, delayed_old_sequence)),
|
||||
delayed_old_activity,
|
||||
)
|
||||
.await;
|
||||
|
||||
let sessions = manager.inner.sessions.lock().await;
|
||||
let record = sessions.get(&structured_id).unwrap();
|
||||
assert_eq!(record.session.activity, ActivityState::Idle);
|
||||
let watermark = record.structured_activity_watermark.as_ref().unwrap();
|
||||
assert_eq!(watermark.sequence, new_sequence);
|
||||
assert!(watermark.stream.ptr_eq(&Arc::downgrade(&replacement)));
|
||||
assert!(Arc::ptr_eq(
|
||||
record.acp_events.as_ref().unwrap(),
|
||||
&replacement
|
||||
));
|
||||
}
|
||||
|
||||
/// A handle that can never append *or* roll back: opened read-only, so
|
||||
/// both `write_all` and the rollback `truncate` fail with the OS's own
|
||||
/// error (`ftruncate` requires a writable fd too) instead of a
|
||||
|
||||
Reference in New Issue
Block a user