feat: add peer pod handshake command
This commit is contained in:
@@ -84,6 +84,16 @@ pub struct PodReclaimedChild {
|
||||
pub scope_delegated: Vec<PodSpawnedScopeRule>,
|
||||
}
|
||||
|
||||
/// One peer Pod made visible by an explicit peer handshake.
|
||||
///
|
||||
/// Peer visibility is intentionally separate from spawned-child delegation: it
|
||||
/// does not carry filesystem scope, callback ownership, output cursors, or
|
||||
/// lifecycle-notification authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PodPeer {
|
||||
pub pod_name: String,
|
||||
}
|
||||
|
||||
/// Persistent metadata for a Pod name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PodMetadata {
|
||||
@@ -94,6 +104,8 @@ pub struct PodMetadata {
|
||||
pub spawned_children: Vec<PodSpawnedChild>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reclaimed_children: Vec<PodReclaimedChild>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub peers: Vec<PodPeer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_manifest_snapshot: Option<serde_json::Value>,
|
||||
}
|
||||
@@ -106,6 +118,7 @@ impl PodMetadata {
|
||||
active,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
}
|
||||
}
|
||||
@@ -168,6 +181,33 @@ pub trait PodMetadataStore: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set peer visibility state while preserving active pointer, child state,
|
||||
/// and manifest snapshot.
|
||||
fn set_peers(&self, pod_name: &str, peers: Vec<PodPeer>) -> Result<PodMetadata, PodStoreError> {
|
||||
self.update_by_name(pod_name, |metadata| {
|
||||
metadata.peers = peers;
|
||||
})
|
||||
}
|
||||
|
||||
/// Add one peer if absent while preserving every other metadata field.
|
||||
fn add_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> {
|
||||
self.update_by_name(pod_name, |metadata| {
|
||||
if !metadata.peers.iter().any(|peer| peer.pod_name == peer_name) {
|
||||
metadata.peers.push(PodPeer {
|
||||
pod_name: peer_name.to_string(),
|
||||
});
|
||||
metadata.peers.sort_by(|a, b| a.pod_name.cmp(&b.pod_name));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove one peer while preserving every other metadata field.
|
||||
fn remove_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> {
|
||||
self.update_by_name(pod_name, |metadata| {
|
||||
metadata.peers.retain(|peer| peer.pod_name != peer_name);
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove reclaimed child delegations from the outstanding set and record
|
||||
/// them in durable reclaim history.
|
||||
fn reclaim_spawned_children(
|
||||
@@ -503,6 +543,52 @@ mod tests {
|
||||
assert_eq!(restored.resolved_manifest_snapshot, Some(snapshot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_updates_preserve_active_children_and_manifest_snapshot() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = FsPodStore::new(tmp.path()).unwrap();
|
||||
let active = PodActiveSegmentRef::active_segment(
|
||||
session_store::new_session_id(),
|
||||
session_store::new_segment_id(),
|
||||
);
|
||||
let snapshot = serde_json::json!({"pod":{"name":"agent"}});
|
||||
store
|
||||
.set_active("agent", Some(active.clone()), Some(snapshot.clone()))
|
||||
.unwrap();
|
||||
store
|
||||
.set_spawned_children(
|
||||
"agent",
|
||||
vec![PodSpawnedChild {
|
||||
pod_name: "child".into(),
|
||||
socket_path: std::path::Path::new("/tmp/child.sock").into(),
|
||||
scope_delegated: vec![],
|
||||
callback_address: std::path::Path::new("/tmp/parent.sock").into(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store.add_peer("agent", "peer-b").unwrap();
|
||||
store.add_peer("agent", "peer-a").unwrap();
|
||||
store.add_peer("agent", "peer-a").unwrap();
|
||||
|
||||
let restored = store.read_by_name("agent").unwrap().unwrap();
|
||||
assert_eq!(restored.active, Some(active));
|
||||
assert_eq!(restored.spawned_children.len(), 1);
|
||||
assert_eq!(restored.resolved_manifest_snapshot, Some(snapshot));
|
||||
assert_eq!(
|
||||
restored
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| peer.pod_name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["peer-a", "peer-b"]
|
||||
);
|
||||
|
||||
store.remove_peer("agent", "peer-a").unwrap();
|
||||
let restored = store.read_by_name("agent").unwrap().unwrap();
|
||||
assert_eq!(restored.peers.len(), 1);
|
||||
assert_eq!(restored.peers[0].pod_name, "peer-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_children_removes_outstanding_and_records_history() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -8,7 +8,7 @@ use pod_store::PodMetadataStore;
|
||||
use session_store::Store;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool};
|
||||
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool};
|
||||
use crate::ipc::alerter::Alerter;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
use crate::ipc::server::SocketServer;
|
||||
@@ -561,7 +561,8 @@ where
|
||||
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
|
||||
let discovery = PodDiscovery::new(pod_store, spawner_name, runtime_base, pwd, spawned_registry);
|
||||
worker.register_tool(list_pods_tool(discovery.clone()));
|
||||
worker.register_tool(restore_pod_tool(discovery));
|
||||
worker.register_tool(restore_pod_tool(discovery.clone()));
|
||||
worker.register_tool(send_to_peer_pod_tool(discovery));
|
||||
pod.attach_tracker(tracker);
|
||||
fs_for_view
|
||||
}
|
||||
@@ -862,6 +863,26 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
},
|
||||
|
||||
Method::RegisterPeer { name } => match discovery.register_peer(&name) {
|
||||
Ok(result) => match serde_json::to_value(result) {
|
||||
Ok(result) => {
|
||||
let _ = event_tx.send(Event::PeerRegistered { result });
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: format!("serialize peer registration result: {error}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ListCompletions is handled at the socket layer (direct
|
||||
// response). If it reaches the controller, ignore it.
|
||||
Method::ListCompletions { .. } => {}
|
||||
@@ -1063,10 +1084,10 @@ where
|
||||
notify_buffer.push_notify(message);
|
||||
}
|
||||
Some(Method::ListCompletions { .. }) => {}
|
||||
Some(Method::ListPods | Method::RestorePod { .. }) => {
|
||||
Some(Method::ListPods | Method::RestorePod { .. } | Method::RegisterPeer { .. }) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Pod discovery requests are only handled while the Pod is idle or paused"
|
||||
message: "Pod discovery/control requests are only handled while the Pod is idle or paused"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
+333
-8
@@ -17,9 +17,9 @@ use async_trait::async_trait;
|
||||
use client::PodRuntimeCommand;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::{Permission, ScopeRule};
|
||||
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
|
||||
use protocol::stream::JsonLineReader;
|
||||
use protocol::{Event, PodStatus};
|
||||
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore, validate_pod_name};
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{Event, Method, PodStatus};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::{SegmentId, SessionId};
|
||||
@@ -172,6 +172,41 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register_peer(
|
||||
&self,
|
||||
peer_name: &str,
|
||||
) -> Result<PeerRegistrationResult, PodDiscoveryError> {
|
||||
validate_pod_name(peer_name)?;
|
||||
if peer_name == self.self_pod_name {
|
||||
return Err(PodDiscoveryError::SelfPeer {
|
||||
pod_name: peer_name.to_string(),
|
||||
});
|
||||
}
|
||||
let self_exists = self.store.read_by_name(&self.self_pod_name)?.is_some();
|
||||
if !self_exists {
|
||||
return Err(PodDiscoveryError::StateMissing {
|
||||
pod_name: self.self_pod_name.clone(),
|
||||
});
|
||||
}
|
||||
let peer_exists = self.store.read_by_name(peer_name)?.is_some();
|
||||
if !peer_exists {
|
||||
return Err(PodDiscoveryError::MissingPod {
|
||||
pod_name: peer_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
self.store.add_peer(&self.self_pod_name, peer_name)?;
|
||||
if let Err(error) = self.store.add_peer(peer_name, &self.self_pod_name) {
|
||||
let _ = self.store.remove_peer(&self.self_pod_name, peer_name);
|
||||
return Err(PodDiscoveryError::PodStore(error));
|
||||
}
|
||||
|
||||
Ok(PeerRegistrationResult {
|
||||
source: self.self_pod_name.clone(),
|
||||
peer: peer_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn visibility(&self) -> Result<VisibilitySet, PodDiscoveryError> {
|
||||
let mut visible = BTreeMap::new();
|
||||
let mut child_sockets = BTreeMap::new();
|
||||
@@ -187,6 +222,11 @@ where
|
||||
child_sockets.insert(child.pod_name.clone(), child.socket_path.clone());
|
||||
comm_registry.insert(child.pod_name.clone(), comm_info_from_spawned_child(&child));
|
||||
}
|
||||
for peer in metadata.peers {
|
||||
visible
|
||||
.entry(peer.pod_name)
|
||||
.or_insert(VisibilityReason::Peer);
|
||||
}
|
||||
}
|
||||
|
||||
// The live in-memory registry covers just-spawned children even if a
|
||||
@@ -379,6 +419,7 @@ where
|
||||
pub enum VisibilityReason {
|
||||
SelfPod,
|
||||
SpawnedChild,
|
||||
Peer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -525,6 +566,12 @@ pub enum RestoreResult {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PeerRegistrationResult {
|
||||
pub source: String,
|
||||
pub peer: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PodDiscoveryError {
|
||||
#[error("pod state missing for `{pod_name}`")]
|
||||
@@ -533,6 +580,10 @@ pub enum PodDiscoveryError {
|
||||
NotVisible { pod_name: String },
|
||||
#[error("pod `{pod_name}` is not restorable: {reason}")]
|
||||
NotRestorable { pod_name: String, reason: String },
|
||||
#[error("pod `{pod_name}` cannot be registered as a peer of itself")]
|
||||
SelfPeer { pod_name: String },
|
||||
#[error("pod `{pod_name}` does not exist")]
|
||||
MissingPod { pod_name: String },
|
||||
#[error(
|
||||
"pod `{pod_name}` segment {segment_id} is locked by `{owner_pod}` pid {pid} at {socket_path}"
|
||||
)]
|
||||
@@ -683,6 +734,14 @@ struct PodNameInput {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SendToPeerPodInput {
|
||||
/// Target peer Pod name.
|
||||
name: String,
|
||||
/// Text delivered to the peer as a peer notification.
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct ListPodsTool<St> {
|
||||
discovery: PodDiscovery<St>,
|
||||
}
|
||||
@@ -776,6 +835,83 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
const SEND_TO_PEER_POD_DESCRIPTION: &str = "Send a text message to a peer Pod made visible by an explicit peer handshake. The message is delivered as a peer notification through the target Pod's durable notification/history path. This does not grant delegated scope, create a spawned-child output cursor, imply parent ownership, or produce child completion notifications. Fails if the target is not a visible live peer.";
|
||||
|
||||
struct SendToPeerPodTool<St> {
|
||||
discovery: PodDiscovery<St>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<St> Tool for SendToPeerPodTool<St>
|
||||
where
|
||||
St: PodMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let input: SendToPeerPodInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SendToPeerPod input: {e}")))?;
|
||||
let detail = self
|
||||
.discovery
|
||||
.inspect(&input.name)
|
||||
.await
|
||||
.map_err(discovery_error_to_tool_error)?;
|
||||
if detail.visibility != VisibilityReason::Peer {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"pod `{}` is visible as {:?}, not as a peer",
|
||||
input.name, detail.visibility
|
||||
)));
|
||||
}
|
||||
if !detail.live.reachable {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"peer pod `{}` is not live/reachable; restore it before sending",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"[Peer message from `{}`]\n{}",
|
||||
self.discovery.self_pod_name, input.message
|
||||
);
|
||||
send_peer_notify(&detail.live.socket_path, message)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("send to peer `{}`: {error}", input.name))
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary: format!("sent peer message to `{}`", input.name),
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_to_peer_pod_tool<St>(discovery: PodDiscovery<St>) -> ToolDefinition
|
||||
where
|
||||
St: PodMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new("SendToPeerPod")
|
||||
.description(SEND_TO_PEER_POD_DESCRIPTION)
|
||||
.input_schema(serde_json::to_value(schemars::schema_for!(SendToPeerPodInput)).unwrap());
|
||||
let tool: Arc<dyn Tool> = Arc::new(SendToPeerPodTool {
|
||||
discovery: discovery.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> {
|
||||
let stream = tokio::time::timeout(Duration::from_secs(5), UnixStream::connect(socket_path))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "connect timed out"))??;
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
writer.write(&Method::Notify { message }),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "write timed out"))?
|
||||
}
|
||||
|
||||
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
serde_json::to_string_pretty(value)
|
||||
.map_err(|e| ToolError::Internal(format!("serialize pod discovery output: {e}")))
|
||||
@@ -785,7 +921,9 @@ fn discovery_error_to_tool_error(error: PodDiscoveryError) -> ToolError {
|
||||
match error {
|
||||
PodDiscoveryError::StateMissing { .. }
|
||||
| PodDiscoveryError::NotVisible { .. }
|
||||
| PodDiscoveryError::NotRestorable { .. } => ToolError::InvalidArgument(error.to_string()),
|
||||
| PodDiscoveryError::NotRestorable { .. }
|
||||
| PodDiscoveryError::SelfPeer { .. }
|
||||
| PodDiscoveryError::MissingPod { .. } => ToolError::InvalidArgument(error.to_string()),
|
||||
PodDiscoveryError::LockConflict { .. }
|
||||
| PodDiscoveryError::Store(_)
|
||||
| PodDiscoveryError::PodStore(_)
|
||||
@@ -805,7 +943,7 @@ mod tests {
|
||||
use manifest::{Permission, ScopeRule};
|
||||
use pod_store::{FsPodStore, PodSpawnedChild, PodSpawnedScopeRule};
|
||||
use protocol::stream::JsonLineWriter;
|
||||
use protocol::{Alert, AlertLevel, AlertSource, Greeting};
|
||||
use protocol::{Alert, AlertLevel, AlertSource};
|
||||
use session_store::{new_segment_id, new_session_id};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::UnixListener;
|
||||
@@ -844,6 +982,9 @@ mod tests {
|
||||
child("child-pending", &pending_socket),
|
||||
],
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "peer".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
};
|
||||
store.write(&parent).unwrap();
|
||||
@@ -856,6 +997,7 @@ mod tests {
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -868,6 +1010,7 @@ mod tests {
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -877,6 +1020,7 @@ mod tests {
|
||||
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -889,6 +1033,19 @@ mod tests {
|
||||
)),
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "peer".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "parent".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
@@ -913,14 +1070,37 @@ mod tests {
|
||||
let restore_tool_def = restore_pod_tool(discovery.clone());
|
||||
let (restore_meta, _) = restore_tool_def();
|
||||
assert_eq!(restore_meta.name, "RestorePod");
|
||||
let send_peer_tool_def = send_to_peer_pod_tool(discovery.clone());
|
||||
let (send_peer_meta, _) = send_peer_tool_def();
|
||||
assert_eq!(send_peer_meta.name, "SendToPeerPod");
|
||||
|
||||
let list = discovery.list_visible().await.unwrap();
|
||||
let names: Vec<_> = list.iter().map(|p| p.pod_name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["child-live", "child-pending", "child-stale", "parent"]
|
||||
vec![
|
||||
"child-live",
|
||||
"child-pending",
|
||||
"child-stale",
|
||||
"parent",
|
||||
"peer"
|
||||
]
|
||||
);
|
||||
assert!(!names.contains(&"hidden"));
|
||||
assert_eq!(
|
||||
list.iter()
|
||||
.find(|p| p.pod_name == "peer")
|
||||
.unwrap()
|
||||
.visibility,
|
||||
VisibilityReason::Peer
|
||||
);
|
||||
assert_eq!(
|
||||
list.iter()
|
||||
.find(|p| p.pod_name == "child-live")
|
||||
.unwrap()
|
||||
.visibility,
|
||||
VisibilityReason::SpawnedChild
|
||||
);
|
||||
assert!(
|
||||
list.iter()
|
||||
.find(|p| p.pod_name == "child-live")
|
||||
@@ -983,6 +1163,151 @@ mod tests {
|
||||
live_listener.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn register_peer_persists_reciprocal_metadata() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
std::fs::create_dir_all(&runtime_base).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
store.write(&PodMetadata::new("source", None)).unwrap();
|
||||
store.write(&PodMetadata::new("target", None)).unwrap();
|
||||
let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap());
|
||||
|
||||
let discovery = PodDiscovery::new(
|
||||
store.clone(),
|
||||
"source".into(),
|
||||
runtime_base.clone(),
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
);
|
||||
let result = discovery.register_peer("target").unwrap();
|
||||
assert_eq!(result.source, "source");
|
||||
assert_eq!(result.peer, "target");
|
||||
|
||||
let source = store.read_by_name("source").unwrap().unwrap();
|
||||
let target = store.read_by_name("target").unwrap().unwrap();
|
||||
assert_eq!(source.peers[0].pod_name, "target");
|
||||
assert_eq!(target.peers[0].pod_name, "source");
|
||||
|
||||
let list = discovery.list_visible().await.unwrap();
|
||||
assert_eq!(
|
||||
list.iter()
|
||||
.find(|item| item.pod_name == "target")
|
||||
.unwrap()
|
||||
.visibility,
|
||||
VisibilityReason::Peer
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn register_peer_rejects_self_and_missing_target() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
std::fs::create_dir_all(&runtime_base).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
store.write(&PodMetadata::new("source", None)).unwrap();
|
||||
let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap());
|
||||
let discovery = PodDiscovery::new(
|
||||
store,
|
||||
"source".into(),
|
||||
runtime_base,
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
);
|
||||
|
||||
let self_err = discovery.register_peer("source").unwrap_err();
|
||||
assert!(matches!(self_err, PodDiscoveryError::SelfPeer { .. }));
|
||||
let missing_err = discovery.register_peer("missing").unwrap_err();
|
||||
assert!(matches!(missing_err, PodDiscoveryError::MissingPod { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn send_to_peer_pod_delivers_notify_without_child_registry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
std::fs::create_dir_all(runtime_base.join("target")).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "source".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "target".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "target".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "source".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap());
|
||||
let discovery = PodDiscovery::new(
|
||||
store,
|
||||
"source".into(),
|
||||
runtime_base.clone(),
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
);
|
||||
|
||||
let socket = runtime_base.join("target").join("sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let target = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: PodStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = JsonLineReader::new(stream);
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::Notify { message } = method {
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
panic!("expected Notify, got {method:?}");
|
||||
}
|
||||
});
|
||||
|
||||
let (_, tool) = send_to_peer_pod_tool(discovery)();
|
||||
let output = tool
|
||||
.execute(r#"{"name":"target","message":"hello"}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.summary, "sent peer message to `target`");
|
||||
let message = rx.recv().await.unwrap();
|
||||
assert_eq!(message, "[Peer message from `source`]\nhello");
|
||||
target.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn probe_socket_reads_status_after_replayed_alert() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -1003,7 +1328,7 @@ mod tests {
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: Greeting {
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "alerted".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
@@ -1051,7 +1376,7 @@ mod tests {
|
||||
let _ = writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: Greeting {
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "child-live".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
|
||||
@@ -64,6 +64,13 @@ pub enum Method {
|
||||
RestorePod {
|
||||
name: String,
|
||||
},
|
||||
/// Register another existing Pod as a reciprocal peer of this Pod.
|
||||
///
|
||||
/// This is metadata/control state only: it must not grant delegated scope,
|
||||
/// spawned-child ownership, output cursors, or child lifecycle authority.
|
||||
RegisterPeer {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Typed lifecycle events sent from a child Pod to its parent.
|
||||
@@ -480,6 +487,10 @@ pub enum Event {
|
||||
PodRestored {
|
||||
result: serde_json::Value,
|
||||
},
|
||||
/// Reply to `Method::RegisterPeer`.
|
||||
PeerRegistered {
|
||||
result: serde_json::Value,
|
||||
},
|
||||
Alert(Alert),
|
||||
/// Latest memory extract/consolidation lifecycle event for UI observability.
|
||||
///
|
||||
@@ -1465,13 +1476,17 @@ mod tests {
|
||||
Method::RestorePod {
|
||||
name: "child".into(),
|
||||
},
|
||||
Method::RegisterPeer {
|
||||
name: "peer".into(),
|
||||
},
|
||||
];
|
||||
for method in methods {
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match (decoded, method) {
|
||||
(Method::ListPods, Method::ListPods)
|
||||
| (Method::RestorePod { .. }, Method::RestorePod { .. }) => {}
|
||||
| (Method::RestorePod { .. }, Method::RestorePod { .. })
|
||||
| (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {}
|
||||
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1486,6 +1501,9 @@ mod tests {
|
||||
Event::PodRestored {
|
||||
result: serde_json::json!({ "action": "already_live" }),
|
||||
},
|
||||
Event::PeerRegistered {
|
||||
result: serde_json::json!({ "source": "self", "peer": "other" }),
|
||||
},
|
||||
];
|
||||
for event in events {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -1497,6 +1515,9 @@ mod tests {
|
||||
(Event::PodRestored { result }, Event::PodRestored { result: expected }) => {
|
||||
assert_eq!(result, expected)
|
||||
}
|
||||
(Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => {
|
||||
assert_eq!(result, expected)
|
||||
}
|
||||
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,6 +1204,22 @@ impl App {
|
||||
});
|
||||
}
|
||||
Event::PodsListed { .. } | Event::PodRestored { .. } => {}
|
||||
Event::PeerRegistered { result } => {
|
||||
let source = result
|
||||
.get("source")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("this Pod");
|
||||
let peer = result
|
||||
.get("peer")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("peer Pod");
|
||||
self.flash_actionbar_notice(
|
||||
format!("Peer handshake registered: `{source}` ↔ `{peer}`"),
|
||||
ActionbarNoticeLevel::Info,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(4),
|
||||
);
|
||||
}
|
||||
Event::Shutdown => {
|
||||
self.mark_orphan_compacts_incomplete();
|
||||
self.quit = true;
|
||||
|
||||
@@ -156,6 +156,15 @@ impl CommandRegistry {
|
||||
can_execute: rewind_available,
|
||||
executor: rewind_command,
|
||||
});
|
||||
registry.register(CommandSpec {
|
||||
name: "peer",
|
||||
aliases: &[],
|
||||
usage: "peer <pod-name>",
|
||||
description: "Make another existing Pod visible as a reciprocal peer.",
|
||||
argument_parser: peer_args,
|
||||
can_execute: peer_available,
|
||||
executor: peer_command,
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -302,6 +311,17 @@ fn rewind_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
let args = CommandArgs::parse_whitespace(raw);
|
||||
if args.argv().len() == 1 {
|
||||
Ok(args)
|
||||
} else {
|
||||
Err(CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: peer <pod-name>",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
@@ -335,6 +355,20 @@ fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagn
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot register a peer before the Pod is connected.",
|
||||
));
|
||||
}
|
||||
if environment.running {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot register a peer while the Pod is running.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
if let Some(name) = invocation.args.argv().first() {
|
||||
let Some(command) = invocation.registry.find(name) else {
|
||||
@@ -394,6 +428,20 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.command;
|
||||
let _ = invocation.environment;
|
||||
let name = invocation.args.argv()[0].clone();
|
||||
CommandExecution {
|
||||
method: Some(Method::RegisterPeer { name: name.clone() }),
|
||||
diagnostics: vec![CommandDiagnostic::new(format!(
|
||||
"peer handshake requested with `{name}`"
|
||||
))],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -501,4 +549,38 @@ mod tests {
|
||||
let result = registry.dispatch("rewind", &paused);
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_command_returns_register_peer_method() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let result = registry.dispatch("peer reviewer", &env());
|
||||
assert!(matches!(
|
||||
result.method,
|
||||
Some(Method::RegisterPeer { ref name }) if name == "reviewer"
|
||||
));
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("peer handshake"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_invalid_arguments_are_local_diagnostic() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["peer", "peer one two"] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(result.method.is_none());
|
||||
assert!(!result.exit_command_mode);
|
||||
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_rejects_running() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let mut running = env();
|
||||
running.running = true;
|
||||
let result = registry.dispatch("peer reviewer", &running);
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.diagnostics[0].message.contains("running"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user