Skip to main content

forge_workspace/mcp/peers/
facade.rs

1//! The narrow workspace-state surface the peer-coordination tools
2//! depend on, plus the production impl on [`Workspace`] and a mock
3//! for unit tests.
4//!
5//! ## Why a trait, not direct calls on `Workspace`
6//!
7//! The four Tool impls (C5-C8) hold an `Arc<dyn WorkspaceFacade>`
8//! rather than `Arc<Workspace>`. Two reasons:
9//!
10//! 1. **Testability.** Unit tests instantiate `MockWorkspaceFacade`
11//!    (capture-into-Vec) and assert the tool dispatched the expected
12//!    commands without spinning up real `Workspace` infrastructure
13//!    (which needs a workspace dir, account state map, etc.).
14//! 2. **Narrow API surface.** The tools only need ~7 methods; binding
15//!    them to the full `Workspace` would surface everything to anyone
16//!    poking at the tools.
17//!
18//! ## Method shape
19//!
20//! All methods are `&self` + `parking_lot::Mutex` internally, so the
21//! trait is plain (not `async_trait`). Tools may `await` other things
22//! inside their `Tool::call` body but the facade calls themselves
23//! return synchronously after a mutex acquire.
24
25use std::sync::{Arc, Weak};
26
27use crate::mcp::peers::types::{PeerLiveness, PeerStatus, WrappedPrompt};
28
29use crate::SessionSlot;
30use crate::protocol::Command;
31use crate::workspace::Workspace;
32
33/// Why a `deliver_peer_prompt` call failed synchronously.
34///
35/// An async failure - a message parked for a sleeping project whose spawn
36/// then fails - is not reported here: it reaches the sender later as a
37/// delivery notice through [`crate::Workspace::notice_undelivered_message`].
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum DeliverError {
40    /// No project named `name` in forge.toml.
41    UnknownTarget { name: String },
42    /// The command reached the handler and the handler refused it: the
43    /// target's session did not take the prompt, the project left
44    /// `forge.toml` between the tool's check and the dispatch, or the
45    /// workspace is shutting down. `message` says which.
46    DispatchFailed { message: String },
47}
48
49/// The narrow workspace-state surface peer-coordination tools call
50/// into. See module docs for design rationale.
51pub trait WorkspaceFacade: Send + Sync {
52    /// Snapshot of every configured project's peer status, in
53    /// forge.toml declaration order. Computed fresh on each call.
54    fn list_peers(&self) -> Vec<PeerStatus>;
55
56    /// Identity of the calling session. Returns `None` when `caller`
57    /// doesn't resolve to any known project (defensive - the tools
58    /// closure-bind a real key at spawn time, so this should be
59    /// `Some` in practice).
60    fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus>;
61
62    /// Deliver a wrapped peer prompt to `target_project`.
63    ///
64    /// The answer is the handler's own: `Ok(())` once the target's session
65    /// took the prompt or it was parked for a spawn the handler kicked off,
66    /// `Err(UnknownTarget)` for a project not in forge.toml, and
67    /// `Err(DispatchFailed)` when the handler refused the delivery. A
68    /// delivery that never left forge is never `Ok` (#2342).
69    fn deliver_peer_prompt(
70        &self,
71        caller: &SessionSlot,
72        target_project: &str,
73        wrapped: WrappedPrompt,
74    ) -> Result<(), DeliverError>;
75
76    /// The exit call: the note is accepted only while a handoff is running on
77    /// the caller's own seat. A handoff starts at `/handoff` or at the
78    /// automatic trigger (fill or compaction), which dispatch the command
79    /// directly; no caller starts one through here.
80    fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError>;
81}
82
83/// Why a handoff could not be started or completed.
84#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
85pub enum HandoffError {
86    #[error("the seat {slot} is not running; a handoff needs a live session to hand over")]
87    SessionGone { slot: String },
88    #[error(
89        "no handoff is running on {slot}; a handoff starts at /handoff or at the automatic trigger \
90         (fill or compaction)"
91    )]
92    NotInAHandoff { slot: String },
93}
94
95/// Production impl. Holds a `Weak<Workspace>` rather than
96/// `Arc<Workspace>` so the construction sites
97/// (`Arc::new(weak) as Arc<dyn WorkspaceFacade>`) don't close the
98/// Workspace → pool → AgentHandle → bridge → MCP → Tool → facade
99/// → Workspace strong cycle. Audit C7.
100///
101/// Every trait method starts with `upgrade()`. When the workspace
102/// has been dropped (only possible if `Workspace::shutdown` has run
103/// and tools are firing during teardown), each method short-circuits
104/// to a sensible "workspace gone" fallback - typically returning a
105/// default, an error variant, or a no-op. The recipient session is
106/// dying anyway; the LLM call won't have anywhere to land.
107pub struct ProdWorkspaceFacade(pub Weak<Workspace>);
108
109impl ProdWorkspaceFacade {
110    /// Construct from a strong reference. Downgrades immediately so
111    /// the facade never closes a cycle through its own holdings.
112    pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn WorkspaceFacade> {
113        Arc::new(Self(Arc::downgrade(workspace)))
114    }
115}
116
117/// Return the first non-worker session in `view.sessions`, i.e. the
118/// project's lead. Worker sessions land in `view.sessions` once their
119/// `Connected` lands and the catalog indexes them; keying on the slot a
120/// spawn stated is what keeps a worker from shadowing its lead, so no
121/// filter over the catalog is needed.
122fn lead_for(
123    ws: &crate::workspace::Workspace,
124    view: &crate::views::ProjectView,
125) -> (SessionSlot, bool) {
126    let slot = SessionSlot::lead(&view.org, &view.name);
127    let running = ws.session_is_pooled(&slot);
128    (slot, running)
129}
130
131impl WorkspaceFacade for ProdWorkspaceFacade {
132    fn list_peers(&self) -> Vec<PeerStatus> {
133        let Some(ws) = self.0.upgrade() else { return Vec::new() };
134        ws.list_projects()
135            .into_iter()
136            .map(|view| {
137                let (lead, running) = lead_for(&ws, &view);
138                let liveness = if running { PeerLiveness::Running } else { PeerLiveness::Sleeping };
139                PeerStatus {
140                    name: view.name,
141                    org: view.org,
142                    path: view.path,
143                    status: liveness,
144                    spawned_at: ws.session_last_activity(&lead),
145                }
146            })
147            .collect()
148    }
149
150    fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus> {
151        let ws = self.0.upgrade()?;
152        let cx = crate::mcp::caller_context::caller_context(&ws, caller)?;
153        // Liveness keys off the LEAD's session, not the caller's:
154        // `PeerStatus` represents project-level peer identity. A worker
155        // calling `whoami` sees its project's peer identity (the same
156        // identity another peer would see when targeting this project),
157        // not its own session - the lead-only match the pre-#298 impl
158        // gated on was wrong because workers also legitimately ask "who
159        // am I as a peer?".
160        let status = if cx.lead_running { PeerLiveness::Running } else { PeerLiveness::Sleeping };
161        // The stamp is the caller's own seat, the value the row
162        // `agents__list` shows for it: a live worker's row carries its
163        // spawn time, a project's own row its session's last activity.
164        let spawned_at = if cx.is_lead {
165            ws.session_last_activity(&cx.lead)
166        } else {
167            ws.list_live_workers(&cx.project_key)
168                .iter()
169                .find(|w| w.slot == *caller)
170                .map(|w| w.spawned_at)
171        };
172        Some(PeerStatus {
173            name: cx.project_name,
174            org: cx.project_org,
175            path: cx.project_path,
176            status,
177            spawned_at,
178        })
179    }
180
181    fn deliver_peer_prompt(
182        &self,
183        caller: &SessionSlot,
184        target_project: &str,
185        wrapped: WrappedPrompt,
186    ) -> Result<(), DeliverError> {
187        let Some(ws) = self.0.upgrade() else {
188            return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
189        };
190        if !ws.list_projects().iter().any(|v| v.name == target_project) {
191            return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
192        }
193        let (respond, mut reply) = tokio::sync::oneshot::channel();
194        ws.dispatch(Command::DeliverPeerPrompt {
195            caller: caller.clone(),
196            target_project: target_project.to_owned(),
197            wrapped,
198            respond: Some(respond),
199        })
200        .map_err(|err| DeliverError::DispatchFailed {
201            message: format!("the workspace did not take the command ({err:?})"),
202        })?;
203        // **The handler runs inline on this thread**, so its answer is
204        // already in the channel. An empty channel is still not a success:
205        // a delivery nobody answered is a delivery nobody can claim.
206        delivery_answer(reply.try_recv())
207    }
208
209    fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError> {
210        let Some(ws) = self.0.upgrade() else {
211            return Err(HandoffError::SessionGone { slot: caller.display() });
212        };
213        // **An exit is only the end of a handoff already running.** The gate
214        // opens at the entry and closes on the swap, so it is the authority
215        // on whether this call has a turn to belong to.
216        if !ws.swap_in_progress(caller) {
217            return Err(HandoffError::NotInAHandoff { slot: caller.display() });
218        }
219        ws.dispatch(Command::Handoff { key: caller.clone(), brief: Some(note) })
220            .map_err(|_| HandoffError::SessionGone { slot: caller.display() })
221    }
222}
223
224/// The handler's answer narrowed to the facade's own: a command the
225/// workspace took but nobody answered is a refusal, not a success (#2342).
226fn delivery_answer(
227    reply: Result<Result<(), String>, tokio::sync::oneshot::error::TryRecvError>,
228) -> Result<(), DeliverError> {
229    match reply {
230        Ok(Ok(())) => Ok(()),
231        Ok(Err(message)) => Err(DeliverError::DispatchFailed { message }),
232        Err(_) => Err(DeliverError::DispatchFailed {
233            message: "the delivery handler took the command and answered nothing".to_owned(),
234        }),
235    }
236}
237
238/// Mock for unit tests in the Tool impls. Captures every dispatched call
239/// into a Vec so tests can assert "tool X dispatched deliver_peer_prompt
240/// with these args" without spinning up a real Workspace.
241#[cfg(any(test, feature = "testing"))]
242#[derive(Default)]
243pub struct MockWorkspaceFacade {
244    /// Pre-loaded peer status snapshot returned by `list_peers`.
245    pub peers: parking_lot::Mutex<Vec<PeerStatus>>,
246    /// Captured calls to `deliver_peer_prompt`.
247    pub deliver_calls: parking_lot::Mutex<Vec<(SessionSlot, String, WrappedPrompt)>>,
248    /// If set, `deliver_peer_prompt` returns this error instead of
249    /// running the normal lookup path. Lets tests force-test the
250    /// failure surface.
251    pub force_deliver_error: parking_lot::Mutex<Option<DeliverError>>,
252    /// Captured calls to `handoff`.
253    pub handoff_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
254}
255
256#[cfg(any(test, feature = "testing"))]
257impl MockWorkspaceFacade {
258    /// New empty mock; tests pre-load the fields they care about.
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    /// Cheap clone-and-share for tools that need `Arc<dyn ...>`.
264    pub fn into_arc(self) -> Arc<dyn WorkspaceFacade> {
265        Arc::new(self)
266    }
267}
268
269#[cfg(any(test, feature = "testing"))]
270impl WorkspaceFacade for MockWorkspaceFacade {
271    fn list_peers(&self) -> Vec<PeerStatus> {
272        self.peers.lock().clone()
273    }
274
275    fn whoami(&self, caller: &SessionSlot) -> Option<PeerStatus> {
276        // Mock's `whoami` answers from the mock's pre-loaded peers list
277        // with the row named for the caller's label - the mock's stand-in
278        // for the caller's own stamp, which the prod impl builds from the
279        // caller's live worker entry or its project's own row.
280        // Tests that want a specific identity pre-load the peers with an
281        // entry whose name matches their caller key convention.
282        self.peers.lock().iter().find(|p| p.name == caller.label()).cloned()
283    }
284
285    fn deliver_peer_prompt(
286        &self,
287        caller: &SessionSlot,
288        target_project: &str,
289        wrapped: WrappedPrompt,
290    ) -> Result<(), DeliverError> {
291        if let Some(err) = self.force_deliver_error.lock().clone() {
292            return Err(err);
293        }
294        let known = self.peers.lock().iter().any(|p| p.name == target_project);
295        if !known {
296            return Err(DeliverError::UnknownTarget { name: target_project.to_owned() });
297        }
298        self.deliver_calls.lock().push((caller.clone(), target_project.to_owned(), wrapped));
299        Ok(())
300    }
301
302    fn handoff(&self, caller: &SessionSlot, note: String) -> Result<(), HandoffError> {
303        self.handoff_calls.lock().push((caller.clone(), note));
304        Ok(())
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::mcp::peers::types::{MessageId, WrappedKind};
312    use std::path::PathBuf;
313
314    fn fake_key(s: &str) -> SessionSlot {
315        // Tests use the production constructor (no test-helpers feature
316        // here) - SessionSlot is just a String newtype, so this aligns
317        // with how the workspace itself constructs keys at runtime.
318        SessionSlot::from_str_for_test(s)
319    }
320
321    fn fake_peer(name: &str, liveness: PeerLiveness) -> PeerStatus {
322        PeerStatus {
323            name: name.to_owned(),
324            org: "TestOrg".to_owned(),
325            path: PathBuf::from(format!("/tmp/{name}")),
326            status: liveness,
327            spawned_at: None,
328        }
329    }
330
331    fn fake_wrapped() -> WrappedPrompt {
332        WrappedPrompt {
333            id: MessageId::mint(),
334            kind: WrappedKind::Message,
335            sender_name: "forge".to_owned(),
336            sender_org: "Personal".to_owned(),
337            body: "hi".to_owned(),
338        }
339    }
340
341    #[test]
342    fn mock_list_peers_returns_preloaded() {
343        let mock = MockWorkspaceFacade::new();
344        mock.peers.lock().push(fake_peer("alpha", PeerLiveness::Running));
345        mock.peers.lock().push(fake_peer("beta", PeerLiveness::Sleeping));
346        let peers = mock.list_peers();
347        assert_eq!(peers.len(), 2);
348        assert_eq!(peers[0].name, "alpha");
349        assert_eq!(peers[1].status, PeerLiveness::Sleeping);
350    }
351
352    #[test]
353    fn mock_deliver_unknown_target_errors() {
354        let mock = MockWorkspaceFacade::new();
355        let caller = fake_key("alpha");
356        let result = mock.deliver_peer_prompt(&caller, "missing", fake_wrapped());
357        assert!(
358            matches!(result, Err(DeliverError::UnknownTarget { ref name }) if name == "missing")
359        );
360    }
361
362    #[test]
363    fn mock_deliver_known_target_records_the_call() {
364        let mock = MockWorkspaceFacade::new();
365        mock.peers.lock().push(fake_peer("beta", PeerLiveness::Running));
366        let caller = fake_key("alpha");
367        assert_eq!(mock.deliver_peer_prompt(&caller, "beta", fake_wrapped()), Ok(()));
368        assert_eq!(mock.deliver_calls.lock().len(), 1);
369    }
370
371    #[test]
372    fn mock_whoami_matches_by_name() {
373        let mock = MockWorkspaceFacade::new();
374        mock.peers.lock().push(fake_peer("alpha", PeerLiveness::Running));
375        mock.peers.lock().push(fake_peer("beta", PeerLiveness::Sleeping));
376        // Convention in the mock: caller key string == project name.
377        // The prod impl matches by SessionSlot ↔ lead-session lookup.
378        let identity = mock.whoami(&fake_key("alpha"));
379        assert!(identity.is_some());
380        assert_eq!(identity.unwrap().name, "alpha");
381    }
382
383    #[test]
384    fn force_deliver_error_overrides_normal_path() {
385        let mock = MockWorkspaceFacade::new();
386        mock.peers.lock().push(fake_peer("beta", PeerLiveness::Running));
387        *mock.force_deliver_error.lock() =
388            Some(DeliverError::UnknownTarget { name: "forced".to_owned() });
389        let caller = fake_key("alpha");
390        let result = mock.deliver_peer_prompt(&caller, "beta", fake_wrapped());
391        assert!(matches!(
392            result,
393            Err(DeliverError::UnknownTarget { ref name }) if name == "forced"
394        ));
395    }
396
397    /// **A handler that answers nothing is not an ack.** The command was
398    /// taken; the empty channel is the delivery nobody can claim, and
399    /// mapping it to `Ok(())` is the warn-and-Ok overclaim returning
400    /// (#2342).
401    #[test]
402    fn a_handler_that_answers_nothing_is_not_an_ack() {
403        let (sender, mut reply) = tokio::sync::oneshot::channel::<Result<(), String>>();
404        drop(sender);
405        match delivery_answer(reply.try_recv()) {
406            Err(DeliverError::DispatchFailed { message }) => assert!(
407                message.contains("answered nothing"),
408                "the refusal says the command was taken but unanswered: {message}",
409            ),
410            other => panic!("an empty answer must not read as delivered; got {other:?}"),
411        }
412    }
413}
414
415/// Worker-shadow resolution for `lead_session_view` - the shared gate
416/// `list_peers` / `whoami` / `deliver_peer_prompt` all route through.
417/// Behind `test-helpers` because the fixtures use the cross-crate
418/// `ProjectView` / `SessionView` constructors.
419#[cfg(all(test, feature = "test-helpers"))]
420mod lead_resolution_tests {
421    use super::{DeliverError, HandoffError, ProdWorkspaceFacade, lead_for};
422    use crate::mcp::peers::types::WrappedKind;
423    use crate::target::ProjectKey;
424    use crate::views::{ProjectView, SessionView};
425    use crate::workspace::Workspace;
426    use crate::{MessageId, SessionSlot, WorkerEntry, WrappedPrompt};
427    use forge_primitives::WorkerLiveness;
428    use std::time::{Duration, SystemTime};
429
430    fn session(id: &str) -> SessionView {
431        SessionView::new_for_test(forge_primitives::SessionId::new(id), id, true, None)
432    }
433
434    fn worker_entry(slot: SessionSlot) -> WorkerEntry {
435        WorkerEntry {
436            label: "reviewer".into(),
437            charter: "review the diff".into(),
438            slot,
439            session_id: None,
440            status: WorkerLiveness::Running,
441            spawned_at: SystemTime::UNIX_EPOCH,
442            spawned_by: SessionSlot::lead("Test", "forge"),
443            needs_tag: false,
444            is_git_repo_at_spawn: false,
445            cwd: None,
446            diagnostic: None,
447            kick: None,
448        }
449    }
450
451    #[test]
452    fn names_the_lead_slot_when_no_lead_row_is_catalogued() {
453        // A project with no lead transcript still has a lead slot: the
454        // triple names it, so the catalog cannot take it away.
455        let (ws, _rx) = Workspace::testing_stub();
456        let key = ProjectKey::new("p".to_owned());
457        let lead = session("lead-uuid");
458        let view = ProjectView::new_for_test(key, "forge", "/tmp/forge", vec![lead.clone()]);
459        let (resolved, running) = lead_for(&ws, &view);
460        assert_eq!(resolved, SessionSlot::lead("Test", "forge"));
461        assert!(!running, "nothing is pooled for it here");
462    }
463
464    #[test]
465    fn names_the_lead_slot_even_when_every_session_is_a_live_worker() {
466        // LeadGone: the lead disconnected and only workers remain in
467        // the catalog. The slot still names the lead; only its
468        // liveness is gone, which is what the caller reads.
469        let (ws, _rx) = Workspace::testing_stub();
470        let key = ProjectKey::new("p".to_owned());
471        let worker = session("worker-uuid");
472        let view =
473            ProjectView::new_for_test(key.clone(), "forge", "/tmp/forge", vec![worker.clone()]);
474        ws.insert_live_worker(
475            &key,
476            worker_entry(SessionSlot::from_str_for_test(worker.session.as_str())),
477        );
478        let (resolved, running) = lead_for(&ws, &view);
479        assert_eq!(
480            resolved,
481            SessionSlot::lead("Test", "forge"),
482            "an all-worker project still names its lead"
483        );
484        assert!(!running, "and the lead is not running");
485    }
486
487    fn wrapped() -> WrappedPrompt {
488        WrappedPrompt {
489            id: MessageId::mint(),
490            kind: WrappedKind::Message,
491            sender_name: "forge".to_owned(),
492            sender_org: "Personal".to_owned(),
493            body: "hi".to_owned(),
494        }
495    }
496
497    /// **A delivery no session could take is not a success.** The running
498    /// branch is chosen on the pool entry alone; the dispatch then finds no
499    /// session task, and that has to reach the caller as an error rather
500    /// than the old warn-and-Ok (#2342).
501    #[test]
502    fn a_delivery_no_session_can_take_is_not_acked() {
503        let (ws, _rx) = Workspace::testing_stub();
504        ws.seed_test_project("beta", "/tmp/beta");
505        let slot = SessionSlot::lead("TestOrg", "beta");
506        let (handle, _commands) = Workspace::testing_stub_handle();
507        ws.pool.lock().insert(
508            slot,
509            crate::workspace::PooledAgent {
510                handle: std::sync::Arc::new(handle),
511                account: forge_gateway::AccountKey("acct-a".to_owned()),
512                permission_mode: None,
513                registration: None,
514                session_id: "pooled-no-task".to_owned(),
515            },
516        );
517        let facade = ProdWorkspaceFacade::from_arc(&ws);
518
519        let result = facade.deliver_peer_prompt(
520            &SessionSlot::from_str_for_test("caller"),
521            "beta",
522            WrappedPrompt {
523                id: MessageId::mint(),
524                kind: WrappedKind::Message,
525                sender_name: "forge".to_owned(),
526                sender_org: "Personal".to_owned(),
527                body: "hi".to_owned(),
528            },
529        );
530
531        match result {
532            Err(DeliverError::DispatchFailed { message }) => assert!(
533                message.contains("the target's running session did not take the message"),
534                "the refusal is the running branch's own answer, not an unknown target: {message}",
535            ),
536            other => panic!(
537                "a prompt the pooled lead's missing task cannot take is a refusal; got {other:?}"
538            ),
539        }
540    }
541
542    #[test]
543    fn deliver_to_unknown_project_errors() {
544        let (ws, _rx) = Workspace::testing_stub();
545        let facade = ProdWorkspaceFacade::from_arc(&ws);
546        let result = facade.deliver_peer_prompt(
547            &SessionSlot::from_str_for_test("caller"),
548            "no-such-project",
549            wrapped(),
550        );
551        assert!(
552            matches!(result, Err(DeliverError::UnknownTarget { ref name }) if name == "no-such-project")
553        );
554    }
555
556    #[test]
557    fn whoami_none_when_caller_leads_no_project() {
558        let (ws, _rx) = Workspace::testing_stub();
559        let facade = ProdWorkspaceFacade::from_arc(&ws);
560        assert!(facade.whoami(&SessionSlot::from_str_for_test("nobody")).is_none());
561    }
562
563    /// #298 Cause 1: workers can call `agents__whoami` and see their
564    /// project's peer identity. Pre-fix, the impl required the caller
565    /// to be the lead session, which returned None for any worker.
566    #[test]
567    fn whoami_resolves_worker_caller_to_project_peer_identity() {
568        let (ws, _rx) = Workspace::testing_stub();
569        ws.seed_test_project("myproj", "/tmp/myproj");
570        ws.record_connected_session("/tmp/myproj", "lead-uuid", None);
571        let pk = crate::ProjectKey::new(
572            forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/myproj")),
573        );
574        let worker = SessionSlot::worker("TestOrg", "myproj", "worker-uuid");
575        ws.insert_live_worker(&pk, worker_entry(worker.clone()));
576
577        let facade = ProdWorkspaceFacade::from_arc(&ws);
578        let status =
579            facade.whoami(&worker).expect("worker caller resolves to its project's peer identity");
580        assert_eq!(status.name, "myproj");
581        assert_eq!(status.org, "TestOrg");
582
583        // Regression lock: the pre-existing lead-only path still
584        // resolves to the same project identity.
585        let lead_status = facade
586            .whoami(&SessionSlot::lead("TestOrg", "myproj"))
587            .expect("lead caller still resolves");
588        assert_eq!(lead_status.name, "myproj");
589    }
590
591    /// A worker entry for `label` in the fixture's project, carrying a
592    /// chosen spawn stamp so a test can tell whose stamp a read carried.
593    fn entry_stamped(label: &str, spawned_at: SystemTime) -> WorkerEntry {
594        let slot = SessionSlot::worker("TestOrg", "myproj", label);
595        WorkerEntry { label: label.to_owned(), spawned_at, ..worker_entry(slot) }
596    }
597
598    /// #1716: `whoami` answers with the caller's own seat - the stamp the
599    /// `agents__list` row for that seat carries. A worker caller reads its
600    /// own spawn time rather than the lead's; a lead caller reads its
601    /// project's own row, unchanged.
602    #[test]
603    fn whoami_stamps_the_callers_own_seat() {
604        let (ws, _rx) = Workspace::testing_stub();
605        ws.seed_test_project("myproj", "/tmp/myproj");
606        let pk = crate::ProjectKey::new(
607            forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/myproj")),
608        );
609        // The lead is live and has a row, so its own stamp is a real value
610        // a wrongly-keyed read carries rather than reading as absent.
611        let lead = SessionSlot::lead("TestOrg", "myproj");
612        ws.seed_test_running_session_id(&lead, "lead-session");
613        ws.record_connected_session("/tmp/myproj", "lead-session", None);
614        let lead_stamp = ws.session_last_activity(&lead).expect("the lead's row is seeded");
615
616        // Two live workers, the caller second: a stamp taken from any
617        // worker rather than the caller's own seat lands on the other one.
618        let caller = SessionSlot::worker("TestOrg", "myproj", "caller");
619        let caller_stamp = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
620        ws.insert_live_worker(
621            &pk,
622            entry_stamped("other", SystemTime::UNIX_EPOCH + Duration::from_secs(2_000)),
623        );
624        ws.insert_live_worker(&pk, entry_stamped("caller", caller_stamp));
625        // The caller runs under a session of its own, so a stamp read from
626        // its transcript's last activity is a different real value.
627        ws.seed_test_running_session_id(&caller, "caller-session");
628        ws.record_connected_session("/tmp/myproj", "caller-session", None);
629
630        let facade = ProdWorkspaceFacade::from_arc(&ws);
631        let worker_status = facade.whoami(&caller).expect("a worker caller resolves");
632        assert_eq!(
633            worker_status.spawned_at,
634            Some(caller_stamp),
635            "a worker caller's stamp is its own seat's, the row agents__list shows for it \
636             (the lead's is {lead_stamp:?})",
637        );
638
639        let lead_status = facade.whoami(&lead).expect("a lead caller resolves");
640        assert_eq!(
641            lead_status.spawned_at,
642            Some(lead_stamp),
643            "a lead caller's stamp is its project's own row, unchanged",
644        );
645    }
646
647    /// #1716: a caller whose live-worker row is gone (the teardown race) is
648    /// answered with no stamp rather than falling back to the lead's - the
649    /// miss branch, where an `or_else` back to the project's stamp would
650    /// resurrect the #1716 shape.
651    #[test]
652    fn whoami_answers_a_caller_with_no_row_with_no_stamp() {
653        let (ws, _rx) = Workspace::testing_stub();
654        ws.seed_test_project("myproj", "/tmp/myproj");
655        // The lead's own stamp is a real value here, so a fallback to it
656        // reads as a wrong answer rather than as absence.
657        let lead = SessionSlot::lead("TestOrg", "myproj");
658        ws.seed_test_running_session_id(&lead, "lead-session");
659        ws.record_connected_session("/tmp/myproj", "lead-session", None);
660
661        let gone = SessionSlot::worker("TestOrg", "myproj", "gone");
662        let facade = ProdWorkspaceFacade::from_arc(&ws);
663        let status = facade.whoami(&gone).expect("a worker caller still resolves to its project");
664        assert_eq!(
665            status.spawned_at, None,
666            "a caller with no live-worker row is answered with no stamp, not the lead's",
667        );
668    }
669
670    /// **The accepting arm.** With a handoff running, the exit call is
671    /// dispatched with its note; a guard that refused whenever a brief was
672    /// present would close the only exit a handoff has.
673    #[test]
674    fn a_running_handoffs_exit_is_dispatched() {
675        let (ws, _rx) = Workspace::testing_stub();
676        ws.enable_test_dispatch_intercept();
677        let facade = ProdWorkspaceFacade::from_arc(&ws);
678        let slot = SessionSlot::worker("Busytools", "forge", "handoff-cut");
679        ws.begin_swap(&slot);
680
681        facade.handoff(&slot, "Now: the cut is in.".to_owned()).expect("accepted");
682
683        let routed = ws.drain_test_dispatch_buffer();
684        assert!(
685            routed.iter().any(|command| matches!(
686                command,
687                crate::protocol::Command::Handoff { brief: Some(note), .. }
688                    if note == "Now: the cut is in."
689            )),
690            "the note rides the command: {routed:?}",
691        );
692    }
693
694    /// **An exit call with no handoff running is refused, naming the two
695    /// doors.** `agents__handoff` completes a handoff; it can never start
696    /// one, which is what keeps `/handoff` and the fill trigger the only
697    /// entries.
698    #[test]
699    fn a_cold_handoff_exit_is_refused() {
700        let (ws, _rx) = Workspace::testing_stub();
701        let facade = ProdWorkspaceFacade::from_arc(&ws);
702        let slot = SessionSlot::worker("Busytools", "forge", "handoff-cut");
703
704        let error = facade
705            .handoff(&slot, "Now: the cut is in.".to_owned())
706            .expect_err("no handoff is running on this seat");
707        assert_eq!(error, HandoffError::NotInAHandoff { slot: slot.display() });
708        assert!(
709            error.to_string().contains("/handoff")
710                && error.to_string().contains("automatic trigger"),
711            "the refusal names both doors: {error}",
712        );
713    }
714}