Skip to main content

forge_workspace/mcp/workers/
facade.rs

1//! Narrow workspace-state surface the workers MCP Tool impls
2//! consume. Trait + mock for unit-testing the Tool impls without
3//! spinning up a real Workspace. Mirrors `crate::mcp::peers::facade`.
4//!
5//! The production impl is `ProdWorkerFacade` (below).
6
7use std::sync::{Arc, Weak};
8
9use forge_agent::client::SpawnFailureKind;
10use forge_primitives::WorkerStatus;
11
12use crate::SessionSlot;
13use crate::mcp::peers::types::WrappedPrompt;
14use crate::protocol::{Command, SessionChoice, WorkerSpawnReply};
15use crate::workspace::{ResumeTarget, Workspace};
16
17/// A delivery's own answer, and it has one shape: the prompt was accepted
18/// (handed to the target's session queue, held for its turn end, or parked
19/// for its connect). A prompt that reached no session is an error instead,
20/// so "accepted" is the whole of what this claims.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum WorkerTargetStatus {
23    Delivered,
24}
25
26/// Error from `deliver_worker_prompt`. A prompt not yet taken is not one:
27/// a worker that never finishes connecting leaves a parked message that
28/// `Workspace::expire_parked_for_slot` acknowledges back to its sender.
29#[derive(Debug, PartialEq, Eq)]
30pub enum WorkerDeliverError {
31    /// No worker by `label` in `project_key` at all.
32    UnknownLabel { project_key: String, label: String },
33    /// The label's row exists but is not live - it crashed or was closed,
34    /// and the row is kept so the label can be respawned. Distinct from
35    /// [`Self::UnknownLabel`], which claims the label was never there.
36    NotLive { project_key: String, label: String },
37    /// The delivery did not happen: the handler found nothing that could
38    /// take the prompt, the workspace channel failed, or the handler
39    /// dropped its reply, so whether anything took the message is
40    /// unknown. Distinct from [`Self::UnknownLabel`], which claims there
41    /// is no live match.
42    DispatchFailed { message: String },
43}
44
45/// Error from `deliver_prompt_to_lead`. The caller wants to send a
46/// prompt back to its lead via the reserved `"lead"` addressing
47/// keyword.
48#[derive(Debug, PartialEq, Eq)]
49pub enum WorkerLeadDeliverError {
50    /// Caller resolves to no known session (defensive - should not
51    /// happen with a valid caller slot).
52    UnknownCaller,
53    /// Caller is a project lead - leads have no lead to talk back
54    /// to. The `"lead"` keyword is worker-only.
55    LeadCallerHasNoLead,
56    /// Worker's recorded lead session no longer resolves to a session
57    /// in the pool (lead session ended). The worker can't reach its
58    /// lead anymore.
59    LeadGone,
60    /// The delivery did not happen (see
61    /// [`WorkerDeliverError::DispatchFailed`]).
62    DispatchFailed { message: String },
63}
64
65/// The label reserved for a project's own agent. A caller addresses it
66/// as the `label` of an `agents__send_message` target;
67/// `agents__spawn` rejects the label so no live worker can shadow the
68/// keyword.
69pub use forge_primitives::LEAD_LABEL;
70
71/// Synchronous error from `spawn_worker`. All gating happens before
72/// the workspace dispatch is even issued.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum WorkerSpawnError {
75    /// Caller is a worker, not a project lead. v1 is lead-only.
76    NotLeadCaller,
77    /// `label` is empty after trim.
78    EmptyLabel,
79    /// `label` collides with the reserved `"lead"` keyword
80    /// (workers MCP uses it as an addressing target for the worker's
81    /// own lead - see [`LEAD_LABEL`]).
82    ReservedLabel,
83    /// `label` carries a slash. claude rewrites one before deriving its
84    /// worktree path and branch, so forge's own composition from the raw
85    /// label names a directory and a branch claude never uses (#1603).
86    LabelWithPathSeparator,
87    /// `charter` is empty after trim.
88    EmptyCharter,
89    /// Caller's session key resolves to no known project (defensive;
90    /// the tools closure-bind a real key at spawn time, so this
91    /// should never fire in practice).
92    UnknownCallerProject,
93    /// Spawn was dispatched but the workspace returned an error
94    /// (e.g. tag-write failed). Forwarded as-is to the LLM.
95    DispatchFailed { message: String },
96    /// The worker's git worktree is missing or unusable, so nothing was
97    /// spawned: claude could not create `--worktree <label>` (its reason
98    /// passed through verbatim), or the composed path belongs to another
99    /// project's checkout or worker and was refused before anything
100    /// could touch it.
101    WorktreeCreationFailed { reason: String },
102    /// `resume_session` was set and the label's prior session could not
103    /// be looked up: the transcript directory, or a transcript in it, is
104    /// there and unreadable. Nothing was spawned. A lookup that found
105    /// nothing is not this - that one falls back to a fresh session and
106    /// reports it.
107    ResumeLookupFailed { label: String, message: String },
108}
109
110/// Synchronous error from `update_worker`. Gating (lead-only, non-empty
111/// label, at least one supplied field) happens in the tool before the
112/// call, so these are the outcomes only the store can decide.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum WorkerUpdateError {
115    /// Caller's session key resolves to no known project (defensive).
116    UnknownCallerProject,
117    /// No persisted dynamic-worker row for `label` in the caller's
118    /// project. Update never creates one.
119    NoSuchWorker { label: String, project_key: String },
120    /// The store read or write failed, or the store isn't open.
121    StoreFailed { message: String },
122}
123
124/// Synchronous outcome of `despawn_worker` - the success-shaped half
125/// of a `Command::DespawnWorker`. Gating + dispatch errors are the
126/// `Err` arm of the result; these two variants are what the
127/// `agents__despawn` Tool renders as `status: "despawned" | "blocked"`.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum DespawnOutcome {
130    /// Worker torn down. `worktree_cleanup_warning` is `Some` when the
131    /// post-teardown `git worktree remove` failed (the worker is still
132    /// gone; only the worktree directory lingers).
133    /// `branch_cleanup_warning` is `Some` when the `worktree-<label>`
134    /// branch was left in place because it holds commits reachable from
135    /// no other ref, or the check failed.
136    Despawned { worktree_cleanup_warning: Option<String>, branch_cleanup_warning: Option<String> },
137    /// Despawn refused: the worktree is dirty (uncommitted/untracked or
138    /// unpushed) and `force` was not set. `reason` names what is dirty;
139    /// the worker stays live.
140    Blocked { reason: String },
141}
142
143/// Synchronous error from `despawn_worker`. Gating (lead-only,
144/// non-empty label) happens before dispatch; `UnknownLabel` is
145/// surfaced from the handler's `NotFound`, and `DispatchFailed` from
146/// its `Failed`.
147#[derive(Debug, PartialEq, Eq)]
148pub enum WorkerDespawnError {
149    /// Caller is a worker, not a project lead. Despawn is lead-only.
150    NotLeadCaller,
151    /// `label` is empty after trim.
152    EmptyLabel,
153    /// Caller's session key resolves to no known project (defensive).
154    UnknownCallerProject,
155    /// No live worker matched `label` in the caller's project.
156    UnknownLabel { label: String, project_key: String },
157    /// The despawn did not happen: the workspace channel failed, the
158    /// handler dropped the reply, or the store could not be read or
159    /// written, so whether a worker's row exists is unknown. Distinct
160    /// from [`Self::UnknownLabel`], which claims there is none.
161    DispatchFailed { message: String },
162}
163
164/// Heuristic mapping from a raw spawn-error message + the worker's
165/// `is_git_repo_at_spawn` flag to a typed [`WorkerSpawnError`].
166///
167/// Routing rules:
168///
169/// - The message mentions "worktree" verbatim, OR matches a known
170///   worktree-creation failure pattern ("failed to resolve base branch",
171///   "already used by worktree") AND the worker was actually spawned
172///   in a git repo (so `--worktree=<label>` was on the argv) →
173///   [`WorkerSpawnError::WorktreeCreationFailed`]. The original message
174///   is forwarded verbatim as `reason`.
175/// - Everything else → [`WorkerSpawnError::DispatchFailed`].
176///
177/// The `is_git_repo_at_spawn` guard exists because a worker spawned in
178/// a non-git-repo project (no `--worktree` on the argv) could not have
179/// produced a worktree-creation failure. A "worktree" word arriving for
180/// such a worker is suspicious - some other layer of the spawn pipe -
181/// and falls through to [`WorkerSpawnError::DispatchFailed`] so the
182/// LLM sees the raw message rather than a misclassified variant.
183///
184/// Matching is case-insensitive. The reason string passed back keeps
185/// the original casing for verbatim LLM display.
186///
187/// ## Bridge-prefix contract (#245 Layer C blocker 2)
188///
189/// Async spawn failures arrive here pre-wrapped by the bridge in
190/// `forge_agent::forge_sdk_bridge`:
191///
192/// - `"forge-sdk session spawn failed: {err}"`
193/// - `"forge-sdk session resume failed: {err}"`
194/// - `"forge-sdk session spawn failed after resume fallback (resume err: {a}; new err: {b})"`
195///
196/// The wrapper prefixes themselves carry no discriminator, but the
197/// wrapped error can: `Error::CwdNotFound` renders the path it could not
198/// enter, and for a worker that path runs through
199/// `.claude/worktrees/<label>`. That word is the worktree heuristic's own
200/// key, so a message-only classifier reads a resume with nowhere to start
201/// as a worktree that could not be CREATED, deletes the worker's row, and
202/// tells the lead something untrue. `kind` carries the SDK's own variant
203/// for exactly that case; the substring search below is what answers for
204/// everything the bridge could not name.
205pub fn classify_worker_spawn_failure(
206    message: &str,
207    is_git_repo_at_spawn: bool,
208    kind: SpawnFailureKind,
209) -> WorkerSpawnError {
210    // A named variant is the truth and the message is only how it
211    // renders; searching the render cannot overrule it.
212    if !matches!(kind, SpawnFailureKind::Unclassified) {
213        return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
214    }
215    // The cap refusal embeds the project key, which derives from a
216    // directory path, so it can carry "worktree" without being a
217    // worktree failure.
218    if message.contains("worker limit reached") {
219        return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
220    }
221    // The cleanup refusal is forge's own sentence, so it is recognised by
222    // its own closing phrase - the part no label interpolated into the
223    // sentence can split - and whatever that label happens to spell cannot
224    // turn it into a worktree-creation failure.
225    if message.contains("is still cleaning up; retry once it finishes") {
226        return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
227    }
228    // The known-project refusal is forge's own sentence and carries a
229    // project's name - whatever that project is called, "worktree"
230    // included - so it is recognised by the phrase no name can split.
231    if message.contains("which forge.toml declares") {
232        return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
233    }
234    let lower = message.to_lowercase();
235    let mentions_worktree = lower.contains("worktree");
236    let resembles_branch_resolve = lower.contains("failed to resolve base branch");
237    let resembles_already_exists = lower.contains("already used by worktree");
238    if (mentions_worktree || resembles_branch_resolve || resembles_already_exists)
239        && is_git_repo_at_spawn
240    {
241        return WorkerSpawnError::WorktreeCreationFailed { reason: message.to_owned() };
242    }
243    WorkerSpawnError::DispatchFailed { message: message.to_owned() }
244}
245
246/// Caller's project + lead-or-not flag. Returned by
247/// [`WorkerFacade::caller_project`].
248#[derive(Debug, Clone)]
249pub struct CallerProject {
250    pub project_key: crate::ProjectKey,
251    pub is_lead: bool,
252}
253
254/// Whether a project's worker cap came from its
255/// `[[orgs.projects]]` `max_workers` override or the built-in default.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum WorkerCapSource {
258    ProjectMaxWorkers,
259    Default,
260}
261
262/// Point-in-time worker-capacity read for the caller's project: the
263/// cap the spawn path enforces, how many workers are live under it,
264/// and where the cap came from. Produced by
265/// [`WorkerFacade::capacity`].
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct WorkerCapacity {
268    pub project: String,
269    pub cap: usize,
270    pub live: usize,
271    pub cap_source: WorkerCapSource,
272}
273
274/// The narrow workspace-state surface workers MCP Tool impls
275/// depend on.
276///
277/// `spawn_worker` is async because the production impl awaits a
278/// oneshot receiver wired into `Command::SpawnWorker`. The remaining
279/// methods are synchronous (one mutex acquire over in-memory state).
280#[async_trait::async_trait]
281pub trait WorkerFacade: Send + Sync {
282    /// Resolve the caller's project key + lead/worker flag.
283    /// Returns `None` when `caller` matches no known session.
284    fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject>;
285
286    /// Dispatch a `Command::SpawnWorker` and await its synchronous
287    /// reply. Gating (lead-only, non-empty label, non-empty charter)
288    /// happens before dispatch. `resume_kick` is persisted rather than
289    /// delivered now; it replaces the generic restart note when this
290    /// worker resumes after a forge restart with a turn that was cut off.
291    /// A worker that was idle when forge stopped resumes with nothing
292    /// delivered. `interactive` keeps the built-in
293    /// `AskUserQuestion` tool, which every other worker is denied.
294    /// `resume_session` resumes the label's most recent prior session
295    /// instead of starting fresh. A label with none resolves to
296    /// `ResumeTarget::None`, which starts a fresh session and says so in
297    /// the reply's [`SessionChoice`]; only a lookup that failed is an
298    /// error.
299    ///
300    /// `cwd` names the tree the worker should run in when it is not the
301    /// project's own - an absolute or `~`-prefixed path, resolved to its
302    /// repo root. `None` keeps the project's path for a fresh spawn, and
303    /// adopts the row's recorded tree for a resume that states none.
304    async fn spawn_worker(
305        &self,
306        caller: &SessionSlot,
307        label: String,
308        charter: String,
309        kick: Option<String>,
310        resume_kick: Option<String>,
311        interactive: bool,
312        resume_session: bool,
313        mcp_families: Option<Vec<String>>,
314        cwd: Option<String>,
315    ) -> Result<WorkerSpawnReply, WorkerSpawnError>;
316
317    /// Merge the supplied fields onto the stored dynamic-worker row for
318    /// `label` in the caller's project, leaving a `None` field at its
319    /// stored value. Errors with `NoSuchWorker` when no row exists:
320    /// update revises an existing worker, it never creates one. Takes
321    /// effect on that worker's next respawn, since a session's system
322    /// prompt and tool list are fixed when the session spawns.
323    /// `mcp_families` follows the same contract: `None` leaves the
324    /// stored selection alone, `Some(names)` replaces it - canonicalised
325    /// by the tool - and `Some(empty)` resets it to every family.
326    fn update_worker(
327        &self,
328        caller: &SessionSlot,
329        label: &str,
330        charter: Option<String>,
331        kick: Option<String>,
332        resume_kick: Option<String>,
333        mcp_families: Option<Vec<String>>,
334    ) -> Result<(), WorkerUpdateError>;
335
336    /// Dispatch a `Command::DespawnWorker` and await its result.
337    /// Gating (lead-only, non-empty label) happens before dispatch.
338    /// `force` removes a dirty worktree; without it a dirty worktree
339    /// blocks the despawn (`DespawnOutcome::Blocked`).
340    async fn despawn_worker(
341        &self,
342        caller: &SessionSlot,
343        label: &str,
344        force: bool,
345    ) -> Result<DespawnOutcome, WorkerDespawnError>;
346
347    /// Snapshot of every worker in the caller's project. Returns an
348    /// empty Vec when the caller resolves to no project.
349    fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus>;
350
351    /// Snapshot of every project's workers: the real workspace walks
352    /// `list_projects` in config order, the mock sorts its project keys.
353    fn list_all_workers(&self) -> Vec<WorkerStatus>;
354
355    /// The caller's project worker capacity: the cap the spawn path
356    /// enforces (`max_workers` in the project's `[[orgs.projects]]`
357    /// entry, else `DEFAULT_MAX_WORKERS_PER_PROJECT`) paired with the
358    /// live count.
359    /// A plain data read - the same one the spawn path's cap check
360    /// performs. Returns `None` when the caller resolves to no
361    /// project.
362    fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity>;
363
364    /// Dispatch a worker-bound wrapped prompt. `Delivered` means the
365    /// prompt was accepted - handed to the target's session queue, held
366    /// for its turn end, or parked for its connect - which is the whole
367    /// claim the sender's ack makes; anything that reached no session is
368    /// an error. `UnknownLabel` when no live worker matches.
369    async fn deliver_worker_prompt(
370        &self,
371        caller: &SessionSlot,
372        target_label: &str,
373        wrapped: WrappedPrompt,
374    ) -> Result<WorkerTargetStatus, WorkerDeliverError>;
375
376    /// Dispatch a worker-bound wrapped prompt to a project named by
377    /// its `(org, project)` pair rather than the caller's own. The
378    /// caller lives in another project, so the target cannot be
379    /// resolved from it. Mirrors `deliver_worker_prompt` otherwise:
380    /// an unknown label is an error and a sleeping target is not
381    /// auto-spawned.
382    async fn deliver_worker_prompt_to_project(
383        &self,
384        caller: &SessionSlot,
385        org: &str,
386        project: &str,
387        target_label: &str,
388        wrapped: WrappedPrompt,
389    ) -> Result<WorkerTargetStatus, WorkerDeliverError>;
390
391    /// Dispatch a wrapped prompt from a worker back to its lead.
392    /// Caller MUST be a worker. The target lead is resolved from the
393    /// caller's `WorkerEntry::spawned_by_session_id`. Same
394    /// wire-shape contract as `deliver_worker_prompt`: `Delivered`
395    /// means the lead's session accepted it, and one the session could
396    /// not take is an error.
397    async fn deliver_prompt_to_lead(
398        &self,
399        caller: &SessionSlot,
400        wrapped: WrappedPrompt,
401    ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError>;
402}
403
404/// The refusal for a label that cannot take a delivery: `None` when a live
405/// worker matches, else the error naming which absence this is - a label
406/// that is not in the project's pool at all, or a row kept because the
407/// worker crashed. The two read very differently to the sender: one says
408/// "check the label", the other "respawn it".
409fn refused_delivery(
410    workers: &[crate::mcp::workers::types::WorkerEntry],
411    project_key: &str,
412    label: &str,
413) -> Option<WorkerDeliverError> {
414    if crate::mcp::workers::types::live_worker_with_label(workers, label).is_some() {
415        return None;
416    }
417    if workers.iter().any(|w| w.label == label) {
418        return Some(WorkerDeliverError::NotLive {
419            project_key: project_key.to_owned(),
420            label: label.to_owned(),
421        });
422    }
423    Some(WorkerDeliverError::UnknownLabel {
424        project_key: project_key.to_owned(),
425        label: label.to_owned(),
426    })
427}
428
429/// Await a delivery's acceptance answer, mapping it to the caller's
430/// ack. The handler answers `Ok(())` once the prompt was accepted and
431/// `Err(reason)` when nothing could take it, so this is the one place
432/// the ack is decided.
433async fn await_delivery(
434    rx: tokio::sync::oneshot::Receiver<Result<(), String>>,
435) -> Result<WorkerTargetStatus, WorkerDeliverError> {
436    match rx.await {
437        Ok(Ok(())) => Ok(WorkerTargetStatus::Delivered),
438        Ok(Err(message)) => Err(WorkerDeliverError::DispatchFailed { message }),
439        Err(_) => Err(WorkerDeliverError::DispatchFailed {
440            message: "the delivery handler dropped its reply".to_owned(),
441        }),
442    }
443}
444
445impl WorkerSpawnError {
446    /// The sentence a caller reads. Shared with the spawn handler, which the
447    /// command bus and the socket reach without the tool's own validation,
448    /// so both seams refuse a label with the same words.
449    pub(crate) fn message(&self) -> String {
450        match self {
451            Self::NotLeadCaller => {
452                "agents__spawn is lead-only; this session is a worker. Workers cannot \
453                 spawn other workers in v1."
454                    .to_owned()
455            }
456            Self::EmptyLabel => "label must be non-empty after trim".to_owned(),
457            Self::ReservedLabel => format!(
458                "label '{LEAD_LABEL}' is reserved - agents__send_message uses it as \
459                 the addressing keyword for the caller's project's own agent. Pick a different label."
460            ),
461            Self::LabelWithPathSeparator => {
462                "label must not contain a slash ('/'): claude rewrites it in the worktree name, \
463                 so forge's path and branch would point somewhere it never creates. Pick a label \
464                 without one."
465                    .to_owned()
466            }
467            Self::EmptyCharter => "charter must be non-empty after trim".to_owned(),
468            Self::UnknownCallerProject => {
469                "could not resolve caller to a known project (forge bug)".to_owned()
470            }
471            Self::DispatchFailed { message } => format!("worker spawn failed: {message}"),
472            Self::WorktreeCreationFailed { reason } => {
473                format!("worktree creation failed: {reason}")
474            }
475            Self::ResumeLookupFailed { label, message } => format!(
476                "could not look up a prior session for '{label}': {message}. Nothing was spawned, \
477                 because a failed lookup is not the same answer as no prior session; retry, or \
478                 spawn without resume_session to start fresh"
479            ),
480        }
481    }
482}
483
484/// The label rules a spawn must pass wherever it enters: empty, the
485/// reserved `lead`, and a slash. Shared by the tool's validation and the
486/// spawn handler so the MCP tool, the command bus and the socket cannot
487/// disagree about which labels mint a worker.
488pub(crate) fn validate_worker_label(label: &str) -> Result<(), WorkerSpawnError> {
489    if label.trim().is_empty() {
490        return Err(WorkerSpawnError::EmptyLabel);
491    }
492    if label.trim() == LEAD_LABEL {
493        return Err(WorkerSpawnError::ReservedLabel);
494    }
495    if label.contains('/') {
496        return Err(WorkerSpawnError::LabelWithPathSeparator);
497    }
498    Ok(())
499}
500
501/// Validation chain shared by the production and mock `spawn_worker`
502/// impls so tests exercise the real rules rather than a hand-copied
503/// duplicate. `is_lead` is the resolved caller role.
504pub(super) fn validate_worker_spawn(
505    is_lead: bool,
506    label: &str,
507    charter: &str,
508) -> Result<(), WorkerSpawnError> {
509    if !is_lead {
510        return Err(WorkerSpawnError::NotLeadCaller);
511    }
512    validate_worker_label(label)?;
513    if charter.trim().is_empty() {
514        return Err(WorkerSpawnError::EmptyCharter);
515    }
516    Ok(())
517}
518
519/// Undo the worktree a refused spawn's `ensure_worker_worktree` step
520/// created, so a refusal strands neither worktree nor branch. `ensured`
521/// is `None` when this spawn never touched a worktree.
522///
523/// A concurrent spawn anywhere in the repository that won this tree holds
524/// it now - a claim, or a live seat, in ANY project - so the rollback
525/// leaves it alone.
526fn discard_refused_worktree(
527    ws: &Workspace,
528    project_key: &crate::target::ProjectKey,
529    repo: &std::path::Path,
530    label: &str,
531    ensured: Option<(std::path::PathBuf, forge_agent::env::worktree::WorktreeEnsure)>,
532) {
533    let Some((worktree, outcome)) = ensured else { return };
534    if let Some(holder) = ws.worktree_held_elsewhere(&worktree, project_key, label) {
535        tracing::debug!(
536            target: "forge_workspace::mcp::workers",
537            %label,
538            worktree = %worktree.display(),
539            %holder,
540            "a refused spawn's worktree is held by another seat; the rollback leaves it",
541        );
542        return;
543    }
544    if let Some(warning) =
545        forge_agent::env::worktree::discard_worker_worktree(repo, label, &worktree, outcome)
546    {
547        tracing::warn!(
548            target: "forge_workspace::mcp::workers",
549            %label,
550            worktree = %worktree.display(),
551            %warning,
552            "a refused spawn's fresh worktree stayed behind"
553        );
554    }
555}
556
557/// Production impl. Holds a `Weak<Workspace>` so construction doesn't
558/// close a strong cycle through the Workspace -> bridge -> MCP ->
559/// Tool -> facade -> Workspace path. Every method starts with
560/// `upgrade()` and short-circuits when the workspace has been
561/// dropped (only possible during shutdown).
562pub struct ProdWorkerFacade {
563    workspace: Weak<Workspace>,
564}
565
566impl ProdWorkerFacade {
567    pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn WorkerFacade> {
568        Arc::new(Self { workspace: Arc::downgrade(workspace) })
569    }
570}
571
572#[async_trait::async_trait]
573impl WorkerFacade for ProdWorkerFacade {
574    fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject> {
575        let ws = self.workspace.upgrade()?;
576        let cx = crate::mcp::caller_context::caller_context(&ws, caller)?;
577        Some(CallerProject { project_key: cx.project_key, is_lead: cx.is_lead })
578    }
579
580    async fn spawn_worker(
581        &self,
582        caller: &SessionSlot,
583        label: String,
584        charter: String,
585        kick: Option<String>,
586        resume_kick: Option<String>,
587        interactive: bool,
588        resume_session: bool,
589        mcp_families: Option<Vec<String>>,
590        cwd: Option<String>,
591    ) -> Result<WorkerSpawnReply, WorkerSpawnError> {
592        let cp = self.caller_project(caller).ok_or(WorkerSpawnError::UnknownCallerProject)?;
593        validate_worker_spawn(cp.is_lead, &label, &charter)?;
594        let ws = self.workspace.upgrade().ok_or_else(|| WorkerSpawnError::DispatchFailed {
595            message: "workspace dropped".into(),
596        })?;
597        // A label whose despawn cleanup is still running is refused here,
598        // before anything mints a worktree: that cleanup is deleting the
599        // directory a resume would re-create, and re-attaching its branch
600        // would leave the despawn reporting a branch it could not reap - a
601        // failure this spawn made. The spawn handler reads the same mark;
602        // this half is what keeps a refused spawn from minting anything.
603        if ws.despawn_cleanup_pending(&cp.project_key, &label) {
604            return Err(WorkerSpawnError::DispatchFailed {
605                message:
606                    "this label's previous despawn is still cleaning up; retry once it finishes"
607                        .to_owned(),
608            });
609        }
610        // The project view's path answers the is_git_repo probe the
611        // failure classifier reads (the WorkerEntry is gone by the time
612        // we see a spawn error).
613        let Some(view) = ws.list_projects().into_iter().find(|v| v.key == cp.project_key) else {
614            return Err(WorkerSpawnError::UnknownCallerProject);
615        };
616        // D7's fallback: a resume that states no tree resumes the one the
617        // row records, so the session lands back where its JSONL lives.
618        // A fresh spawn stating none keeps the project's own path (D9).
619        // The row is read once here: its tree is both that fallback and
620        // what a stated tree is judged against below.
621        let row = if resume_session {
622            ws.stored_worker_row(&cp.project_key, &label).unwrap_or_else(|error| {
623                tracing::warn!(
624                    target: "forge_workspace::mcp::workers",
625                    event_name = "worker_row_cwd_unreadable",
626                    project = %cp.project_key.as_str(),
627                    label = %label,
628                    %error,
629                    "reading the worker's recorded tree failed; resuming in the project \
630                     path, which may be a different directory than the row names",
631                );
632                None
633            })
634        } else {
635            None
636        };
637        let stated = cwd
638            .as_deref()
639            .map(str::to_owned)
640            .or_else(|| row.as_ref().and_then(|row| row.cwd.clone()));
641        // The tree this spawn runs the worker in: the stated path resolved
642        // to its repo root, else the project's own path. Resolving here is
643        // what makes the ensure, the run directory and the record agree -
644        // they all compose from `root` below.
645        let (root, stated_is_git) =
646            crate::mcp::workers::types::worker_root_and_git(&view.path, stated.as_deref())
647                .map_err(|message| WorkerSpawnError::DispatchFailed { message })?;
648        // D3 again, before anything is ensured: the handler refuses this
649        // as the shared core, but it runs after this half has already
650        // recreated a worktree - and a refused spawn must not write into
651        // a declared project's tree even transiently.
652        if stated.is_some()
653            && let Some(known) = ws.project_name_for_resolved_path(&root.to_string_lossy())
654        {
655            return Err(WorkerSpawnError::DispatchFailed {
656                message: crate::spawn::known_project_refusal(&known, &view.name),
657            });
658        }
659        // A resume that states a DIFFERENT tree than the row's cannot
660        // carry the history: claude finds a conversation by the project
661        // key derived from the directory it runs in, so a moved resume
662        // dies "No conversation found". The worker's history is keyed to
663        // its tree, so moving means despawn and a fresh spawn.
664        if resume_session && let (Some(_), Some(row)) = (cwd.as_deref(), row.as_ref()) {
665            let history =
666                row.cwd.as_deref().map_or_else(|| view.path.clone(), std::path::PathBuf::from);
667            let history = std::fs::canonicalize(&history).unwrap_or(history);
668            if root != history {
669                return Err(WorkerSpawnError::DispatchFailed {
670                    message: format!(
671                        "this resume states a different tree ('{}') than the one this \
672                         worker's history lives in ('{}'); a conversation is found by the \
673                         directory it ran in, so a worker moves trees only by being \
674                         despawned and spawned fresh",
675                        root.display(),
676                        history.display(),
677                    ),
678                });
679            }
680        }
681        // A row that already exists answers for gitness, exactly as it
682        // does in `handle_spawn_worker`, so the worktree ensured below is
683        // the one the spawn will actually run in. Probing here instead
684        // would diverge on a project whose repo-ness changed since the
685        // worker was spawned: the ensure would create a worktree nothing
686        // uses and nothing cleans up, or skip one the resume needs. A
687        // stated tree answers for itself: the row's gitness belongs to the
688        // directory the row names.
689        let is_git_repo_at_spawn = if let Some(stated_is_git) = stated_is_git {
690            stated_is_git
691        } else {
692            ws.recorded_worker_is_git_repo(&cp.project_key, &label)
693                .unwrap_or_else(|error| {
694                    tracing::warn!(
695                        target: "forge_workspace::mcp::workers",
696                        event_name = "worker_row_gitness_unreadable",
697                        project = %cp.project_key.as_str(),
698                        label = %label,
699                        %error,
700                        "reading the worker's recorded gitness failed; probing the project path, \
701                         which may ensure a different worktree than the row names",
702                    );
703                    None
704                })
705                .unwrap_or_else(|| forge_agent::env::worktree::is_git_repo(&view.path))
706        };
707        // The one-live-worker-per-label guard lives in the shared
708        // `handle_spawn_worker` core, so a boot re-spawn is deduped
709        // against this one; a duplicate MCP spawn surfaces from there as
710        // a dispatch error.
711        //
712        // The worktree is recreated before the spawn: a despawn removes
713        // it, and `--worktree <label>` needs it to exist so the resumed
714        // session lands back in the directory it first ran in. What
715        // ensure did is kept so a refusal can undo exactly that work -
716        // and, on the fallback below, so the fresh session inherits it.
717        let mut ensured = None;
718        // The checkout the worker's worktree belongs to: the CLI's
719        // canonical root, which is the main checkout when the project is
720        // itself a linked worktree (#2460). Every worktree operation here
721        // - the ensure, the leftover clear under it, and a refusal's
722        // discard - aims at it, not at the project path.
723        let base = forge_agent::env::worktree::worktree_base(&root);
724        // Held until this function returns, after the core has inserted
725        // the worker or refused it.
726        let mut _worktree_claim: Option<crate::workspace::WorktreeClaimGuard> = None;
727        let (resume_existing, session_choice) = if resume_session {
728            // The directory the label's sessions ran in: its worktree, or
729            // the project root for a worker spawned without one. The
730            // ensure, the tag write and the lookup below all take it from
731            // `worker_tag_dir`, so they cannot disagree about where a
732            // worker lives.
733            let run_dir =
734                crate::mcp::workers::types::worker_tag_dir(&root, &label, is_git_repo_at_spawn);
735            if is_git_repo_at_spawn {
736                // Claim the path before the ensure or the transcript
737                // lookup can touch it, and hold it through the dispatch:
738                // the claim is the atomic half of the collision check, so
739                // a concurrent spawn in another project cannot pass
740                // between the check and this spawn's insert.
741                let claim = ws
742                    .claim_worktree(
743                        &run_dir,
744                        &cp.project_key,
745                        &label,
746                        crate::workspace::WorktreeOp::Spawn,
747                    )
748                    .map_err(|reason| WorkerSpawnError::WorktreeCreationFailed {
749                        reason: format!("this worker's worktree cannot be shared: {reason}"),
750                    })?;
751                // Refuse before the ensure or the transcript lookup can
752                // touch a tree that belongs to another project (#2460):
753                // the base is repository-global, so the label alone does
754                // not identify this project's worktree. The ensure is not
755                // read-only - it clears a deregistered leftover and
756                // rewrites a stale link - and the lookup would read that
757                // project's conversation.
758                if let Some(collision) = ws.worktree_collision(
759                    &run_dir,
760                    &cp.project_key,
761                    &label,
762                    crate::workspace::WorktreeOp::Spawn,
763                ) {
764                    return Err(WorkerSpawnError::WorktreeCreationFailed {
765                        reason: format!("this worker's worktree cannot be shared: {collision}"),
766                    });
767                }
768                // Released when this function returns, after the core has
769                // inserted the worker or refused it.
770                _worktree_claim = Some(claim);
771                match forge_agent::env::worktree::ensure_worker_worktree(&base, &label, &run_dir) {
772                    Ok(outcome) => ensured = Some((run_dir.clone(), outcome)),
773                    Err(err) => {
774                        return Err(WorkerSpawnError::WorktreeCreationFailed {
775                            reason: err.to_string(),
776                        });
777                    }
778                }
779            }
780            match ws.resolve_worker_resume_session(&view.org, &view.name, &run_dir, &label) {
781                Ok(ResumeTarget::Found { session_id, restamp }) => {
782                    if restamp {
783                        // A pre-upgrade tag row carries no project; stamp
784                        // it now, before the spawn, so the migration
785                        // window is this one adoption and not every
786                        // project's to reuse.
787                        if let Err(error) = ws.stamp_adopted_worker_transcript(
788                            &cp.project_key,
789                            &label,
790                            &session_id,
791                            &run_dir,
792                        ) {
793                            // Like every other failure after the ensure:
794                            // undo the worktree it minted.
795                            discard_refused_worktree(
796                                &ws,
797                                &cp.project_key,
798                                &base,
799                                &label,
800                                ensured.take(),
801                            );
802                            return Err(WorkerSpawnError::DispatchFailed {
803                                message: format!(
804                                    "stamping the adopted transcript's project failed: {error}"
805                                ),
806                            });
807                        }
808                    }
809                    (Some(session_id), SessionChoice::Resumed)
810                }
811                // Best effort: a label with nothing to resume starts a
812                // new session and the reply says so. The worktree the
813                // ensure minted stays - it is where the new session runs.
814                Ok(ResumeTarget::None) => (None, SessionChoice::FreshWithoutPrior),
815                // A lookup that failed is not an absence. Starting fresh
816                // on it would answer a question the lookup never
817                // answered, leaving the caller holding context it does
818                // not have.
819                Err(error) => {
820                    discard_refused_worktree(&ws, &cp.project_key, &base, &label, ensured.take());
821                    return Err(WorkerSpawnError::ResumeLookupFailed {
822                        label,
823                        message: error.to_string(),
824                    });
825                }
826            }
827        } else {
828            (None, SessionChoice::Fresh)
829        };
830
831        // The worker's row is written by the spawn handler itself, from
832        // the same arguments, so there is nothing to persist here - and
833        // one write path covers the boot re-spawn too.
834        let spawn_label = label.clone();
835        let (tx, rx) = tokio::sync::oneshot::channel();
836        let cmd = Command::SpawnWorker {
837            project_key: cp.project_key.clone(),
838            label,
839            charter,
840            spawned_by: caller.clone(),
841            // Fresh unless the caller asked to resume; the resume
842            // resolution above fills in the real session id.
843            resume_existing,
844            kick,
845            resume_kick,
846            interactive,
847            mcp_families,
848            // The resolved root, which the handler records on the row: a
849            // spawn that stated no tree records none, so the row keeps
850            // meaning the project's own path.
851            cwd: stated.as_ref().map(|_| root.to_string_lossy().into_owned()),
852            from_boot_respawn: false,
853            return_to: Some(tx),
854        };
855        if let Err(err) = ws.dispatch(cmd) {
856            discard_refused_worktree(&ws, &cp.project_key, &base, &spawn_label, ensured.take());
857            return Err(WorkerSpawnError::DispatchFailed {
858                message: format!("dispatch failed: {err:?}"),
859            });
860        }
861        match rx.await {
862            Ok(Ok(mut reply)) => {
863                reply.session_choice = session_choice;
864                if let Some(warning) = reply.durability_warning.take() {
865                    tracing::error!(
866                        target: "forge_workspace::mcp::workers",
867                        project = %cp.project_key.as_str(),
868                        label = %spawn_label,
869                        warning = %warning,
870                        "the worker's row could not be recorded; it will not survive a forge restart",
871                    );
872                    reply.durability_warning = Some(warning);
873                }
874                Ok(reply)
875            }
876            Ok(Err(message)) => {
877                discard_refused_worktree(&ws, &cp.project_key, &base, &spawn_label, ensured.take());
878                // The sync path's reply is a string the spawn handler
879                // built, not an SDK error, so there is no variant to
880                // name and the heuristics answer.
881                Err(classify_worker_spawn_failure(
882                    &message,
883                    is_git_repo_at_spawn,
884                    SpawnFailureKind::Unclassified,
885                ))
886            }
887            // No rollback here: a dropped reply means the command may
888            // still run, and the spawn it would do needs the worktree.
889            Err(_) => Err(WorkerSpawnError::DispatchFailed {
890                message: "spawn handler dropped reply channel".into(),
891            }),
892        }
893    }
894
895    fn update_worker(
896        &self,
897        caller: &SessionSlot,
898        label: &str,
899        charter: Option<String>,
900        kick: Option<String>,
901        resume_kick: Option<String>,
902        mcp_families: Option<Vec<String>>,
903    ) -> Result<(), WorkerUpdateError> {
904        let cp = self.caller_project(caller).ok_or(WorkerUpdateError::UnknownCallerProject)?;
905        let ws = self.workspace.upgrade().ok_or_else(|| WorkerUpdateError::StoreFailed {
906            message: "workspace dropped".into(),
907        })?;
908        match ws.update_worker_row(&cp.project_key, label, charter, kick, resume_kick, mcp_families)
909        {
910            Ok(true) => Ok(()),
911            Ok(false) => Err(WorkerUpdateError::NoSuchWorker {
912                label: label.to_owned(),
913                project_key: cp.project_key.as_str().to_owned(),
914            }),
915            Err(err) => Err(WorkerUpdateError::StoreFailed { message: err.to_string() }),
916        }
917    }
918
919    async fn despawn_worker(
920        &self,
921        caller: &SessionSlot,
922        label: &str,
923        force: bool,
924    ) -> Result<DespawnOutcome, WorkerDespawnError> {
925        let cp = self.caller_project(caller).ok_or(WorkerDespawnError::UnknownCallerProject)?;
926        if !cp.is_lead {
927            return Err(WorkerDespawnError::NotLeadCaller);
928        }
929        if label.trim().is_empty() {
930            return Err(WorkerDespawnError::EmptyLabel);
931        }
932        let ws = self.workspace.upgrade().ok_or_else(|| WorkerDespawnError::DispatchFailed {
933            message: "workspace dropped".into(),
934        })?;
935        let (tx, rx) = tokio::sync::oneshot::channel();
936        let cmd = Command::DespawnWorker {
937            project_key: cp.project_key.clone(),
938            label: label.to_owned(),
939            force,
940            respond: Some(tx),
941        };
942        if let Err(err) = ws.dispatch(cmd) {
943            return Err(WorkerDespawnError::DispatchFailed {
944                message: format!("dispatch failed: {err:?}"),
945            });
946        }
947        match rx.await {
948            Ok(crate::protocol::DespawnResult::Despawned {
949                worktree_cleanup_warning,
950                branch_cleanup_warning,
951            }) => {
952                Ok(DespawnOutcome::Despawned { worktree_cleanup_warning, branch_cleanup_warning })
953            }
954            Ok(crate::protocol::DespawnResult::Blocked { reason }) => {
955                Ok(DespawnOutcome::Blocked { reason })
956            }
957            Ok(crate::protocol::DespawnResult::NotFound) => Err(WorkerDespawnError::UnknownLabel {
958                label: label.to_owned(),
959                project_key: cp.project_key.as_str().to_owned(),
960            }),
961            Ok(crate::protocol::DespawnResult::Failed { reason }) => {
962                Err(WorkerDespawnError::DispatchFailed { message: reason })
963            }
964            Err(_) => Err(WorkerDespawnError::DispatchFailed {
965                message: "despawn handler dropped reply channel".into(),
966            }),
967        }
968    }
969
970    fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus> {
971        let Some(cp) = self.caller_project(caller) else {
972            return Vec::new();
973        };
974        let Some(ws) = self.workspace.upgrade() else {
975            return Vec::new();
976        };
977        ws.list_live_workers(&cp.project_key)
978            .iter()
979            .map(|entry| ws.worker_status_snapshot(entry))
980            .collect()
981    }
982
983    fn list_all_workers(&self) -> Vec<WorkerStatus> {
984        let Some(ws) = self.workspace.upgrade() else {
985            return Vec::new();
986        };
987        let mut all = Vec::new();
988        for project in ws.list_projects() {
989            for entry in ws.list_live_workers(&project.key) {
990                all.push(ws.worker_status_snapshot(&entry));
991            }
992        }
993        all
994    }
995
996    fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity> {
997        let cp = self.caller_project(caller)?;
998        let ws = self.workspace.upgrade()?;
999        let (cap, cap_source) = ws
1000            .project_for_key(&cp.project_key)
1001            .and_then(|project| {
1002                project.max_workers.map(|cap| (cap, WorkerCapSource::ProjectMaxWorkers))
1003            })
1004            .unwrap_or((crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT, WorkerCapSource::Default));
1005        Some(WorkerCapacity {
1006            project: cp.project_key.as_str().to_owned(),
1007            cap,
1008            live: ws.count_live_workers(&cp.project_key),
1009            cap_source,
1010        })
1011    }
1012
1013    async fn deliver_worker_prompt(
1014        &self,
1015        caller: &SessionSlot,
1016        target_label: &str,
1017        wrapped: WrappedPrompt,
1018    ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1019        let cp = self.caller_project(caller).ok_or_else(|| WorkerDeliverError::UnknownLabel {
1020            project_key: "<unknown>".into(),
1021            label: target_label.into(),
1022        })?;
1023        let Some(ws) = self.workspace.upgrade() else {
1024            return Err(WorkerDeliverError::UnknownLabel {
1025                project_key: cp.project_key.as_str().into(),
1026                label: target_label.into(),
1027            });
1028        };
1029        // A crashed worker keeps its row (Failed) so the label stays
1030        // respawnable; that row is not addressable, and the refusal says so
1031        // rather than acking a prompt no session can take.
1032        if let Some(refusal) = refused_delivery(
1033            &ws.list_live_workers(&cp.project_key),
1034            cp.project_key.as_str(),
1035            target_label,
1036        ) {
1037            return Err(refusal);
1038        }
1039        let (tx, rx) = tokio::sync::oneshot::channel();
1040        if let Err(err) = ws.dispatch(Command::DeliverWorkerPrompt {
1041            caller: caller.clone(),
1042            project_key: cp.project_key,
1043            target_label: target_label.into(),
1044            wrapped,
1045            respond: Some(tx),
1046        }) {
1047            return Err(WorkerDeliverError::DispatchFailed {
1048                message: format!("dispatch failed: {err:?}"),
1049            });
1050        }
1051        await_delivery(rx).await
1052    }
1053
1054    async fn deliver_worker_prompt_to_project(
1055        &self,
1056        caller: &SessionSlot,
1057        org: &str,
1058        project: &str,
1059        target_label: &str,
1060        wrapped: WrappedPrompt,
1061    ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1062        let unknown = || WorkerDeliverError::UnknownLabel {
1063            project_key: project.into(),
1064            label: target_label.into(),
1065        };
1066        let Some(ws) = self.workspace.upgrade() else {
1067            return Err(unknown());
1068        };
1069        let Some(view) = ws.list_projects().into_iter().find(|v| v.org == org && v.name == project)
1070        else {
1071            return Err(unknown());
1072        };
1073        if let Some(refusal) =
1074            refused_delivery(&ws.list_live_workers(&view.key), project, target_label)
1075        {
1076            return Err(refusal);
1077        }
1078        let (tx, rx) = tokio::sync::oneshot::channel();
1079        if let Err(err) = ws.dispatch(Command::DeliverWorkerPrompt {
1080            caller: caller.clone(),
1081            project_key: view.key,
1082            target_label: target_label.into(),
1083            wrapped,
1084            respond: Some(tx),
1085        }) {
1086            return Err(WorkerDeliverError::DispatchFailed {
1087                message: format!("dispatch failed: {err:?}"),
1088            });
1089        }
1090        await_delivery(rx).await
1091    }
1092
1093    async fn deliver_prompt_to_lead(
1094        &self,
1095        caller: &SessionSlot,
1096        wrapped: WrappedPrompt,
1097    ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError> {
1098        let Some(ws) = self.workspace.upgrade() else {
1099            return Err(WorkerLeadDeliverError::UnknownCaller);
1100        };
1101        // Resolve the caller's project + current lead via the shared
1102        // helper. The previous impl read `entry.spawned_by_session_id`
1103        // (set once at spawn time, never rewritten on lead-resume) -
1104        // tells routed to the dead pre-resume session id and were
1105        // silently dropped. #298 Cause 2.
1106        let Some(cx) = crate::mcp::caller_context::caller_context(&ws, caller) else {
1107            return Err(WorkerLeadDeliverError::UnknownCaller);
1108        };
1109        if cx.is_lead {
1110            return Err(WorkerLeadDeliverError::LeadCallerHasNoLead);
1111        }
1112        let target_lead_key = cx.lead.clone();
1113        // Defensive: confirm the lead's session is still in the pool
1114        // before dispatching. If it closed since the worker was
1115        // spawned, surface a clear error so the worker LLM can adapt.
1116        if !cx.lead_running {
1117            return Err(WorkerLeadDeliverError::LeadGone);
1118        }
1119        let (tx, rx) = tokio::sync::oneshot::channel();
1120        if let Err(err) = ws.dispatch(Command::DeliverWorkerPromptToLead {
1121            caller: caller.clone(),
1122            target_lead_key,
1123            wrapped,
1124            respond: Some(tx),
1125        }) {
1126            return Err(WorkerLeadDeliverError::DispatchFailed {
1127                message: format!("dispatch failed: {err:?}"),
1128            });
1129        }
1130        match rx.await {
1131            Ok(Ok(())) => Ok(WorkerTargetStatus::Delivered),
1132            Ok(Err(message)) => Err(WorkerLeadDeliverError::DispatchFailed { message }),
1133            Err(_) => Err(WorkerLeadDeliverError::DispatchFailed {
1134                message: "the delivery handler dropped its reply".to_owned(),
1135            }),
1136        }
1137    }
1138}
1139
1140/// A captured `MockWorkerFacade::spawn_worker` call:
1141/// `(caller, label, resolved charter, kick, resume_kick, interactive,
1142/// resume_session, mcp_families, cwd)`.
1143#[cfg(any(test, feature = "testing"))]
1144type RecordedSpawnCall = (
1145    SessionSlot,
1146    String,
1147    String,
1148    Option<String>,
1149    Option<String>,
1150    bool,
1151    bool,
1152    Option<Vec<String>>,
1153    Option<String>,
1154);
1155
1156/// A captured `MockWorkerFacade::update_worker` call:
1157/// `(caller, label, charter, kick, resume_kick)`.
1158#[cfg(any(test, feature = "testing"))]
1159type RecordedUpdateCall =
1160    (SessionSlot, String, Option<String>, Option<String>, Option<String>, Option<Vec<String>>);
1161
1162/// One captured `deliver_worker_prompt_to_project` call: the address it
1163/// was given, and the prompt it carried.
1164#[cfg(any(test, feature = "testing"))]
1165#[derive(Debug)]
1166pub struct RecordedProjectDelivery {
1167    pub caller: SessionSlot,
1168    pub org: String,
1169    pub project: String,
1170    pub label: String,
1171    pub wrapped: WrappedPrompt,
1172}
1173
1174/// Mock for unit-testing the four Tool impls. Captures every
1175/// dispatched call into a Vec so tests can assert "tool X
1176/// dispatched spawn with these args" without spinning up a real
1177/// Workspace.
1178#[cfg(any(test, feature = "testing"))]
1179#[derive(Default)]
1180pub struct MockWorkerFacade {
1181    /// Pre-loaded caller -> project mapping. Tests insert entries
1182    /// before invoking the tool.
1183    pub callers: parking_lot::Mutex<std::collections::HashMap<SessionSlot, CallerProject>>,
1184    /// Pre-loaded `live_workers` snapshot per project_key string.
1185    /// `list_workers` and `deliver_worker_prompt` both read from
1186    /// this.
1187    pub workers: parking_lot::Mutex<std::collections::HashMap<String, Vec<WorkerStatus>>>,
1188    /// Captured `spawn_worker` calls.
1189    pub spawn_calls: parking_lot::Mutex<Vec<RecordedSpawnCall>>,
1190    /// Pre-loaded reply for `spawn_worker`. When `None`, the mock
1191    /// returns `DispatchFailed { message: "no preloaded reply" }`.
1192    pub spawn_reply: parking_lot::Mutex<Option<Result<WorkerSpawnReply, WorkerSpawnError>>>,
1193    /// Captured `update_worker` calls.
1194    pub update_calls: parking_lot::Mutex<Vec<RecordedUpdateCall>>,
1195    /// Pre-loaded result for `update_worker`. When `None`, the mock
1196    /// reports success, so a test that cares only about the args passed
1197    /// through does not have to set it.
1198    pub update_result: parking_lot::Mutex<Option<Result<(), WorkerUpdateError>>>,
1199    /// Captured `deliver_worker_prompt` calls.
1200    pub deliver_calls: parking_lot::Mutex<Vec<(SessionSlot, String, WrappedPrompt)>>,
1201    /// Captured `deliver_worker_prompt_to_project` calls.
1202    pub deliver_to_project_calls: parking_lot::Mutex<Vec<RecordedProjectDelivery>>,
1203    /// Captured `despawn_worker` calls: (caller, label, force).
1204    pub despawn_calls: parking_lot::Mutex<Vec<(SessionSlot, String, bool)>>,
1205    /// Pre-loaded outcome for `despawn_worker` on a known label. When
1206    /// `None`, the mock reports `Despawned` with neither warning set.
1207    pub despawn_outcome: parking_lot::Mutex<Option<DespawnOutcome>>,
1208    /// Pre-loaded reply for `capacity`. When `None`, the mock derives
1209    /// it from the workers map: default cap, live count from the
1210    /// caller's project's entries.
1211    pub capacity_reply: parking_lot::Mutex<Option<WorkerCapacity>>,
1212}
1213
1214#[cfg(any(test, feature = "testing"))]
1215impl MockWorkerFacade {
1216    pub fn new() -> Self {
1217        Self::default()
1218    }
1219
1220    pub fn into_arc(self) -> std::sync::Arc<dyn WorkerFacade> {
1221        std::sync::Arc::new(self)
1222    }
1223}
1224
1225#[cfg(any(test, feature = "testing"))]
1226#[async_trait::async_trait]
1227impl WorkerFacade for MockWorkerFacade {
1228    fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject> {
1229        self.callers.lock().get(caller).cloned()
1230    }
1231
1232    async fn spawn_worker(
1233        &self,
1234        caller: &SessionSlot,
1235        label: String,
1236        charter: String,
1237        kick: Option<String>,
1238        resume_kick: Option<String>,
1239        interactive: bool,
1240        resume_session: bool,
1241        mcp_families: Option<Vec<String>>,
1242        cwd: Option<String>,
1243    ) -> Result<WorkerSpawnReply, WorkerSpawnError> {
1244        let cp = self.caller_project(caller).ok_or(WorkerSpawnError::UnknownCallerProject)?;
1245        validate_worker_spawn(cp.is_lead, &label, &charter)?;
1246        self.spawn_calls.lock().push((
1247            caller.clone(),
1248            label,
1249            charter,
1250            kick,
1251            resume_kick,
1252            interactive,
1253            resume_session,
1254            mcp_families,
1255            cwd,
1256        ));
1257        self.spawn_reply.lock().clone().unwrap_or(Err(WorkerSpawnError::DispatchFailed {
1258            message: "no preloaded reply".into(),
1259        }))
1260    }
1261
1262    fn update_worker(
1263        &self,
1264        caller: &SessionSlot,
1265        label: &str,
1266        charter: Option<String>,
1267        kick: Option<String>,
1268        resume_kick: Option<String>,
1269        mcp_families: Option<Vec<String>>,
1270    ) -> Result<(), WorkerUpdateError> {
1271        self.caller_project(caller).ok_or(WorkerUpdateError::UnknownCallerProject)?;
1272        self.update_calls.lock().push((
1273            caller.clone(),
1274            label.to_owned(),
1275            charter,
1276            kick,
1277            resume_kick,
1278            mcp_families,
1279        ));
1280        self.update_result.lock().clone().unwrap_or(Ok(()))
1281    }
1282
1283    async fn despawn_worker(
1284        &self,
1285        caller: &SessionSlot,
1286        label: &str,
1287        force: bool,
1288    ) -> Result<DespawnOutcome, WorkerDespawnError> {
1289        let cp = self.caller_project(caller).ok_or(WorkerDespawnError::UnknownCallerProject)?;
1290        if !cp.is_lead {
1291            return Err(WorkerDespawnError::NotLeadCaller);
1292        }
1293        if label.trim().is_empty() {
1294            return Err(WorkerDespawnError::EmptyLabel);
1295        }
1296        let known = self
1297            .workers
1298            .lock()
1299            .get(cp.project_key.as_str())
1300            .is_some_and(|ws| ws.iter().any(|w| w.label == label));
1301        if !known {
1302            return Err(WorkerDespawnError::UnknownLabel {
1303                label: label.to_owned(),
1304                project_key: cp.project_key.as_str().to_owned(),
1305            });
1306        }
1307        self.despawn_calls.lock().push((caller.clone(), label.to_owned(), force));
1308        Ok(self.despawn_outcome.lock().clone().unwrap_or(DespawnOutcome::Despawned {
1309            worktree_cleanup_warning: None,
1310            branch_cleanup_warning: None,
1311        }))
1312    }
1313
1314    fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus> {
1315        let Some(cp) = self.caller_project(caller) else {
1316            return Vec::new();
1317        };
1318        self.workers.lock().get(cp.project_key.as_str()).cloned().unwrap_or_default()
1319    }
1320
1321    fn list_all_workers(&self) -> Vec<WorkerStatus> {
1322        let workers = self.workers.lock();
1323        let mut projects: Vec<(&String, &Vec<WorkerStatus>)> = workers.iter().collect();
1324        projects.sort_by(|left, right| left.0.cmp(right.0));
1325        projects.into_iter().flat_map(|(_, list)| list).cloned().collect()
1326    }
1327
1328    fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity> {
1329        let cp = self.caller_project(caller)?;
1330        Some(self.capacity_reply.lock().clone().unwrap_or(WorkerCapacity {
1331            project: cp.project_key.as_str().to_owned(),
1332            cap: crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT,
1333            live: self.workers.lock().get(cp.project_key.as_str()).map_or(0, Vec::len),
1334            cap_source: WorkerCapSource::Default,
1335        }))
1336    }
1337
1338    async fn deliver_worker_prompt(
1339        &self,
1340        caller: &SessionSlot,
1341        target_label: &str,
1342        wrapped: WrappedPrompt,
1343    ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1344        let cp = self.caller_project(caller).ok_or_else(|| WorkerDeliverError::UnknownLabel {
1345            project_key: "<unknown>".into(),
1346            label: target_label.into(),
1347        })?;
1348        let known = self
1349            .workers
1350            .lock()
1351            .get(cp.project_key.as_str())
1352            .is_some_and(|ws| ws.iter().any(|w| w.label == target_label));
1353        if !known {
1354            return Err(WorkerDeliverError::UnknownLabel {
1355                project_key: cp.project_key.as_str().into(),
1356                label: target_label.into(),
1357            });
1358        }
1359        self.deliver_calls.lock().push((caller.clone(), target_label.into(), wrapped));
1360        Ok(WorkerTargetStatus::Delivered)
1361    }
1362
1363    async fn deliver_worker_prompt_to_project(
1364        &self,
1365        caller: &SessionSlot,
1366        org: &str,
1367        project: &str,
1368        target_label: &str,
1369        wrapped: WrappedPrompt,
1370    ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1371        let unknown = || WorkerDeliverError::UnknownLabel {
1372            project_key: project.into(),
1373            label: target_label.into(),
1374        };
1375        let known = self
1376            .workers
1377            .lock()
1378            .get(project)
1379            .is_some_and(|ws| ws.iter().any(|w| w.label == target_label));
1380        if !known {
1381            return Err(unknown());
1382        }
1383        self.deliver_to_project_calls.lock().push(RecordedProjectDelivery {
1384            caller: caller.clone(),
1385            org: org.into(),
1386            project: project.into(),
1387            label: target_label.into(),
1388            wrapped,
1389        });
1390        Ok(WorkerTargetStatus::Delivered)
1391    }
1392
1393    async fn deliver_prompt_to_lead(
1394        &self,
1395        caller: &SessionSlot,
1396        wrapped: WrappedPrompt,
1397    ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError> {
1398        let cp = self.caller_project(caller).ok_or(WorkerLeadDeliverError::UnknownCaller)?;
1399        if cp.is_lead {
1400            return Err(WorkerLeadDeliverError::LeadCallerHasNoLead);
1401        }
1402        // Mock surfaces a synthetic spawned_by lookup via the
1403        // preloaded workers map (entries carry spawned_by via
1404        // WorkerStatus). The Tool tests preload that field; the
1405        // failure modes (LeadGone, UnknownCaller) are still
1406        // reachable when the test omits the entry.
1407        let lead_slot = self
1408            .workers
1409            .lock()
1410            .get(cp.project_key.as_str())
1411            .and_then(|ws| ws.iter().find(|w| w.slot == *caller))
1412            .map(|w| w.spawned_by.clone());
1413        let Some(lead_slot) = lead_slot else {
1414            return Err(WorkerLeadDeliverError::UnknownCaller);
1415        };
1416        // Mock has no pool to consult; treat an unset `spawned_by` as
1417        // "lead gone" so tests can exercise that path explicitly.
1418        if lead_slot.label().is_empty() {
1419            return Err(WorkerLeadDeliverError::LeadGone);
1420        }
1421        // Record under the synthetic label `<lead>` so tests can
1422        // assert "the lead-bound delivery happened" without colliding
1423        // with a real worker label.
1424        self.deliver_calls.lock().push((caller.clone(), "<lead>".to_owned(), wrapped));
1425        Ok(WorkerTargetStatus::Delivered)
1426    }
1427}
1428
1429#[cfg(test)]
1430mod mock_tests {
1431    use super::*;
1432    use crate::mcp::peers::types::WrappedKind;
1433
1434    #[test]
1435    fn mock_caller_project_returns_preloaded() {
1436        let mock = MockWorkerFacade::new();
1437        mock.callers.lock().insert(
1438            SessionSlot::from_str_for_test("k1"),
1439            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1440        );
1441        let cp = mock.caller_project(&SessionSlot::from_str_for_test("k1")).unwrap();
1442        assert!(cp.is_lead);
1443        assert_eq!(cp.project_key.as_str(), "forge");
1444    }
1445
1446    #[tokio::test]
1447    async fn mock_spawn_rejects_non_lead() {
1448        let mock = MockWorkerFacade::new();
1449        mock.callers.lock().insert(
1450            SessionSlot::from_str_for_test("k1"),
1451            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: false },
1452        );
1453        let res = mock
1454            .spawn_worker(
1455                &SessionSlot::from_str_for_test("k1"),
1456                "reviewer".into(),
1457                "charter".into(),
1458                None,
1459                None,
1460                false,
1461                false,
1462                None,
1463                None,
1464            )
1465            .await;
1466        assert!(matches!(res, Err(WorkerSpawnError::NotLeadCaller)));
1467    }
1468
1469    #[tokio::test]
1470    async fn mock_spawn_records_call_and_returns_preloaded_reply() {
1471        let mock = MockWorkerFacade::new();
1472        mock.callers.lock().insert(
1473            SessionSlot::from_str_for_test("lead-key"),
1474            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1475        );
1476        *mock.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1477            session_id: "new-uuid".into(),
1478            tag: "forge:worker:reviewer".into(),
1479            mcp_families: None,
1480            rate_limited_account: None,
1481            durability_warning: None,
1482            worktree: None,
1483            session_choice: SessionChoice::Fresh,
1484        }));
1485        let res = mock
1486            .spawn_worker(
1487                &SessionSlot::from_str_for_test("lead-key"),
1488                "reviewer".into(),
1489                "charter".into(),
1490                None,
1491                None,
1492                false,
1493                false,
1494                None,
1495                None,
1496            )
1497            .await
1498            .unwrap();
1499        assert_eq!(res.session_id, "new-uuid");
1500        assert_eq!(mock.spawn_calls.lock().len(), 1);
1501    }
1502
1503    #[test]
1504    fn validate_worker_spawn_enforces_lead_and_nonempty_fields() {
1505        // Prod + mock spawn_worker both route through this, so the rules
1506        // are covered against the shipping code, not a copy.
1507        assert!(matches!(
1508            validate_worker_spawn(false, "reviewer", "c"),
1509            Err(WorkerSpawnError::NotLeadCaller)
1510        ));
1511        assert!(matches!(
1512            validate_worker_spawn(true, "   ", "c"),
1513            Err(WorkerSpawnError::EmptyLabel)
1514        ));
1515        assert!(matches!(
1516            validate_worker_spawn(true, LEAD_LABEL, "c"),
1517            Err(WorkerSpawnError::ReservedLabel)
1518        ));
1519        // claude rewrites a slash before deriving the worktree's path and
1520        // branch, so a label carrying one names a directory and a branch
1521        // forge would never find (#1603).
1522        assert!(matches!(
1523            validate_worker_spawn(true, "a/b", "c"),
1524            Err(WorkerSpawnError::LabelWithPathSeparator)
1525        ));
1526        assert!(matches!(
1527            validate_worker_spawn(true, "reviewer", "   "),
1528            Err(WorkerSpawnError::EmptyCharter)
1529        ));
1530        assert!(validate_worker_spawn(true, "reviewer", "c").is_ok());
1531    }
1532
1533    #[tokio::test]
1534    async fn mock_spawn_empty_inline_charter_errors_empty_charter() {
1535        let mock = MockWorkerFacade::new();
1536        let lead = SessionSlot::from_str_for_test("lead-key");
1537        mock.callers.lock().insert(
1538            lead.clone(),
1539            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1540        );
1541        let res = mock
1542            .spawn_worker(
1543                &lead,
1544                "reviewer".into(),
1545                "   ".into(),
1546                None,
1547                None,
1548                false,
1549                false,
1550                None,
1551                None,
1552            )
1553            .await;
1554        assert!(matches!(res, Err(WorkerSpawnError::EmptyCharter)));
1555        assert_eq!(
1556            mock.spawn_calls.lock().len(),
1557            0,
1558            "empty inline charter must not record a spawn"
1559        );
1560    }
1561
1562    #[test]
1563    fn dedup_finds_non_failed_label_ignores_failed() {
1564        use crate::mcp::workers::types::WorkerEntry;
1565        use forge_primitives::WorkerLiveness;
1566        use std::time::SystemTime;
1567        let entry = |label: &str, status: WorkerLiveness| WorkerEntry {
1568            label: label.to_owned(),
1569            charter: "c".into(),
1570            slot: SessionSlot::from_str_for_test("w-uuid"),
1571            session_id: None,
1572            status,
1573            spawned_at: SystemTime::UNIX_EPOCH,
1574            spawned_by: SessionSlot::from_str_for_test("lead"),
1575            needs_tag: false,
1576            is_git_repo_at_spawn: false,
1577            cwd: None,
1578            diagnostic: None,
1579            kick: None,
1580        };
1581        // Running / Spawning workers hold the label -> dedup blocks re-spawn.
1582        let running = vec![entry("implementer", WorkerLiveness::Running)];
1583        assert!(
1584            crate::mcp::workers::types::live_worker_with_label(&running, "implementer").is_some()
1585        );
1586        let spawning = vec![entry("implementer", WorkerLiveness::Spawning)];
1587        assert!(
1588            crate::mcp::workers::types::live_worker_with_label(&spawning, "implementer").is_some()
1589        );
1590        // A Failed entry is ignored -> its label can be re-spawned.
1591        let failed = vec![entry("implementer", WorkerLiveness::Failed)];
1592        assert!(
1593            crate::mcp::workers::types::live_worker_with_label(&failed, "implementer").is_none()
1594        );
1595        // No matching label.
1596        assert!(crate::mcp::workers::types::live_worker_with_label(&running, "tester").is_none());
1597    }
1598
1599    #[tokio::test]
1600    async fn mock_deliver_unknown_label_errors() {
1601        let mock = MockWorkerFacade::new();
1602        let caller = SessionSlot::from_str_for_test("k1");
1603        mock.callers.lock().insert(
1604            caller.clone(),
1605            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1606        );
1607        let wrapped = WrappedPrompt {
1608            id: crate::mcp::peers::types::MessageId::mint(),
1609            kind: WrappedKind::Message,
1610            sender_name: "forge".into(),
1611            sender_org: "Personal".into(),
1612            body: "hi".into(),
1613        };
1614        let res = mock.deliver_worker_prompt(&caller, "missing", wrapped).await;
1615        assert!(matches!(res, Err(WorkerDeliverError::UnknownLabel { .. })));
1616    }
1617
1618    fn status(label: &str) -> WorkerStatus {
1619        WorkerStatus {
1620            label: label.into(),
1621            charter: format!("{label}'s charter"),
1622            status: forge_primitives::WorkerLiveness::Running,
1623            session_id: format!("{label}-uuid"),
1624            slot: SessionSlot::from_str_for_test(label),
1625            spawned_at: std::time::SystemTime::UNIX_EPOCH,
1626            spawned_by: SessionSlot::from_str_for_test("lead"),
1627            diagnostic: None,
1628            heal_attempt: None,
1629            heal_next_at: None,
1630            activity: None,
1631        }
1632    }
1633
1634    #[test]
1635    fn mock_list_all_workers_flattens_every_project() {
1636        let mock = MockWorkerFacade::new();
1637        mock.workers.lock().insert("zeta".into(), vec![status("z1")]);
1638        mock.workers.lock().insert("alpha".into(), vec![status("a1"), status("a2")]);
1639        let mut labels: Vec<String> =
1640            mock.list_all_workers().into_iter().map(|worker| worker.label).collect();
1641        labels.sort();
1642        assert_eq!(labels, ["a1", "a2", "z1"], "every project's pool is flattened");
1643    }
1644
1645    #[test]
1646    fn mock_capacity_fallback_derives_live_from_workers_map() {
1647        let mock = MockWorkerFacade::new();
1648        mock.callers.lock().insert(
1649            SessionSlot::from_str_for_test("k1"),
1650            CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1651        );
1652        mock.workers.lock().insert(
1653            "forge".into(),
1654            vec![
1655                WorkerStatus {
1656                    label: "a".into(),
1657                    charter: "c".into(),
1658                    status: forge_primitives::WorkerLiveness::Running,
1659                    session_id: "a-uuid".into(),
1660                    slot: SessionSlot::from_str_for_test("a-uuid"),
1661                    spawned_at: std::time::SystemTime::UNIX_EPOCH,
1662                    spawned_by: SessionSlot::from_str_for_test("lead"),
1663                    diagnostic: None,
1664                    heal_attempt: None,
1665                    heal_next_at: None,
1666                    activity: None,
1667                },
1668                WorkerStatus {
1669                    label: "b".into(),
1670                    charter: "c".into(),
1671                    status: forge_primitives::WorkerLiveness::Running,
1672                    session_id: "b-uuid".into(),
1673                    slot: SessionSlot::from_str_for_test("b-uuid"),
1674                    spawned_at: std::time::SystemTime::UNIX_EPOCH,
1675                    spawned_by: SessionSlot::from_str_for_test("lead"),
1676                    diagnostic: None,
1677                    heal_attempt: None,
1678                    heal_next_at: None,
1679                    activity: None,
1680                },
1681            ],
1682        );
1683        let capacity =
1684            mock.capacity(&SessionSlot::from_str_for_test("k1")).expect("caller resolves");
1685        assert_eq!(capacity.project, "forge");
1686        assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
1687        assert_eq!(capacity.cap_source, WorkerCapSource::Default);
1688        assert_eq!(capacity.live, 2);
1689    }
1690}
1691
1692/// The `agents__list` read path is the only producer of
1693/// `WorkerStatus::activity`, so it needs pinning here rather than only
1694/// on the projection it calls - reverting this one `map` to
1695/// `WorkerEntry::to_status` makes the whole field inert.
1696#[cfg(test)]
1697mod prod_list_workers_tests {
1698    use super::*;
1699    use forge_primitives::{SessionLifecycleState, WorkerLiveness};
1700
1701    #[test]
1702    fn list_workers_populates_activity() {
1703        let (ws, _rx) = Workspace::testing_stub();
1704        ws.seed_test_project("forge", "/tmp/forge");
1705        let project = ws
1706            .list_projects()
1707            .into_iter()
1708            .find(|v| v.name == "forge")
1709            .expect("seeded project present")
1710            .key;
1711
1712        // The caller IS the worker, which is enough for
1713        // `caller_context` to resolve it into the project.
1714        let caller = SessionSlot::worker("TestOrg", "forge", "implementer");
1715        ws.insert_live_worker(
1716            &project,
1717            crate::mcp::workers::types::WorkerEntry {
1718                label: "implementer".into(),
1719                charter: "test charter".into(),
1720                slot: caller.clone(),
1721                session_id: None,
1722                status: WorkerLiveness::Running,
1723                spawned_at: std::time::SystemTime::UNIX_EPOCH,
1724                spawned_by: SessionSlot::lead("TestOrg", "forge"),
1725                needs_tag: false,
1726                is_git_repo_at_spawn: false,
1727                cwd: None,
1728                diagnostic: None,
1729                kick: None,
1730            },
1731        );
1732        let domain = ws.register_domain_session(caller.clone(), None);
1733
1734        let facade = ProdWorkerFacade::from_arc(&ws);
1735        let listed = facade.list_workers(&caller);
1736        assert_eq!(listed.len(), 1);
1737        assert_eq!(
1738            listed[0].activity,
1739            Some(SessionLifecycleState::Idle),
1740            "agents__list must report the derived activity, not leave it unset",
1741        );
1742
1743        domain.lock().turn_pending = true;
1744        assert_eq!(
1745            facade.list_workers(&caller)[0].activity,
1746            Some(SessionLifecycleState::Running),
1747            "and it must be re-derived per call, not cached at spawn",
1748        );
1749    }
1750
1751    /// The real walk pairs `list_projects` with the `live_workers` map, so
1752    /// this pins that both projects' entries reach the listing. A split
1753    /// project-key derivation would otherwise show an empty listing while
1754    /// every mock-backed handler test stays green.
1755    #[test]
1756    fn list_all_workers_walks_every_project() {
1757        let (ws, _rx) = Workspace::testing_stub();
1758        ws.seed_test_project("forge", "/tmp/forge");
1759        ws.seed_test_project("other", "/tmp/other");
1760        let projects = ws.list_projects();
1761        let key_for = |name: &str| {
1762            projects
1763                .iter()
1764                .find(|view| view.name == name)
1765                .expect("seeded project present")
1766                .key
1767                .clone()
1768        };
1769        let entry = |project_name: &str, label: &str, status: WorkerLiveness| {
1770            crate::mcp::workers::types::WorkerEntry {
1771                label: label.into(),
1772                charter: "test charter".into(),
1773                slot: SessionSlot::worker("TestOrg", project_name, label),
1774                session_id: None,
1775                status,
1776                spawned_at: std::time::SystemTime::UNIX_EPOCH,
1777                spawned_by: SessionSlot::lead("TestOrg", project_name),
1778                needs_tag: false,
1779                is_git_repo_at_spawn: false,
1780                cwd: None,
1781                diagnostic: None,
1782                kick: None,
1783            }
1784        };
1785        ws.insert_live_worker(
1786            &key_for("forge"),
1787            entry("forge", "implementer", WorkerLiveness::Running),
1788        );
1789        ws.insert_live_worker(
1790            &key_for("other"),
1791            entry("other", "reviewer", WorkerLiveness::Running),
1792        );
1793        // A failed worker keeps its row; the real listing must keep it too.
1794        ws.insert_live_worker(&key_for("forge"), entry("forge", "crashed", WorkerLiveness::Failed));
1795
1796        let implementer = SessionSlot::worker("TestOrg", "forge", "implementer");
1797        let domain = ws.register_domain_session(implementer, None);
1798
1799        let facade = ProdWorkerFacade::from_arc(&ws);
1800        let listed = facade.list_all_workers();
1801        let pairs: Vec<(String, String)> = listed
1802            .iter()
1803            .map(|worker| (worker.slot.project().to_owned(), worker.label.clone()))
1804            .collect();
1805        assert_eq!(
1806            pairs,
1807            vec![
1808                ("forge".to_owned(), "implementer".to_owned()),
1809                ("forge".to_owned(), "crashed".to_owned()),
1810                ("other".to_owned(), "reviewer".to_owned()),
1811            ],
1812            "every project's workers, in config order",
1813        );
1814        assert_eq!(
1815            listed[0].activity,
1816            Some(SessionLifecycleState::Idle),
1817            "the listing carries the derived activity, not the bare status",
1818        );
1819        domain.lock().turn_pending = true;
1820        assert_eq!(
1821            facade.list_all_workers()[0].activity,
1822            Some(SessionLifecycleState::Running),
1823            "and it is re-derived per call, not cached at spawn",
1824        );
1825    }
1826}
1827
1828#[cfg(test)]
1829mod deliver_tests {
1830    use super::*;
1831    use crate::mcp::peers::types::{MessageId, WrappedKind};
1832    use crate::mcp::workers::types::WorkerEntry;
1833    use forge_primitives::WorkerLiveness;
1834    use std::time::SystemTime;
1835
1836    fn entry(label: &str, status: WorkerLiveness, session_id: Option<&str>) -> WorkerEntry {
1837        WorkerEntry {
1838            label: label.to_owned(),
1839            charter: "test charter".into(),
1840            slot: SessionSlot::worker("TestOrg", "forge", label),
1841            session_id: session_id.map(forge_primitives::SessionId::new),
1842            status,
1843            spawned_at: SystemTime::UNIX_EPOCH,
1844            spawned_by: SessionSlot::lead("TestOrg", "forge"),
1845            needs_tag: false,
1846            is_git_repo_at_spawn: false,
1847            cwd: None,
1848            diagnostic: None,
1849            kick: None,
1850        }
1851    }
1852
1853    fn message() -> WrappedPrompt {
1854        WrappedPrompt {
1855            id: MessageId::mint(),
1856            kind: WrappedKind::Message,
1857            sender_name: "forge".to_owned(),
1858            sender_org: "Busytools".to_owned(),
1859            body: "status?".to_owned(),
1860        }
1861    }
1862
1863    /// A live worker whose seat has no session behind it: the prompt reaches
1864    /// nothing, so the ack must say so. This is the overclaim the issue names -
1865    /// `Delivered` for a delivery that never left forge. The entry is
1866    /// Running with a session id, so the delivery is not parked or held: it
1867    /// goes to the session dispatch, which finds no session task.
1868    #[tokio::test]
1869    async fn a_prompt_the_session_cannot_take_is_not_acked_as_delivered() {
1870        let (ws, _rx) = Workspace::testing_stub();
1871        ws.seed_test_project("forge", "/tmp/forge");
1872        let project = ws
1873            .list_projects()
1874            .into_iter()
1875            .find(|v| v.name == "forge")
1876            .expect("seeded project present")
1877            .key;
1878        let slot = SessionSlot::worker("TestOrg", "forge", "implementer");
1879        ws.insert_live_worker(
1880            &project,
1881            entry("implementer", WorkerLiveness::Running, Some("live")),
1882        );
1883        // A connected domain and no session task: the seat looks reachable
1884        // and nothing can receive the prompt.
1885        ws.mark_session_connected_for_test(&slot, "live");
1886
1887        let facade = ProdWorkerFacade::from_arc(&ws);
1888        let caller = SessionSlot::lead("TestOrg", "forge");
1889        let res = facade.deliver_worker_prompt(&caller, "implementer", message()).await;
1890        assert!(
1891            matches!(res, Err(WorkerDeliverError::DispatchFailed { .. })),
1892            "a prompt no session took must not be acked as delivered; got {res:?}",
1893        );
1894    }
1895
1896    /// A crashed worker keeps its row (Failed) so its label can be respawned
1897    /// and the crash reason stays visible. Addressing that row is addressing
1898    /// a seat with no session: the prompt can never reach a live worker, so
1899    /// the ack must refuse it rather than say "sent" - and the refusal names
1900    /// the dead row, not a label that was never there.
1901    #[tokio::test]
1902    async fn a_failed_workers_label_is_refused_as_not_live() {
1903        let (ws, _rx) = Workspace::testing_stub();
1904        ws.seed_test_project("forge", "/tmp/forge");
1905        let project = ws
1906            .list_projects()
1907            .into_iter()
1908            .find(|v| v.name == "forge")
1909            .expect("seeded project present")
1910            .key;
1911        ws.insert_live_worker(&project, entry("implementer", WorkerLiveness::Failed, Some("dead")));
1912
1913        let facade = ProdWorkerFacade::from_arc(&ws);
1914        let caller = SessionSlot::lead("TestOrg", "forge");
1915        let res = facade.deliver_worker_prompt(&caller, "implementer", message()).await;
1916        assert!(
1917            matches!(res, Err(WorkerDeliverError::NotLive { .. })),
1918            "a Failed worker is not a live worker; the delivery must refuse it rather \
1919             than ack it as sent, and say the row is dead; got {res:?}",
1920        );
1921
1922        // The control: a label with no entry at all is the other refusal, so
1923        // the two absences cannot collapse into one message.
1924        let ghost = facade.deliver_worker_prompt(&caller, "ghost", message()).await;
1925        assert!(
1926            matches!(ghost, Err(WorkerDeliverError::UnknownLabel { .. })),
1927            "a label with no row at all is UnknownLabel, not NotLive; got {ghost:?}",
1928        );
1929    }
1930}
1931
1932#[cfg(test)]
1933mod capacity_tests {
1934    use super::*;
1935    use forge_primitives::WorkerLiveness;
1936
1937    /// The slot the `forge` project's spawn names for `label`.
1938    fn worker_slot(label: &str) -> SessionSlot {
1939        SessionSlot::worker("TestOrg", "forge", label)
1940    }
1941
1942    fn entry(label: &str) -> crate::mcp::workers::types::WorkerEntry {
1943        crate::mcp::workers::types::WorkerEntry {
1944            label: label.to_owned(),
1945            charter: "test charter".into(),
1946            slot: worker_slot(label),
1947            session_id: None,
1948            status: WorkerLiveness::Running,
1949            spawned_at: std::time::SystemTime::UNIX_EPOCH,
1950            spawned_by: SessionSlot::lead("TestOrg", "forge"),
1951            needs_tag: false,
1952            is_git_repo_at_spawn: false,
1953            cwd: None,
1954            diagnostic: None,
1955            kick: None,
1956        }
1957    }
1958
1959    fn project_key_of(ws: &Workspace, name: &str) -> crate::ProjectKey {
1960        ws.list_projects().into_iter().find(|v| v.name == name).expect("seeded project present").key
1961    }
1962
1963    #[test]
1964    fn capacity_reports_project_override_when_set() {
1965        let (ws, _rx) = Workspace::testing_stub();
1966        ws.seed_test_project_with_max_workers("forge", "/tmp/forge", 1);
1967        let project = project_key_of(&ws, "forge");
1968        let caller = worker_slot("implementer");
1969        ws.insert_live_worker(&project, entry("implementer"));
1970
1971        let facade = ProdWorkerFacade::from_arc(&ws);
1972        let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
1973        assert_eq!(capacity.project, project.as_str());
1974        assert_eq!(capacity.cap, 1);
1975        assert_eq!(capacity.cap_source, WorkerCapSource::ProjectMaxWorkers);
1976        assert_eq!(capacity.live, 1);
1977    }
1978
1979    #[test]
1980    fn capacity_falls_back_to_default_without_override() {
1981        let (ws, _rx) = Workspace::testing_stub();
1982        ws.seed_test_project("forge", "/tmp/forge");
1983        let project = project_key_of(&ws, "forge");
1984        let caller = worker_slot("implementer");
1985        ws.insert_live_worker(&project, entry("implementer"));
1986
1987        let facade = ProdWorkerFacade::from_arc(&ws);
1988        let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
1989        assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
1990        assert_eq!(capacity.cap_source, WorkerCapSource::Default);
1991        assert_eq!(capacity.live, 1);
1992    }
1993
1994    #[test]
1995    fn capacity_live_count_matches_project_workers() {
1996        let (ws, _rx) = Workspace::testing_stub();
1997        ws.seed_test_project("forge", "/tmp/forge");
1998        let project = project_key_of(&ws, "forge");
1999        let caller = worker_slot("a");
2000        ws.insert_live_worker(&project, entry("a"));
2001        ws.insert_live_worker(&project, entry("b"));
2002
2003        let facade = ProdWorkerFacade::from_arc(&ws);
2004        let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
2005        assert_eq!(capacity.live, 2);
2006    }
2007
2008    #[test]
2009    fn capacity_none_for_unknown_caller() {
2010        let (ws, _rx) = Workspace::testing_stub();
2011        let facade = ProdWorkerFacade::from_arc(&ws);
2012        assert!(facade.capacity(&SessionSlot::from_str_for_test("ghost")).is_none());
2013    }
2014
2015    #[test]
2016    fn capacity_excludes_failed_workers_from_live_count() {
2017        let (ws, _rx) = Workspace::testing_stub();
2018        ws.seed_test_project_with_max_workers("forge", "/tmp/forge", 2);
2019        let project = project_key_of(&ws, "forge");
2020        let caller = worker_slot("running");
2021        ws.insert_live_worker(&project, entry("running"));
2022        let mut failed = entry("failed");
2023        failed.status = WorkerLiveness::Failed;
2024        ws.insert_live_worker(&project, failed);
2025
2026        let facade = ProdWorkerFacade::from_arc(&ws);
2027        let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
2028        assert_eq!(
2029            capacity.live, 1,
2030            "a Failed entry stays in the map but must not count against the cap"
2031        );
2032    }
2033
2034    #[test]
2035    fn capacity_zero_live_workers_after_failure() {
2036        let (ws, _rx) = Workspace::testing_stub();
2037        ws.seed_test_project("forge", "/tmp/forge");
2038        let project = project_key_of(&ws, "forge");
2039        let caller = worker_slot("only");
2040        let mut failed = entry("only");
2041        failed.status = WorkerLiveness::Failed;
2042        ws.insert_live_worker(&project, failed);
2043
2044        let facade = ProdWorkerFacade::from_arc(&ws);
2045        let capacity = facade.capacity(&caller).expect("a Failed entry still resolves the project");
2046        assert_eq!(capacity.live, 0);
2047        assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
2048    }
2049}
2050
2051#[cfg(test)]
2052mod worktree_creation_failed_tests {
2053    use super::*;
2054
2055    #[test]
2056    fn worktree_creation_failed_carries_reason() {
2057        let err = WorkerSpawnError::WorktreeCreationFailed {
2058            reason: "fatal: 'worktree-reviewer' is already used by worktree at /a".into(),
2059        };
2060        let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2061            panic!("expected WorktreeCreationFailed");
2062        };
2063        assert!(reason.contains("already used by worktree"));
2064    }
2065
2066    // ---------------------------------------------------------------
2067    // classify_worker_spawn_failure: heuristic mapping from claude's
2068    // raw error text + the WorkerEntry's is_git_repo_at_spawn flag
2069    // into either WorktreeCreationFailed (when the message implicates
2070    // the --worktree machinery AND the worker was actually spawned in
2071    // a git repo) or DispatchFailed (everything else). The classifier
2072    // is pure so it's unit-testable without spinning up a workspace.
2073    // ---------------------------------------------------------------
2074
2075    #[test]
2076    fn classify_routes_worktree_word_to_worktree_creation_failed_when_git() {
2077        // claude's worktree failures usually mention the word
2078        // "worktree" verbatim (e.g. "Error creating worktree: ...").
2079        let err = classify_worker_spawn_failure(
2080            "Error creating worktree: something went wrong",
2081            true,
2082            SpawnFailureKind::Unclassified,
2083        );
2084        let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2085            panic!("expected WorktreeCreationFailed; got {err:?}");
2086        };
2087        assert!(reason.contains("worktree"));
2088    }
2089
2090    #[test]
2091    fn classify_routes_base_branch_resolve_to_worktree_creation_failed_when_git() {
2092        // The empty-repo (unborn HEAD) case from spike 0.3: claude
2093        // refuses with "Failed to resolve base branch \"HEAD\": git
2094        // rev-parse failed". The word "worktree" does not appear, so
2095        // the classifier matches on the resolve-base-branch substring
2096        // instead.
2097        let err = classify_worker_spawn_failure(
2098            "Failed to resolve base branch \"HEAD\": git rev-parse failed",
2099            true,
2100            SpawnFailureKind::Unclassified,
2101        );
2102        assert!(
2103            matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2104            "expected WorktreeCreationFailed; got {err:?}",
2105        );
2106    }
2107
2108    #[test]
2109    fn classify_routes_already_used_to_worktree_creation_failed_when_git() {
2110        // "fatal: '<label>' is already used by worktree at <path>" -
2111        // the conflicting-branch case (label collision with an
2112        // existing worktree).
2113        let err = classify_worker_spawn_failure(
2114            "fatal: 'reviewer' is already used by worktree at /a/b/c",
2115            true,
2116            SpawnFailureKind::Unclassified,
2117        );
2118        assert!(
2119            matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2120            "expected WorktreeCreationFailed; got {err:?}",
2121        );
2122    }
2123
2124    #[test]
2125    fn classify_falls_through_to_dispatch_failed_for_unrelated_messages() {
2126        // A generic spawn-side error that has nothing to do with
2127        // worktrees should stay DispatchFailed so the LLM gets the
2128        // raw text verbatim, not a misclassified worktree variant.
2129        let err = classify_worker_spawn_failure(
2130            "agent spawn failed: subprocess exited with code 2",
2131            true,
2132            SpawnFailureKind::Unclassified,
2133        );
2134        let WorkerSpawnError::DispatchFailed { message } = err else {
2135            panic!("expected DispatchFailed; got {err:?}");
2136        };
2137        assert!(message.contains("subprocess exited"));
2138    }
2139
2140    #[test]
2141    fn classify_with_worktree_word_but_non_git_repo_falls_through() {
2142        // Defensive: a worktree-mentioning message arriving for a
2143        // worker that was NEVER spawned with --worktree (non-git
2144        // project, is_git_repo_at_spawn=false) is suspicious - the
2145        // claude binary couldn't have emitted a worktree-creation
2146        // error since it wasn't asked to create one. Fall through to
2147        // DispatchFailed rather than mislabel it.
2148        let err = classify_worker_spawn_failure(
2149            "Error creating worktree: something went wrong",
2150            false,
2151            SpawnFailureKind::Unclassified,
2152        );
2153        assert!(
2154            matches!(err, WorkerSpawnError::DispatchFailed { .. }),
2155            "expected DispatchFailed; got {err:?}",
2156        );
2157    }
2158
2159    /// `Error::CwdNotFound` renders the path it could not enter, and for
2160    /// a worker that path runs through `.claude/worktrees/<label>` - the
2161    /// one word the worktree heuristic keys on. Reading that as a
2162    /// worktree-creation failure deletes the worker's row and tells the
2163    /// lead its worktree could not be created, which is the opposite of
2164    /// what happened and of what this change promises.
2165    ///
2166    /// The second half is the control: the identical message unclassified
2167    /// still routes to the worktree variant, so the first assertion is
2168    /// reading the named kind and not a message that stopped matching.
2169    #[test]
2170    fn classify_reads_a_named_variant_over_a_worktrees_path_in_the_message() {
2171        let message = "forge-sdk session resume failed: claude subprocess working directory \
2172                       `/repo/.claude/worktrees/reviewer` does not exist";
2173        let named =
2174            classify_worker_spawn_failure(message, true, SpawnFailureKind::WorkingDirMissing);
2175        assert!(
2176            matches!(named, WorkerSpawnError::DispatchFailed { .. }),
2177            "a named missing-working-directory failure is not a worktree-creation \
2178             failure, however its path renders; got {named:?}",
2179        );
2180
2181        let unclassified =
2182            classify_worker_spawn_failure(message, true, SpawnFailureKind::Unclassified);
2183        assert!(
2184            matches!(unclassified, WorkerSpawnError::WorktreeCreationFailed { .. }),
2185            "control: the same message with no named variant must still route on the \
2186             heuristic; got {unclassified:?}",
2187        );
2188    }
2189
2190    #[test]
2191    fn classify_is_case_insensitive_on_match_terms() {
2192        // Be lenient about casing - claude has shipped messages with
2193        // varying capitalization across CLI versions. Match on the
2194        // lowercased message body.
2195        let err = classify_worker_spawn_failure(
2196            "WORKTREE creation failed",
2197            true,
2198            SpawnFailureKind::Unclassified,
2199        );
2200        assert!(
2201            matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2202            "expected case-insensitive match to WorktreeCreationFailed; got {err:?}",
2203        );
2204    }
2205
2206    #[test]
2207    fn classify_preserves_original_casing_in_reason() {
2208        // The classifier may lowercase for matching but the reason
2209        // string handed back to the LLM should preserve the original
2210        // casing - the LLM sees claude's verbatim error text.
2211        let original = "Error creating Worktree: nope";
2212        let err = classify_worker_spawn_failure(original, true, SpawnFailureKind::Unclassified);
2213        let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2214            panic!("expected WorktreeCreationFailed; got {err:?}");
2215        };
2216        assert_eq!(reason, original);
2217    }
2218
2219    /// #245 Layer C blocker 2: pin the bridge-prefix contract.
2220    /// `forge_sdk_bridge` wraps every async ConnectionFailed message
2221    /// with a literal "forge-sdk session ..." prefix; none of those
2222    /// prefixes contain the words "worktree" / "base branch" /
2223    /// "already used by worktree" that the classifier matches on, so
2224    /// a generic dispatch failure can't accidentally classify as
2225    /// `WorktreeCreationFailed` just because the bridge happened to
2226    /// wrap it.
2227    ///
2228    /// If a future bridge change introduces "worktree" into the
2229    /// wrapper prose, this test fails - that's the signal to either
2230    /// rename the wrapper or plumb a typed reason variant through
2231    /// `AgentEvent::ConnectionFailed`.
2232    #[test]
2233    fn bridge_prefix_does_not_collide_with_worktree_predicate() {
2234        // The three wrapper prefixes verbatim from forge_sdk_bridge.
2235        let prefixes = [
2236            "forge-sdk session spawn failed: ",
2237            "forge-sdk session resume failed: ",
2238            "forge-sdk session spawn failed after resume fallback (resume err: ",
2239        ];
2240        for prefix in prefixes {
2241            let lower = prefix.to_lowercase();
2242            assert!(
2243                !lower.contains("worktree"),
2244                "bridge prefix {prefix:?} must not contain 'worktree' or the \
2245                 classifier will misclassify generic errors as worktree failures",
2246            );
2247            assert!(
2248                !lower.contains("failed to resolve base branch"),
2249                "bridge prefix {prefix:?} must not contain the 'failed to resolve base branch' \
2250                 discriminator",
2251            );
2252            assert!(
2253                !lower.contains("already used by worktree"),
2254                "bridge prefix {prefix:?} must not contain the 'already used by worktree' \
2255                 discriminator",
2256            );
2257        }
2258
2259        // Concretely: a non-worktree generic failure wrapped by the
2260        // bridge stays classified as DispatchFailed.
2261        let wrapped = "forge-sdk session resume failed: subprocess exited with code 2";
2262        let err = classify_worker_spawn_failure(
2263            wrapped,
2264            /* is_git_repo_at_spawn */ true,
2265            SpawnFailureKind::Unclassified,
2266        );
2267        assert!(
2268            matches!(err, WorkerSpawnError::DispatchFailed { .. }),
2269            "wrapped generic failure must stay DispatchFailed; got {err:?}",
2270        );
2271    }
2272
2273    #[test]
2274    fn the_cap_refusal_stays_a_dispatch_failure() {
2275        let message = crate::spawn::worker_limit_reached_message("my-worktree-proj", 2, 2);
2276        assert_eq!(
2277            classify_worker_spawn_failure(&message, true, SpawnFailureKind::Unclassified),
2278            WorkerSpawnError::DispatchFailed { message },
2279            "the cap refusal stays DispatchFailed even when the project key carries 'worktree'",
2280        );
2281    }
2282
2283    /// The known-project refusal is recognised by its own phrase too, and
2284    /// that guard is what keeps it a dispatch failure: the sentence
2285    /// interpolates a project NAME, and a project may be called anything -
2286    /// `worktree-lab` here - which the classifier would otherwise route to
2287    /// a worktree-creation failure the spawn never suffered.
2288    #[test]
2289    fn the_known_project_refusal_stays_a_dispatch_failure_whatever_the_project_is_called() {
2290        let message = crate::spawn::known_project_refusal("worktree-lab", "forge");
2291        assert_eq!(
2292            classify_worker_spawn_failure(&message, true, SpawnFailureKind::Unclassified),
2293            WorkerSpawnError::DispatchFailed { message: message.clone() },
2294            "the known-project refusal stays DispatchFailed however its project reads",
2295        );
2296
2297        // The other arm too: a spawn from the project named that way.
2298        let own = crate::spawn::known_project_refusal("worktree-lab", "worktree-lab");
2299        assert_eq!(
2300            classify_worker_spawn_failure(&own, true, SpawnFailureKind::Unclassified),
2301            WorkerSpawnError::DispatchFailed { message: own.clone() },
2302            "and the same for a project whose own name carries the word",
2303        );
2304    }
2305
2306    /// The despawn-cleanup refusal is recognised by its own phrase, so a
2307    /// label the sentence might come to carry ("worktree-fix" here, the
2308    /// shape the LabelLive refusal already has) cannot route it to a
2309    /// worktree-creation failure it did not suffer.
2310    #[test]
2311    fn the_cleanup_refusal_stays_a_dispatch_failure_whatever_the_label_spells() {
2312        let message =
2313            "the previous despawn of 'worktree-fix' is still cleaning up; retry once it finishes";
2314        assert_eq!(
2315            classify_worker_spawn_failure(message, true, SpawnFailureKind::Unclassified),
2316            WorkerSpawnError::DispatchFailed { message: message.to_owned() },
2317            "the cleanup refusal stays DispatchFailed however its label reads",
2318        );
2319    }
2320}
2321
2322#[cfg(test)]
2323mod discard_tests {
2324    use super::*;
2325    use std::sync::Arc;
2326
2327    fn run_git(dir: &std::path::Path, args: &[&str]) {
2328        let status =
2329            std::process::Command::new("git").arg("-C").arg(dir).args(args).status().expect("git");
2330        assert!(status.success(), "git {args:?} in {}", dir.display());
2331    }
2332
2333    /// A main checkout with proj-a and proj-b linked worktrees declared,
2334    /// plus an account.
2335    fn linked_projects() -> (Arc<Workspace>, std::path::PathBuf, Vec<tempfile::TempDir>) {
2336        let repo = tempfile::tempdir().expect("repo");
2337        let main = repo.path().join("main");
2338        std::fs::create_dir_all(&main).expect("main dir");
2339        run_git(&main, &["init", "-q"]);
2340        run_git(&main, &["config", "user.email", "t@example.com"]);
2341        run_git(&main, &["config", "user.name", "Test"]);
2342        run_git(&main, &["config", "commit.gpgsign", "false"]);
2343        std::fs::write(main.join("README.md"), "seed").expect("write seed");
2344        run_git(&main, &["add", "."]);
2345        run_git(&main, &["commit", "-q", "-m", "init"]);
2346        let main = std::fs::canonicalize(&main).expect("canonical main");
2347        let holder = tempfile::tempdir().expect("holder");
2348        let linked_a = holder.path().join("proj-a");
2349        let linked_b = holder.path().join("proj-b");
2350        run_git(
2351            &main,
2352            &["worktree", "add", "-q", "-b", "proj-a", linked_a.to_str().expect("utf8")],
2353        );
2354        run_git(
2355            &main,
2356            &["worktree", "add", "-q", "-b", "proj-b", linked_b.to_str().expect("utf8")],
2357        );
2358        let cfg = tempfile::tempdir().expect("cfg");
2359        let forge_dir = cfg.path().join("forge");
2360        std::fs::create_dir_all(&forge_dir).expect("forge dir");
2361        std::fs::write(
2362            forge_dir.join("forge.toml"),
2363            format!(
2364                "[[orgs]]\nname = \"Default\"\naccounts = [\"a\"]\n\
2365                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
2366                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
2367                 [[accounts]]\ndisplay_name = \"a\"\ntoken = \"t\"\n\
2368                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
2369                linked_a.display(),
2370                linked_b.display()
2371            ),
2372        )
2373        .expect("write forge.toml");
2374        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
2375        (ws, main, vec![repo, holder, cfg])
2376    }
2377
2378    fn key_of(ws: &Arc<Workspace>, name: &str) -> crate::ProjectKey {
2379        ws.list_projects().into_iter().find(|v| v.name == name).expect("project").key
2380    }
2381
2382    fn winner_entry(project: &str, label: &str) -> crate::mcp::workers::types::WorkerEntry {
2383        crate::mcp::workers::types::WorkerEntry {
2384            label: label.to_owned(),
2385            charter: "test".to_owned(),
2386            slot: SessionSlot::worker("Default", project, label),
2387            session_id: None,
2388            status: forge_primitives::WorkerLiveness::Running,
2389            spawned_at: std::time::SystemTime::UNIX_EPOCH,
2390            spawned_by: SessionSlot::lead("Default", project),
2391            needs_tag: false,
2392            is_git_repo_at_spawn: true,
2393            cwd: None,
2394            diagnostic: None,
2395            kick: None,
2396        }
2397    }
2398
2399    fn shared_worktree(main: &std::path::Path) -> std::path::PathBuf {
2400        let shared = main.join(".claude").join("worktrees").join("shared");
2401        std::fs::create_dir_all(shared.parent().expect("worktrees root")).expect("mkdir");
2402        run_git(
2403            main,
2404            &["worktree", "add", "-q", "-b", "worktree-shared", shared.to_str().expect("utf8")],
2405        );
2406        shared
2407    }
2408
2409    fn branch_stands(main: &std::path::Path) -> bool {
2410        let branches = std::process::Command::new("git")
2411            .args(["branch", "--list", "worktree-shared"])
2412            .current_dir(main)
2413            .output()
2414            .expect("git branch");
2415        !String::from_utf8_lossy(&branches.stdout).trim().is_empty()
2416    }
2417
2418    /// A rollback never discards a tree another project's live worker
2419    /// holds: a scan of the refused caller's own project misses the
2420    /// cross-project winner and deletes its tree and branch.
2421    #[test]
2422    fn a_rollback_keeps_a_tree_another_project_holds() {
2423        let (ws, main, _keep) = linked_projects();
2424        let shared = shared_worktree(&main);
2425        ws.insert_live_worker(&key_of(&ws, "proj-a"), winner_entry("proj-a", "shared"));
2426
2427        discard_refused_worktree(
2428            &ws,
2429            &key_of(&ws, "proj-b"),
2430            &main,
2431            "shared",
2432            Some((
2433                shared.clone(),
2434                forge_agent::env::worktree::WorktreeEnsure::CreatedWithNewBranch,
2435            )),
2436        );
2437
2438        assert!(shared.exists(), "another project's live worker's tree must survive the rollback");
2439        assert!(branch_stands(&main), "and its branch is not reaped");
2440    }
2441
2442    /// Two resumes of one label: the loser mints the tree, the winner takes
2443    /// the registry under the same project and label, and the loser's
2444    /// rollback must leave the winner's tree and branch alone. The sibling
2445    /// test's holder is another project; this is the same-project winner
2446    /// the skip exists for (a non-git holder's tree is the project root and
2447    /// never matches).
2448    #[test]
2449    fn a_losing_resume_leaves_the_winners_worktree_alone() {
2450        let (ws, main, _keep) = linked_projects();
2451        let shared = shared_worktree(&main);
2452        ws.insert_live_worker(&key_of(&ws, "proj-a"), winner_entry("proj-a", "shared"));
2453
2454        discard_refused_worktree(
2455            &ws,
2456            &key_of(&ws, "proj-a"),
2457            &main,
2458            "shared",
2459            Some((
2460                shared.clone(),
2461                forge_agent::env::worktree::WorktreeEnsure::CreatedWithNewBranch,
2462            )),
2463        );
2464
2465        assert!(shared.exists(), "the winner's tree survives the losing resume's rollback");
2466        assert!(branch_stands(&main), "and the branch the loser minted is not reaped");
2467    }
2468}