Skip to main content

forge_workspace/
workspace.rs

1//! The orchestrator.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::Result;
9use forge_agent::AgentHandle;
10use forge_agent::client::SessionLaunchSettings;
11use forge_agent::env::cli_version::CliVersionInfo;
12use forge_primitives::cloud::service_status::ServiceIssue;
13use forge_primitives::settings::{SettingsEditOp, SettingsEditRequest};
14use forge_primitives::tasks::TaskId;
15use forge_primitives::{AvailableAgent, AvailableCommand, Message, SDKSessionInfo, SessionId};
16use std::io::Write as _;
17
18use crate::gotify::GotifySubsystem;
19use crate::mcp::peers::types::{MessageId, WrappedKind, WrappedPrompt};
20use parking_lot::Mutex;
21use tokio::sync::mpsc;
22use tracing::Instrument;
23
24/// `path` with its longest existing prefix resolved through symlinks and the
25/// rest kept as spelled: `canonicalize` fails wholesale once the last
26/// component is gone, and the two sides of a comparison then use different
27/// spellings of the same directory.
28fn resolve_existing_prefix(path: &Path) -> PathBuf {
29    if let Ok(resolved) = std::fs::canonicalize(path) {
30        return resolved;
31    }
32    let mut missing = Vec::new();
33    let mut head = path.to_path_buf();
34    while let Some(parent) = head.parent().map(Path::to_path_buf) {
35        let Some(name) = head.file_name() else { break };
36        missing.push(name.to_owned());
37        if let Ok(resolved) = std::fs::canonicalize(&parent) {
38            let mut out = resolved;
39            for part in missing.iter().rev() {
40                out.push(part);
41            }
42            return out;
43        }
44        head = parent;
45    }
46    path.to_path_buf()
47}
48
49/// Why a settings save did not write.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum SettingsSaveError {
52    /// The file's raw text moved since the page loaded it.
53    Stale,
54    /// An edit or the candidate failed validation. `key_path` anchors the
55    /// first edit that fails on its own, where one does.
56    Invalid { message: String, key_path: Option<String> },
57    /// A guard refused the save. `key_path` anchors the refusal at one of the
58    /// save's keys where the guard names one.
59    Refused { message: String, key_path: Option<String> },
60    /// The file could not be read or written.
61    Io(String),
62}
63
64impl std::fmt::Display for SettingsSaveError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Stale => f.write_str("forge.toml has changed since this page loaded it"),
68            Self::Invalid { message, .. } | Self::Refused { message, .. } | Self::Io(message) => {
69                f.write_str(message)
70            }
71        }
72    }
73}
74
75impl From<crate::settings_write::WriteError> for SettingsSaveError {
76    fn from(error: crate::settings_write::WriteError) -> Self {
77        Self::Invalid { message: error.message, key_path: error.key_path }
78    }
79}
80
81/// The state a spawn marks its seat with: open, no turn in flight. The
82/// rollback restores only while the row still holds it, so a close landing
83/// mid-spawn is never undone.
84const SPAWNED_SEAT_STATE: crate::store::sessions::SeatState =
85    crate::store::sessions::SeatState::Idle;
86
87/// Undo a spawn's own side effects when it is refused before the session is
88/// pooled: the id it recorded, the seat state it moved, and a domain handle
89/// it created. Armed from the id record to the pool insert; an unarmed guard
90/// does nothing, so the pooled and refused paths both end in a plain drop.
91struct SpawnRollback {
92    workspace: Arc<Workspace>,
93    slot: SessionSlot,
94    stored: Option<String>,
95    prior_seat_state: Option<crate::store::sessions::SeatState>,
96    domain_created: bool,
97    armed: bool,
98}
99
100impl Drop for SpawnRollback {
101    fn drop(&mut self) {
102        if !self.armed {
103            return;
104        }
105        if self.domain_created {
106            self.workspace.domain_handles.lock().remove(&self.slot);
107        }
108        self.workspace.forget_spawn_id(&self.slot, self.stored.as_deref());
109        self.workspace.restore_seat_state(&self.slot, self.prior_seat_state, SPAWNED_SEAT_STATE);
110    }
111}
112
113/// The project pairs and account names a batch leaves behind, read off the
114/// batch and the loaded document rather than a second load: a removal drops
115/// an entry, and a set on one of the naming fields renames it. The loaded
116/// candidate is checked again after validation, so a shape this misses is
117/// still caught.
118fn departures_from_edits(
119    before: &LoadedConfig,
120    edits: &[SettingsEditRequest],
121    document: &forge_primitives::SettingsDocument,
122) -> (HashSet<(String, String)>, HashSet<String>) {
123    let mut projects: HashSet<(String, String)> =
124        before.projects.iter().map(|project| (project.org.clone(), project.name.clone())).collect();
125    let mut accounts: HashSet<String> =
126        before.accounts.iter().map(|account| account.display_name.clone()).collect();
127    let row =
128        |path: &str| document.areas.iter().flat_map(|area| &area.keys).find(|key| key.path == path);
129    let pattern_of = |edit: &SettingsEditRequest| {
130        if edit.pattern.is_empty() {
131            row(&edit.key_path).map(|key| key.pattern.clone())
132        } else {
133            Some(edit.pattern.clone())
134        }
135    };
136    // **The writer applies renames last**, so the simulation does too - a
137    // batch that removes one entry and renames another into its place is
138    // legitimate, and applying the rename first reads both as departures.
139    let mut rest = Vec::new();
140    let mut nested_renames = Vec::new();
141    let mut entry_renames = Vec::new();
142    for edit in edits {
143        match pattern_of(edit) {
144            Some(pattern) if pattern == "orgs[].projects[].name" => nested_renames.push(edit),
145            Some(pattern)
146                if matches!(
147                    pattern.as_str(),
148                    "orgs[].name" | "accounts[].display_name" | "slack[].workspace"
149                ) =>
150            {
151                entry_renames.push(edit);
152            }
153            _ => rest.push(edit),
154        }
155    }
156    for edit in rest.into_iter().chain(nested_renames).chain(entry_renames) {
157        if edit.op == SettingsEditOp::Remove && edit.pattern.is_empty() {
158            if edit.area == "accounts" {
159                if let Some(name) = edit.key_path.strip_prefix("accounts.") {
160                    accounts.remove(name);
161                }
162            } else if edit.area == "projects"
163                && let Some(project) = before.projects.iter().find(|project| {
164                    edit.key_path == format!("orgs.{}.projects.{}", project.org, project.name)
165                })
166            {
167                projects.remove(&(project.org.clone(), project.name.clone()));
168            }
169            continue;
170        }
171        if edit.op != SettingsEditOp::Set {
172            continue;
173        }
174        let pattern = if edit.pattern.is_empty() {
175            row(&edit.key_path).map(|key| key.pattern.clone())
176        } else {
177            Some(edit.pattern.clone())
178        };
179        let Some(pattern) = pattern else { continue };
180        let Some(key) = row(&edit.key_path) else { continue };
181        let renamed = edit.value.as_ref().and_then(serde_json::Value::as_str);
182        match pattern.as_str() {
183            "orgs[].projects[].name" => {
184                if let (Some(org), Some(project)) = (key.group.first(), key.group.get(1)) {
185                    projects.remove(&(org.clone(), project.clone()));
186                    if let Some(name) = renamed {
187                        projects.insert((org.clone(), name.to_owned()));
188                    }
189                }
190            }
191            "orgs[].name" => {
192                if let (Some(org), Some(name)) = (key.group.first(), renamed) {
193                    let moved: Vec<(String, String)> =
194                        projects.iter().filter(|(held, _)| held == org).cloned().collect();
195                    for (_, project) in moved {
196                        projects.remove(&(org.clone(), project.clone()));
197                        projects.insert((name.to_owned(), project));
198                    }
199                }
200            }
201            "accounts[].display_name" => {
202                if let Some(old) = key.group.first() {
203                    accounts.remove(old);
204                    if let Some(name) = renamed {
205                        accounts.insert(name.to_owned());
206                    }
207                }
208            }
209            _ => {}
210        }
211    }
212    (projects, accounts)
213}
214
215/// Replace `path` with `contents` through a temp file in the same directory:
216/// a reader sees the old file or the new one, never a half-written one, and
217/// the config watcher's re-arm survives the rename.
218///
219/// **The temp never widens the file.** It is created 0600 and takes the
220/// destination's mode before the rename: `forge.toml` holds every account,
221/// Slack, Gotify and System One token, and a save must keep whatever mode the
222/// operator gave it. A symlinked path is written through to its target, so a
223/// save cannot replace the link with a regular file.
224/// Why a config write did not land.
225#[derive(Debug)]
226enum WriteRefusal {
227    /// The file moved after the page's fingerprint was checked.
228    Stale,
229    Io(std::io::Error),
230}
231
232fn write_atomically(
233    path: &Path,
234    contents: &str,
235    expected: Option<&str>,
236) -> Result<(), WriteRefusal> {
237    let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
238    let directory = target.parent().unwrap_or_else(|| Path::new("."));
239    let temporary = directory.join(format!(".forge.toml.settings-{}", std::process::id()));
240    let outcome = (|| {
241        let mut options = std::fs::OpenOptions::new();
242        options.write(true).create(true).truncate(true);
243        #[cfg(unix)]
244        {
245            use std::os::unix::fs::OpenOptionsExt as _;
246            options.mode(0o600);
247        }
248        let mut file = options.open(&temporary).map_err(WriteRefusal::Io)?;
249        #[cfg(unix)]
250        {
251            use std::os::unix::fs::PermissionsExt as _;
252            let mode = std::fs::metadata(&target).map_or(0o600, |meta| meta.permissions().mode());
253            file.set_permissions(std::fs::Permissions::from_mode(mode))
254                .map_err(WriteRefusal::Io)?;
255        }
256        file.write_all(contents.as_bytes()).map_err(WriteRefusal::Io)?;
257        file.sync_all().map_err(WriteRefusal::Io)?;
258        // **The file may have moved since the page's fingerprint was
259        // checked.** Reading it here, after the temp is written and before
260        // the rename, is the last moment the check can run.
261        if let Some(expected) = expected {
262            let current = std::fs::read_to_string(&target).map_err(WriteRefusal::Io)?;
263            if crate::config::fingerprint_of(&current) != expected {
264                return Err(WriteRefusal::Stale);
265            }
266        }
267        std::fs::rename(&temporary, &target).map_err(WriteRefusal::Io)?;
268        // **The rename already landed.** A directory fsync that fails leaves
269        // the file replaced and only its durability unknown, so the save is
270        // not reported as failed for it.
271        if let Ok(held) = std::fs::File::open(directory)
272            && let Err(error) = held.sync_all()
273            && error.kind() != std::io::ErrorKind::InvalidInput
274        {
275            tracing::warn!(
276                path = %target.display(),
277                %error,
278                "the config's directory could not be synced after the rename",
279            );
280        }
281        Ok(())
282    })();
283    if outcome.is_err() {
284        let _ = std::fs::remove_file(&temporary);
285    }
286    outcome
287}
288
289use forge_gateway::AccountKey;
290use forge_gateway::ProviderHost as _;
291
292use crate::config::{ConfigReload, LoadedConfig, LoadedProject, load_from_dir};
293use crate::domain_session::DomainSession;
294use crate::error::WorkspaceError;
295use crate::protocol::{Command, DispatchError, NoticeSeverity, PromptSource, SessionUpdate};
296use crate::session_task::SessionTask;
297use crate::spawn;
298use crate::target::{ProjectKey, SessionSlot, SessionTarget};
299use crate::update_fanout::{SeatUpdate, SubscriberRole, UpdateFanout};
300use crate::views::{AccountLoadingRow, ProjectView, SessionView};
301
302#[cfg(any(test, feature = "testing"))]
303pub(crate) mod testing;
304
305/// How often the claude version probe re-runs after the boot fetch.
306/// Versions change rarely, so a few minutes is ample - the point is only
307/// to recover a transient boot failure, not to track releases tightly.
308const CLI_VERSION_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
309
310/// The CLI's model-slot variables: the project's `model` key is
311/// stamped into all of them at spawn, so one model serves the main
312/// loop, subagents and background slots alike.
313const MODEL_SLOT_VARIABLES: [&str; 5] = [
314    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
315    "ANTHROPIC_DEFAULT_OPUS_MODEL",
316    "ANTHROPIC_DEFAULT_SONNET_MODEL",
317    "ANTHROPIC_SMALL_FAST_MODEL",
318    "CLAUDE_CODE_SUBAGENT_MODEL",
319];
320
321/// The env the CLI takes its auto-compaction window from: the `[models]`
322/// table's `auto_compact_window` is stamped here for the project's model.
323const AUTO_COMPACT_WINDOW_ENV: &str = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
324
325/// A project's walk blocked by a rotation cooldown, as the walk's own
326/// `BudgetExhausted` reports it.
327pub(crate) struct ProjectCooling {
328    pub(crate) org: String,
329    pub(crate) model: String,
330    pub(crate) tried: usize,
331    pub(crate) reset_in: Duration,
332}
333
334/// Max attempts for `tag_session_with_retry` to find the worker's
335/// `<session_id>.jsonl` and append the tag row. claude CLI writes the
336/// file lazily on the first user turn - workers with an `initial_prompt`
337/// usually land within ~100 ms-2 s of `Connected`. 30 attempts at
338/// 100 ms = ~3 s wall caps the window generously without burning
339/// resources. Idle-spawned workers (no prompt) never produce a JSONL
340/// at all until the first `DeliverWorkerPrompt`; for those we exit
341/// the retry with `Io(NotFound)`, mark the entry `needs_tag = true`,
342/// and retry opportunistically when the first turn arrives.
343const WORKER_TAG_RETRY_ATTEMPTS: u32 = 30;
344
345/// Delay between `tag_session` retry attempts when the JSONL is not
346/// yet on disk. 100 ms is short enough to land within one or two ticks
347/// of claude's first write and large enough that 30 retries cap the
348/// total wait at a few seconds.
349const WORKER_TAG_RETRY_DELAY: Duration = Duration::from_millis(100);
350
351/// Minimum gap between successive worker-kick dispatches (#259).
352/// Multi-worker teams hit Anthropic's per-IP burst limit when all
353/// `maybe_kick_worker_on_connected` paths fire `Command::Prompt`
354/// within the same tick at boot; routing every kick through a
355/// workspace-level mpsc + a drainer task that sleeps this interval
356/// between sends spreads them out enough to avoid the burst
357/// rejection. The first kick in an empty channel fires with zero
358/// added latency; only subsequent kicks pay the sleep. Worst case
359/// at typical team sizes (7-10 workers) is ~5-7 s to fully kick
360/// the cohort, vs ~1 s pre-#259 with most kicks rejected.
361const KICK_DISPATCH_INTERVAL: Duration = Duration::from_millis(750);
362
363/// The words a continued seat's model receives, and what the row says to
364/// whoever reads it: forge is the one that resumed the turn, and the work
365/// already done is not to be repeated.
366fn auto_continue_prompt(reason: &str) -> String {
367    format!(
368        "forge continued this session automatically after the previous turn failed: {reason}. \
369Pick up exactly where you stopped - everything completed before the failure is in the history \
370above, so do not restart the task, and do not repeat any step, tool call or side effect that \
371already completed."
372    )
373}
374
375/// Delegation block appended to a Lead session's system prompt.
376///
377/// Lead-only: `agents__spawn` refuses a worker caller, so a worker
378/// given this block would be told to call a tool that rejects it.
379///
380/// Matches the shipped-prompt constants in `forge-agent`: one escaped
381/// literal, no runtime assembly.
382const LEAD_DELEGATION_PREAMBLE: &str = "\
383You can delegate work to worker sessions via the \
384mcp__forge__agents__ tools. Spawn one with \
385agents__spawn(label=\"<name>\", charter=\"<its mission>\") - the charter \
386is required and is what defines that worker; talk to it with \
387agents__send_message; list your live workers with agents__list; \
388revise a worker's stored charter or kicks with agents__update, which \
389takes effect on its next restart. agents__spawn always creates the \
390worker in YOUR project. The same family reaches other projects' \
391agents: a message takes an org and project, plus a label to name a \
392worker rather than that project's own agent. At most one live worker \
393exists per label - if it already exists, message it instead of \
394spawning again. Run independent pieces in parallel, up to the \
395project's capacity; a merge conflict is not, by itself, a reason to \
396hold a piece - start it, resolve the conflict and re-run the gates. \
397Only genuine shared-state ordering serializes: one in-flight migration \
398sequence, main's merge queue, a release/version line. Before you park \
399a dispatch decision - spawn now, queue behind a worker, or inline - \
400decide whether the options genuinely differ; when they do, put the \
401enumerated options and the context that bears on them to \
402systemone__ask_choice when your tools include it, and without it \
403decide it yourself. Either way, report the decision where the work is \
404reported. \
405Spawned workers are durable: they survive forge restarts and re-spawn \
406automatically, resuming where they left off, until you explicitly \
407despawn them with agents__despawn (or close their row in the Projects \
408pane). Despawn a worker once its work is truly done, otherwise it keeps \
409coming back on every restart. A PR review loop \
410fans out as ephemeral in-session subagents, not workers - a reviewer \
411spawned as a worker lingers as a durable row and worktree after its \
412round ends. Give each worker the whole cut to land. A work cut - one \
413feature, one issue, one fix - lands whole: one branch, one review, one \
414look, one merge. Never slice it into pieces that land one after \
415another, and never show or merge an intermediate step on the way. \
416The look is the one that cut is owed: a feature waits for the user's \
417own look before merge; a fix merges after the review loop as usual. A \
418plan, spec or design the user must review reaches them through \
419`documents__hand_off` with `reviewable: true`, never as a path in \
420chat. Workers build; subagents review, unless the user wants a \
421reviewer kept on as a long-lived worker.";
422
423/// Forge-supplied resume kick for a worker whose row carries no
424/// `resume_kick` of its own. On a resuming re-spawn forge delivers this
425/// constant as the worker's first turn - telling it to continue rather
426/// than start the task over.
427pub(crate) const DYNAMIC_WORKER_RESTART_NOTE: &str = "This session was restarted by forge. Your prior conversation and progress are in the history above. Continue where you left off; do not restart the task. If the history's timestamps disagree with the clock and the live schedules, believe the clock and the schedules, and still do the concrete asks.";
428
429/// The first turn a boot spawn delivers to a lead it hands a stored
430/// session id to. A lead has no kick of its own, so this note is what
431/// turns a resumed conversation into work continued rather than a seat
432/// idle at a prompt. The handoff and the board are the source of what
433/// was in flight: a `--resume` that the CLI refuses falls back to a
434/// fresh session, so the history is named conditionally rather than
435/// relied on.
436pub const LEAD_RESTART_NOTE: &str = "forge restarted. Read the project handoff (`handoff.md` in the project's memory directory) and the task board before acting; they say what was in flight. Continue that work and do not restart what is finished. If a prior conversation is in the history above, treat it as the record of this seat's last run. The last turn was interrupted, so its final tool call may not have completed - check the state before continuing. Anything you sent before the restart may not have arrived; confirm it did before relying on it. If the history's timestamps disagree with the clock and the live schedules, believe the clock and the schedules.";
437
438/// One project a boot opens, and the kick its last state earns: the
439/// restart note when a turn was cut off, nothing when the seat was parked.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct BootWaveTarget {
442    pub project_name: String,
443    pub resume_kick: Option<String>,
444}
445
446/// The restart note a boot spawn hands its seat: only a spawn carrying a
447/// stored id to resume delivers one, so a first boot or `--new` clears
448/// whatever a previous spawn left. A resume the CLI refuses still takes
449/// the note, whose wording does not depend on history.
450fn resume_kick_for(resumed: bool, requested: Option<String>) -> Option<String> {
451    if resumed { requested } else { None }
452}
453
454/// One enqueue onto the workspace-level worker-kick channel
455/// (#259). Built by `maybe_kick_worker_on_connected` and
456/// `maybe_kick_lead_on_connected` (and any future kick site); drained by
457/// the workspace's
458/// `start_kick_dispatcher` task, which fires one `Command::Prompt`
459/// per `KICK_DISPATCH_INTERVAL` tick. Same payload shape as the
460/// existing `Command::Prompt` carries (no attachments are ever
461/// part of a kick prompt - kicks are pure text).
462#[derive(Debug)]
463pub(crate) struct KickRequest {
464    pub slot: SessionSlot,
465    pub prompt_body: String,
466}
467
468/// Per-session chip the Projects pane renders next to each row.
469/// Carries the assigned account display name + the visual-state
470/// category derived by `Workspace::session_chip_for`.
471#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
472#[serde(rename_all = "snake_case")]
473pub struct SessionChipInfo {
474    /// Account `display_name` from forge.toml `[[accounts]]`.
475    pub account_name: String,
476    /// Render category: drives the chip's color + optional prefix
477    /// glyph.
478    pub state: SessionChipState,
479}
480
481/// Visual category for a session chip. The renderer maps these to
482/// foreground colors + (for `Bailed` alone) a leading `⚠ ` glyph.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum SessionChipState {
486    /// Account is Ready and within budget. DIM foreground.
487    Normal,
488    /// Account is Ready but at least one usage window (5h or weekly)
489    /// is currently at the cap. Yellow foreground signals "still
490    /// spawns but expect throttling until the window resets." When
491    /// every account that declares the model is capped the walk takes
492    /// one anyway, so the chip shows this state.
493    AtCap,
494    /// Account Bailed on an auth failure (rejected or expired
495    /// credentials). Red foreground + `⚠ ` prefix; the repair is an
496    /// env edit plus a restart.
497    Bailed,
498    /// Account Bailed on a transient failure (rate limit, unreachable
499    /// endpoint, malformed response). Warning yellow, no glyph - the
500    /// same split the account rows render; the pollers heal it.
501    Degraded,
502    /// Every account serving the project is inside a rotation cooldown, so
503    /// a spawn is refused until the soonest reset. Unlike `AtCap` the walk
504    /// will not take this account now; the window that is spent and when
505    /// it lifts come from the account's own reading.
506    RateLimited,
507}
508
509/// What a session is waiting on a person for. The kind a needs-you row
510/// names, since "asked you a question" and "a permission prompt is
511/// waiting" are different asks with the same mark.
512///
513/// **One variant per ask source, and every source is here.** A question and
514/// a permission park in the session's pending set; a Slack draft, the
515/// browser, terminal and document hand-offs, a settings write and a vault
516/// call each park in a registry of the workspace's own. The split between
517/// those places is what let a terminal hand-off draw in the dock and mark
518/// nothing (#2954), the third time a kind was missed;
519/// [`Workspace::pending_interaction`] reads every source through one order.
520/// A new ask kind fails the build in the guard test's census until it is
521/// named there, and the census is what parks one of each and asserts the
522/// needs-you path answers it - so a kind cannot be named without being
523/// covered.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
525#[serde(rename_all = "snake_case")]
526pub enum PendingInteractionKind {
527    Question,
528    Permission,
529    SlackDraft,
530    BrowserHandOff,
531    TerminalHandOff,
532    DocumentHandOff,
533    SettingsWrite,
534    Vault,
535}
536
537/// The dedupe key for a delivered Slack message: one destination is
538/// (project, owner, conversation, ts, edited), so a lead and a worker in
539/// one project never starve each other as "already delivered". The edit
540/// ts is part of the identity because an edit keeps the message's own
541/// `ts`: without it a correction reads as a re-run of the delivery it
542/// corrects and never reaches the session.
543pub(crate) type SlackDeliveryKey = (String, Option<String>, String, String, Option<String>);
544
545/// Multi-session orchestrator. Owns the project catalog snapshot
546/// loaded from `<config_dir>/forge.toml` and the pool of currently
547/// One composed Slack message held for a decision: the session that asked,
548/// the draft itself, and the sender the blocked Slack handler awaits.
549///
550/// The draft rides beside the sender for the same reason the permission and
551/// question requests do - the stream carries it once, so a view that attached
552/// after it landed has nothing else to draw the dock from.
553pub(crate) type ParkedSlackDraft = (
554    SessionSlot,
555    forge_primitives::slack::SlackDraft,
556    tokio::sync::oneshot::Sender<forge_primitives::slack::SlackDraftAnswer>,
557);
558
559/// One parked browser hand-off: the session that asked, the hand-off itself,
560/// and the sender the blocked `browser_hand_off` handler awaits.
561///
562/// It rides beside the sender for the same reason the Slack draft does - the
563/// update carries it once, so a view that attached after it landed has nothing
564/// else to draw the dock from - and the sender is what makes the wait end: no
565/// clock ends it, only an answer, or the handler's own drop.
566pub(crate) type ParkedBrowserHandOff = (
567    SessionSlot,
568    forge_primitives::browser::HandOff,
569    tokio::sync::oneshot::Sender<forge_primitives::browser::HandOffEnding>,
570);
571
572/// One parked terminal hand-off: the session that asked, the hand-off itself,
573/// and the sender the blocked `terminal__hand_off` handler awaits. No clock
574/// ends it - only an answer, or the handler's own drop.
575pub(crate) type ParkedTerminalHandOff = (
576    SessionSlot,
577    forge_primitives::terminal::TerminalHandOff,
578    tokio::sync::oneshot::Sender<forge_primitives::browser::HandOffEnding>,
579);
580
581/// A seat's occupant that died, held until a reader has been shown the
582/// seat AND the recovery has announced itself - so a crash nobody watched
583/// is still readable when someone looks, and the recovery that answers it
584/// is announced exactly once. The two are spent independently: showing the
585/// seat takes the row's trace, and the connect takes the announcement,
586/// whichever comes first.
587struct SeatDeath {
588    /// The session that ended.
589    occupant: SessionId,
590    /// What the seat's failure said when it died, which the row keeps
591    /// drawing after the recovering connect clears the failure itself.
592    reason: String,
593    /// Whether the recovery's connect has announced it already.
594    announced: bool,
595    /// Whether a reader has been shown the seat since.
596    shown: bool,
597}
598
599/// Where a dead seat's heal has got to while it is between attempts: the
600/// attempt number the row names, and when the next one fires.
601#[derive(Clone, Copy, Debug, PartialEq, Eq)]
602pub struct HealProgress {
603    pub attempt: u32,
604    pub next_at: SystemTime,
605}
606
607/// Which operation holds a composed path. Re-entrancy needs the same
608/// owner AND the same operation, so a despawn cannot slip into a resume's
609/// claim on one label and delete the tree that resume already ensured.
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
611pub(crate) enum WorktreeOp {
612    Spawn,
613    Despawn,
614}
615
616#[derive(Debug, Clone)]
617struct WorktreeClaim {
618    project_key: ProjectKey,
619    label: String,
620    op: WorktreeOp,
621    count: usize,
622}
623
624/// A held claim on a composed worker-worktree path. Released on drop, so
625/// every early return lets the next spawn or despawn through.
626#[derive(Debug)]
627pub(crate) struct WorktreeClaimGuard {
628    claims: Arc<parking_lot::Mutex<HashMap<std::path::PathBuf, WorktreeClaim>>>,
629    path: std::path::PathBuf,
630}
631
632impl Drop for WorktreeClaimGuard {
633    fn drop(&mut self) {
634        let mut claims = self.claims.lock();
635        let Some(claim) = claims.get_mut(&self.path) else {
636            return;
637        };
638        // The entry cannot change hands while this guard lives: it only
639        // leaves the map when every guard has dropped.
640        claim.count = claim.count.saturating_sub(1);
641        if claim.count == 0 {
642            claims.remove(&self.path);
643        }
644    }
645}
646
647/// A test's pause inside [`Workspace::live_seats`]: the sender fires once a
648/// capture is held, and a message on the receiver releases it.
649#[cfg(any(test, feature = "testing"))]
650type LiveSeatsGate = (std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>);
651
652/// spawned [`forge_agent::Agent`] handles, one per active session.
653///
654/// Construct via [`Workspace::new`]; consume via
655/// [`Workspace::get_agent_handle`]; drain on exit via
656/// [`Workspace::shutdown`].
657pub struct Workspace {
658    /// Retained so error messages can reference the `forge.toml`
659    /// path the workspace was constructed from, and so the per-account
660    /// config-dir binding can re-resolve from here.
661    config_dir: PathBuf,
662    /// `pub(crate)` so the impl block in [`crate::gotify`] can read the
663    /// `[gotify]` section. Swapped whole by [`Workspace::reload_config`],
664    /// which is why readers take an owned snapshot rather than a reference.
665    pub(crate) config: parking_lot::RwLock<Arc<LoadedConfig>>,
666    /// The effective-config document, rebuilt on every applied reload and
667    /// shared by every socket connection. `None` until the server names the
668    /// build that carries it.
669    settings: tokio::sync::watch::Sender<Option<Arc<forge_primitives::SettingsDocument>>>,
670    /// The forge version the document carries, set once by the server at
671    /// boot. A workspace with no server (tests) leaves it empty.
672    settings_version: std::sync::OnceLock<String>,
673    /// One settings save at a time: the file is read, edited, validated and
674    /// replaced under this lock, so two saves cannot interleave.
675    settings_write: parking_lot::Mutex<()>,
676    /// Odd while a reload is swapping state, even when settled. A spawn
677    /// reads it before resolving and re-checks after, so a resolution that
678    /// straddled the swap retries rather than pairing an old project pin
679    /// with the new pool.
680    reload_generation: std::sync::atomic::AtomicU64,
681    /// Held across a reload's odd-to-even swap, so a spawn that sees an odd
682    /// generation waits on this rather than spinning on the counter.
683    reload_gate: std::sync::Mutex<()>,
684    /// The `[systemone]` decision client, built at boot when the section
685    /// is present and enabled; the session MCP servers are handed a
686    /// facade over it. `None` keeps the systemone tools out of every
687    /// session. Rebuilt by [`Workspace::reload_config`].
688    pub(crate) systemone: parking_lot::RwLock<Option<Arc<forge_system_one::SystemOneClient>>>,
689    /// The `[vault]` credential session, built at boot when the section is
690    /// present; the family's tools are handed a facade over it. `None` keeps
691    /// the vault tools out of every session. Rebuilt by
692    /// [`Workspace::reload_config`] only when the section itself changed, so
693    /// an unrelated settings save never locks a live vault.
694    pub(crate) vault: parking_lot::RwLock<Option<Arc<crate::vault::VaultSession>>>,
695    /// The per-seat voice service, built on first use: its sink and its note
696    /// dispatch hold a `Weak<Workspace>`, which does not exist while the
697    /// value is still being constructed.
698    pub(crate) voice: std::sync::OnceLock<Arc<crate::voice::VoiceService>>,
699    /// The last voice preview's render failure, for the settings card to
700    /// draw. Cleared when the next preview is asked for.
701    pub(crate) voice_preview_error: std::sync::Mutex<Option<String>>,
702    /// Catalog of sessions per project. Populated by the background
703    /// catalog scan once [`Workspace::start_catalog_scan`] runs;
704    /// mutated in-place by [`Workspace::record_connected_session`]
705    /// each time a freshly spawned session reaches `Connected`, so the
706    /// Projects pane's drilldown stays current without forcing a full
707    /// disk re-scan. Held under a Mutex because multiple in-process
708    /// tasks (the pane render, the connect-flow event handler) reach
709    /// for it across `await` points; `Arc` so the scan task can swap
710    /// its contents without holding an `Arc<Workspace>` cycle.
711    catalog: Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>,
712    /// forge's machine-local app-support base, resolved once at
713    /// construction: the single-instance lock, the durable store and the
714    /// dictation catalogue cache all sit under it. `None` means boot
715    /// resolution failed (already warned there); hard rule #14 forbids a
716    /// cwd fallback.
717    pub(crate) app_support: Option<PathBuf>,
718    /// Live Agents keyed by the session slot. `parking_lot::Mutex` so the
719    /// public methods can take `&self`. `pub(crate)` so sibling
720    /// modules (`spawn::handle_deliver_worker_prompt_to_lead`,
721    /// `mcp::workers::facade::ProdWorkerFacade`) can probe pool
722    /// membership for lead-delivery gating without an extra method
723    /// wrapper.
724    pub(crate) pool: Mutex<HashMap<SessionSlot, PooledAgent>>,
725    /// A stand-in for the handle a cold spawn would create. The settings a
726    /// spawn launches with are otherwise unobservable: a pooled slot never
727    /// reaches the launch, and a cold one ends in a real subprocess. `None`
728    /// in production and in every test that does not install one, so a
729    /// spawn runs always.
730    #[cfg(any(test, feature = "testing"))]
731    pub(crate) test_spawn_handle: Mutex<Option<forge_agent::AgentHandle>>,
732    /// The `forge` MCP server the last spawn built, kept because a stand-in
733    /// replaces `Agent::spawn`, so the server it would have been handed is
734    /// otherwise dropped unobserved -
735    /// and a test that has to drive a tool through the spawn's OWN
736    /// composition (which facade it was built with) needs it.
737    #[cfg(any(test, feature = "testing"))]
738    pub(crate) test_spawn_server: Mutex<Option<forge_sdk::mcp::server::McpServer>>,
739    /// The account state map, owned by the gateway and reached through
740    /// its pool. It carries account health state updated on every spawn
741    /// and refreshed by the in-memory usage poller, and it is what the
742    /// gateway's selection walk reads to choose an account.
743    pub(crate) accounts: Arc<forge_gateway::AccountPool>,
744    /// Dictation preflight: the per-model progress the launchpad
745    /// renders, the flag Escape sets, and the loaded engine held for
746    /// the run. Populated by `start_dictate_preflight`; inert when
747    /// `[dictate] enabled` is false.
748    pub(crate) dictate: Arc<crate::dictate::DictateState>,
749    /// Live dictation: the takes being fed frames and the submitted
750    /// takes still awaiting a transcript. Driven by
751    /// `Command::DictateStream` / `Command::DictateStop`.
752    pub(crate) dictate_runtime: Mutex<crate::dictate::DictateRuntime>,
753    /// The server's browser fleet, installed once at boot by forge-server.
754    /// `None` is a workspace with no browser host (tests, a view): the
755    /// browser tools then name their refusal.
756    pub(crate) browser_host: std::sync::OnceLock<Arc<dyn crate::browser::BrowserHost>>,
757    /// **Captures do not overlap**: the generation ranks whole captures, so
758    /// the mint and the reads it labels happen under this one lock.
759    live_seats_lock: std::sync::Mutex<()>,
760    /// The generation each [`Self::live_seats`] capture takes, so the fleet
761    /// can drop a snapshot older than the one it last applied.
762    live_seats_generation: std::sync::atomic::AtomicU64,
763    /// A test's hold between [`Self::live_seats`]'s mint and its reads, so a
764    /// capture can be opened across a seat change. Test-only.
765    #[cfg(any(test, feature = "testing"))]
766    live_seats_gate: Mutex<Option<LiveSeatsGate>>,
767    /// How many client connections are attached, noted by the transport per
768    /// connection. A document hand-off reads it before claiming an open: only
769    /// a client that draws a document viewer is counted.
770    pub(crate) attached_clients: std::sync::atomic::AtomicUsize,
771    /// The parked document hand-offs, under their ids: each holds the
772    /// awaiting tool's sender until the reader's answer releases it.
773    pub(crate) document_handoffs:
774        parking_lot::Mutex<HashMap<uuid::Uuid, crate::documents::ParkedDocument>>,
775    /// The model catalogue: the last fetched feed and what the last check
776    /// did. Loaded from its cache at boot, refreshed by
777    /// `start_dictate_catalogue` and `Command::DictateCatalogueCheck`, and
778    /// read by the models page through `dictate_models`.
779    pub(crate) dictate_catalogue: Mutex<crate::catalogue::CatalogueState>,
780    /// Where the last model install got to. Set by
781    /// `Command::DictateInstall`, read by the models page through
782    /// `dictate_install`.
783    pub(crate) dictate_install: Mutex<crate::install::InstallState>,
784    /// How a plugin action reaches `claude` plus the disk scan the actions
785    /// and the Extensions read share. Real in production; a test swaps it
786    /// so a whole action runs without shelling out or touching `~/.claude`.
787    pub(crate) plugin_cli: Mutex<crate::plugins::PluginCli>,
788    /// The last plugin update run or check, in flight or finished: what a
789    /// view attaching mid-run reads, and the guard that keeps one run at a
790    /// time (a second press is refused while this is unfinished).
791    pub(crate) plugin_run: Mutex<Option<forge_primitives::plugins::PluginUpdateRun>>,
792    /// One plugin CLI action at a time; cleared when its task ends, panic
793    /// included, so a crashed task never wedges the surface.
794    pub(crate) plugin_action_running: std::sync::atomic::AtomicBool,
795    /// Where the last model activation got to. Set by
796    /// `Command::DictateActivate`, read by the models page through
797    /// `dictate_activate`.
798    pub(crate) dictate_activate: Mutex<crate::install::ActivateState>,
799    /// Where the last bench got to, and the flag its Stop sets. Read by
800    /// the models page through `dictate_bench` and `bench_results`.
801    pub(crate) dictate_bench: Mutex<crate::bench::BenchState>,
802    pub(crate) dictate_bench_cancel: std::sync::atomic::AtomicBool,
803    /// Where the speaking loop's bench got to, and the flag its Stop sets.
804    /// Read by the models page through `speaking_bench` and its results.
805    pub(crate) speaking_bench: Mutex<crate::bench::SpeakingBenchState>,
806    pub(crate) speaking_bench_cancel: std::sync::atomic::AtomicBool,
807    /// One bench at a time across both directions: a single claim taken
808    /// before either state is written, so two presses cannot both pass the
809    /// check and load two engines.
810    pub(crate) bench_claim: std::sync::atomic::AtomicBool,
811    /// The last read-aloud write's failure, when there was one: a stop
812    /// whose write fails has no dispatch left to answer, so the page reads
813    /// it here instead. Cleared when the next recording starts.
814    pub(crate) read_aloud_error: Mutex<Option<String>>,
815    /// A test's own catalogue source, so a check can run against a
816    /// loopback server instead of GitHub. Not present in production
817    /// builds.
818    #[cfg(any(test, feature = "testing"))]
819    pub(crate) test_catalogue_source: Mutex<Option<forge_dictate::catalogue::CatalogueSource>>,
820    /// A test's own cleanup source, on the same terms.
821    #[cfg(any(test, feature = "testing"))]
822    pub(crate) test_cleanup_source: Mutex<Option<forge_dictate::cleanup::CleanupSource>>,
823    /// A test's own catalogue directory, so a check never writes to the
824    /// real app-support dir. Not present in production builds.
825    #[cfg(any(test, feature = "testing"))]
826    pub(crate) test_catalogue_dir: Mutex<Option<std::path::PathBuf>>,
827    /// A test's own read-aloud set directory, so an arming never writes to
828    /// the real app-support dir. Not present in production builds.
829    #[cfg(any(test, feature = "testing"))]
830    pub(crate) test_read_aloud_dir: Mutex<Option<std::path::PathBuf>>,
831    /// Fan-out [`SessionUpdate`] sender: every producer inside the workspace
832    /// holds a clone, and it hands each update to every subscriber.
833    /// `pub(crate)` so the impl block in [`crate::crons`] can reach it.
834    pub(crate) update_tx: UpdateFanout,
835    /// Per-session [`Command`] sender map. Populated when
836    /// [`Self::get_agent_handle`] spawns the first `SessionTask` for a
837    /// key; cleared on [`Self::release_session_with_cascade`] and [`Self::shutdown`].
838    #[cfg(any(test, feature = "testing"))]
839    pub(crate) command_senders: Mutex<HashMap<SessionSlot, mpsc::UnboundedSender<Command>>>,
840    #[cfg(not(any(test, feature = "testing")))]
841    command_senders: Mutex<HashMap<SessionSlot, mpsc::UnboundedSender<Command>>>,
842    /// Per-session teardown-completion signals, minted beside the command
843    /// channel. The task marks its slot done as it drops - after its exit
844    /// path has reaped the subprocess - and every waiter subscribes rather
845    /// than taking the one receiver: the despawn holds its worktree removal
846    /// until then (#1767), and a shutdown holds its exit until every slot
847    /// answers. An entry stays after its task fires and is replaced when
848    /// the slot respawns, so the map holds one small sender per slot this
849    /// process has ever spawned - the price of a late reader still finding
850    /// the answer.
851    session_teardowns: Mutex<HashMap<SessionSlot, tokio::sync::watch::Sender<bool>>>,
852    /// Set before a shutdown tears anything down. From then on no session
853    /// may start - the boot wave, a cron tick and the socket all race the
854    /// drain - and the mint refuses under the same lock the drain takes,
855    /// which is what keeps a late spawn out of the awaited set.
856    shutting_down: std::sync::atomic::AtomicBool,
857    /// Per-project list of live worker sessions. In-memory only -
858    /// wiped on forge restart by design (workers are ephemeral at the
859    /// forge UI level; their JSONLs persist on disk). Mutated via
860    /// `insert_live_worker` / `remove_latest_worker` / `drain_live_workers`.
861    live_workers: Mutex<HashMap<ProjectKey, Vec<crate::mcp::workers::types::WorkerEntry>>>,
862    /// The per-lead clock of the injected board sweep, keyed by the
863    /// lead's slot. In memory only, so a restart re-arms every lead:
864    /// the sweep is a moment, not a schedule the store should own.
865    /// `pub(crate)` so the impl block in [`crate::idle_sweep`] can
866    /// reach it.
867    pub(crate) idle_sweeps: Mutex<HashMap<SessionSlot, crate::idle_sweep::SweepClock>>,
868    /// One claim per composed worker-worktree path, held while a spawn's
869    /// ensure/insert or a despawn's removal is in flight. The live
870    /// registry is per project; this is the repository-wide one, so two
871    /// projects composing the same path cannot both act on it.
872    worktree_claims: Arc<parking_lot::Mutex<HashMap<PathBuf, WorktreeClaim>>>,
873    /// The `(project, label)` pairs whose despawn cleanup is still running.
874    /// The label's worktree is being torn down for that whole window, so a
875    /// spawn taking the label now would enter a directory the removal is
876    /// about to delete. Held from the cleanup's handoff until it finishes,
877    /// and released by its own `Drop` so a panicking cleanup cannot leave a
878    /// label blocked forever. In-memory only, like the rest of the worker
879    /// registry.
880    despawn_cleanups: Mutex<HashSet<(ProjectKey, String)>>,
881    /// Why the last spawn or connection for a slot failed, keyed by slot.
882    /// Survives the failed attempt itself, which is what a `DomainSession`
883    /// cannot: a connection failure releases the session, so by the time a
884    /// view reads the slot there is nothing left to say the attempt ever
885    /// happened. Written before that release and cleared when a session
886    /// for the slot comes up again, so it reports an unrecovered failure
887    /// rather than a history.
888    spawn_failures: Mutex<HashMap<SessionSlot, String>>,
889    /// The occupant that died on a slot, keyed by slot, held until a
890    /// reader has been shown the seat again or a new turn commits there.
891    /// [`Self::spawn_failures`] says an attempt failed; this says the seat
892    /// HAD something running and it ended under its own process, which is
893    /// what lets the recovery be announced as a resume or a restart under
894    /// a new id rather than reading as a plain connect - and what keeps
895    /// the failure and its reason on the row until someone has looked.
896    deaths: Mutex<HashMap<SessionSlot, SeatDeath>>,
897    /// Where each dead seat's heal has got to while it is restarting,
898    /// keyed by slot. The healing supervisor writes it between attempts;
899    /// every row that draws the seat reads it, and a lead has no worker
900    /// entry for the state to live on instead.
901    heal_progress: Mutex<HashMap<SessionSlot, HealProgress>>,
902    /// The auto-heal supervisor, built on the first death that reaches it.
903    /// It holds a `Weak<Workspace>`, which does not exist while the value
904    /// is still being constructed.
905    healing: std::sync::OnceLock<Arc<crate::healing::Healing>>,
906    /// The seats a view is showing, each with the working-tree scan loop it
907    /// runs. Empty is the quiet case: a seat nobody holds is not scanned.
908    pub(crate) held_work_seats: crate::work::HeldSeats,
909    /// Shared [`DomainSession`] handles, one per active `SessionTask`.
910    /// `pub(crate)` so crate-internal spawn and delivery paths can
911    /// reach a session's `DomainSession` directly.
912    pub(crate) domain_handles: Mutex<HashMap<SessionSlot, Arc<Mutex<DomainSession>>>>,
913    /// The session that submitted the reviews on a `(project, branch)` -
914    /// the target for a worker's review-activity notice. Set by
915    /// [`Self::submit_review`]; latest submit wins (the reviewer is one
916    /// human whose session may rekey across a resume). `pub(crate)` so
917    /// the impl block in [`crate::review`] can reach it.
918    pub(crate) review_origin: Mutex<HashMap<(String, String), SessionSlot>>,
919    /// Review actions a caller took during its current turn, keyed by the
920    /// caller's [`SessionSlot`]. Appended by [`Self::review_reply`] /
921    /// [`Self::review_resolve`] and drained into one notice per review at
922    /// the caller's turn end ([`Self::drain_review_activity`]).
923    /// `pub(crate)` so the impl block in [`crate::review`] can reach it.
924    pub(crate) review_activity:
925        Mutex<HashMap<SessionSlot, Vec<crate::mcp::review::ReviewActivity>>>,
926    /// The process's subsystem supervisor: every long-lived background task
927    /// registers here, so a death is named and a restart is bounded.
928    pub(crate) supervisor: Arc<forge_supervise::Supervisor>,
929    /// Set the first time [`Self::start_dictate_preflight`] runs, so a
930    /// second call cannot spawn a second prewarm over the same models. Set
931    /// before the `[dictate] enabled` gate: the flag reads "the boot wave
932    /// asked for the prewarm", which is the one call a test can watch
933    /// without a 3 GB fetch.
934    dictate_preflight_started: std::sync::atomic::AtomicBool,
935    /// Records that the boot wave asked for the prewarm, set before the
936    /// `[tts] enabled` gate: the flag reads "the wave asked", which is the
937    /// one call a test can watch without the weights. It is not a dispatch
938    /// guard; the guard only stops a second load of the same engine.
939    pub(crate) voice_prewarm_started: std::sync::atomic::AtomicBool,
940    /// Set once the prewarm is dispatched, after the enabled gate, so a
941    /// second call cannot spawn a second load of the same engine.
942    pub(crate) voice_prewarm_dispatched: std::sync::atomic::AtomicBool,
943    /// The gateway's listener + session bindings. Children are stamped
944    /// with a base URL that names this listener, and every spawn
945    /// registers here.
946    pub(crate) gateway: Arc<forge_gateway::forward::Gateway>,
947    /// `true` once the listener's port is bound. The boot gate stays
948    /// shut until it is, because every session's base URL names it.
949    gateway_ready: std::sync::atomic::AtomicBool,
950    /// The port the listener binds: `[gateway] port` or the default.
951    gateway_port: u16,
952    /// The listener's bound URL, stored when the port is taken. The
953    /// stamp reads THIS, not the config value, so the base URL a child
954    /// is stamped with is structurally the one that answers.
955    gateway_url: Mutex<Option<String>>,
956    /// The bind failure, if the listener could not start. Surfaced by
957    /// the launchpad's gate label; `None` while loading or bound.
958    gateway_bind_error: Mutex<Option<String>>,
959    /// Sender half of the worker-kick channel (#259). Cloned via
960    /// [`Self::enqueue_kick`] by `maybe_kick_worker_on_connected`
961    /// (and any future kick site). The matching receiver lives in
962    /// `kick_dispatcher_rx_slot` until [`Self::start_kick_dispatcher`]
963    /// takes it out and spawns the drainer task.
964    kick_dispatcher_tx: mpsc::UnboundedSender<KickRequest>,
965    /// Single-take slot holding the matching receiver.
966    /// [`Self::start_kick_dispatcher`] pops it on first call and
967    /// hands it to the drainer task; subsequent calls find `None`
968    /// and no-op (mirrors `start_usage_poller`'s guard against
969    /// duplicate spawns).
970    kick_dispatcher_rx_slot: Mutex<Option<mpsc::UnboundedReceiver<KickRequest>>>,
971    /// Single-instance lock file held open for the process lifetime.
972    /// `Workspace::new` takes an exclusive flock on a machine-local
973    /// lockfile keyed by the config dir (see [`crate::single_instance`])
974    /// so a second forge on the same config dir refuses to start; the
975    /// flock releases when this `File` drops (Workspace teardown / process
976    /// exit / crash). Held purely for that side effect - never read.
977    /// `None` in `testing_stub` and on the degraded acquire path.
978    _single_instance_lock: Option<std::fs::File>,
979    /// Durable forge crons (`mcp__forge__cron`). In-memory working set,
980    /// loaded at boot from the machine-local redb store ([`crate::store::cron`])
981    /// and persisted back after every mutation - create/delete, the
982    /// scheduler's fire-advance, and boot catch-up - through the one
983    /// [`Workspace::with_crons_mut`] path. The single-instance guard keeps
984    /// this the only process holding that store, so this mutex alone
985    /// serialises writes.
986    /// `pub(crate)` so the impl block in [`crate::crons`] can reach it.
987    pub(crate) crons: Mutex<Vec<forge_primitives::CronEntry>>,
988    /// Set at boot when the durable crons could not be loaded: the store
989    /// could not open, or its read failed. The in-memory set is empty while
990    /// rows may still be on disk, so it is not the registry. `cron__list`
991    /// refuses on it rather than answering an empty array a caller would read
992    /// as "you have no crons".
993    ///
994    /// It gates `cron__list` only: under the same failed load `delete_cron`
995    /// still answers `NoSuchCron` from the empty set, and `create_cron` still
996    /// promises durability while `persist_cron` returns quietly. Both predate
997    /// this cut and the charter's list-then-delete flow is gated by the list
998    /// refusal; the siblings are #2943.
999    pub(crate) crons_load_failed: std::sync::atomic::AtomicBool,
1000    /// The live task list (`mcp__forge__tasks`). In-memory working set,
1001    /// loaded from the machine-local store at boot and persisted back
1002    /// after every mutation through the one [`Workspace::with_tasks_mut`]
1003    /// path. `pub(crate)` so the impl block in [`crate::tasks`] can reach
1004    /// it.
1005    pub(crate) tasks: Mutex<Vec<forge_primitives::tasks::Task>>,
1006    /// Payloads addressed to a slot that had no live session when they
1007    /// arrived - a peer prompt, a fired cron, a Gotify notification, a
1008    /// Slack message - keyed by `(org, project, label)` (`None` = lead),
1009    /// the triple the `sessions` table uses. The slot's session drains
1010    /// its own bucket on first `Connected`. `pub(crate)` so the impl
1011    /// block in [`crate::parked`] can reach it.
1012    pub(crate) parked_by_slot: Mutex<crate::parked::ParkedMap>,
1013    /// The brief a handoff wrote, waiting for the successor it was written
1014    /// for to connect. Taken by the session task on that connect, which is
1015    /// what makes the brief the new occupant's first turn. `pub(crate)` so
1016    /// the impl block in [`crate::handoff`] can reach it.
1017    pub(crate) pending_handoffs:
1018        Mutex<std::collections::HashMap<SessionSlot, crate::handoff::PendingBrief>>,
1019    /// Seats whose occupant is being swapped by a handoff: from the moment
1020    /// the brief is built until the successor connects, a prompt aimed at one
1021    /// is held rather than run by the session leaving.
1022    pub(crate) swapping: Mutex<std::collections::HashMap<SessionSlot, u64>>,
1023    /// Mints a swap's token, so a build that lost the seat cannot spawn.
1024    pub(crate) swap_epochs: std::sync::atomic::AtomicU64,
1025    /// Prompts held for a swapping seat, delivered to its successor after the
1026    /// brief, in arrival order.
1027    pub(crate) held_prompts:
1028        Mutex<std::collections::HashMap<SessionSlot, Vec<crate::handoff::HeldPrompt>>>,
1029    /// Active Gotify subscriptions (`mcp__forge__gotify`). The set the
1030    /// stream matches each inbound message against. Durable ones (lead /
1031    /// team-worker) are also persisted to `db` and reloaded here
1032    /// at boot; ephemeral ad-hoc-worker ones live only in memory.
1033    /// `pub(crate)` so the impl block in [`crate::gotify`] can reach it.
1034    pub(crate) gotify_subs: Mutex<Vec<forge_primitives::GotifySubscription>>,
1035    /// Machine-local redb store. Backs durable crons ([`crate::store::cron`]),
1036    /// Gotify and Slack subscriptions ([`crate::store::gotify`],
1037    /// [`crate::store::slack`]) and persisted sessions
1038    /// ([`crate::store::sessions`]). `None` when the DB couldn't
1039    /// open (degrade to in-memory-only, no persistence) or in
1040    /// `testing_stub`. `pub(crate)` so the impl block in
1041    /// [`crate::review`] can reach it.
1042    pub(crate) db: Arc<Mutex<Option<crate::store::Db>>>,
1043    /// Whether the boot catalog scan has populated `catalog`. The scan
1044    /// runs in the background off the boot path; the Projects pane and
1045    /// the session lookups read the catalog it fills.
1046    catalog_loaded: Arc<std::sync::atomic::AtomicBool>,
1047    /// How [`Workspace::refresh_pricing`] fetches the litellm table. The
1048    /// real fetch in production; a test constructor scripts it so the
1049    /// refill is drivable without the network.
1050    pricing_fetcher: PricingFetcher,
1051    /// Idempotence guard for [`Workspace::start_catalog_scan`].
1052    catalog_scan_started: std::sync::atomic::AtomicBool,
1053    /// The installed and npm-published `claude` CLI versions, merged from
1054    /// every probe so far. One answer for every viewer, so it lives here
1055    /// rather than in the view that happened to probe first; `None` until
1056    /// the boot probe lands.
1057    cli_version: Arc<Mutex<Option<CliVersionInfo>>>,
1058    /// The last fatal error, held so a view that was not subscribed when it
1059    /// fired can still learn of it: the update carries no state of its own
1060    /// and nothing else in the core records it.
1061    last_fatal_error: Mutex<Option<forge_primitives::error::AppError>>,
1062    /// The statuspage's last answer, held so a view that attached after the
1063    /// probe landed can read it rather than having missed the one update
1064    /// that carried it. `None` until the probe answers.
1065    service_status: Arc<Mutex<Option<ServiceIssue>>>,
1066    /// Idempotence guard for the service-status probe.
1067    service_status_probe_started: std::sync::atomic::AtomicBool,
1068    /// Whether the Gotify stream is currently connected. Set by the
1069    /// subsystem pump on `Connected` / `Disconnected`, and cleared by the
1070    /// stop path and the pump's exit guard; read by the Inspector's status
1071    /// line.
1072    /// `pub(crate)` so the impl block in [`crate::gotify`] can reach it.
1073    pub(crate) gotify_connected: Mutex<bool>,
1074    /// Gotify application name -> numeric appid map, fetched from the
1075    /// server's `/application` list on subsystem start and refreshed on
1076    /// each reconnect. Resolves an `application` name filter to the appid
1077    /// inbound messages carry, and the reverse lookup for the envelope.
1078    /// `pub(crate)` so the impl block in [`crate::gotify`] can reach it.
1079    pub(crate) gotify_app_index: Mutex<HashMap<String, u64>>,
1080    /// Shutdown handle for the running Gotify subsystem. `Some` while the
1081    /// pump runs; dropping/sending stops it, and the pump's own exit guard
1082    /// clears it when the task ends. `None` when idle (no subscriptions) or
1083    /// unconfigured. Guards against double-starting.
1084    /// `pub(crate)` so the impl block in [`crate::gotify`] can reach it.
1085    pub(crate) gotify_subsystem: Mutex<Option<GotifySubsystem>>,
1086    /// One Slack client per `[[slack]]` entry, resolved at boot. Empty
1087    /// keeps the Slack connector dormant. `pub(crate)` so the impl
1088    /// blocks in [`crate::slack`] and `crate::mcp::slack` can reach it.
1089    pub(crate) slack: Arc<crate::slack::SlackWorkspaces>,
1090    /// Active Slack subscriptions (`mcp__forge__slack`). The set each
1091    /// workspace's pump sweeps. Durable ones are also persisted to `db`
1092    /// and reloaded here at boot. `pub(crate)` so the impl block in
1093    /// [`crate::slack`] can reach it.
1094    pub(crate) slack_subs: Mutex<Vec<forge_primitives::slack::SlackSubscription>>,
1095    /// Shutdown handles for the running Slack pumps, one per workspace
1096    /// label. Slack has a pump per `[[slack]]` entry where Gotify has a
1097    /// single server and a single handle.
1098    pub(crate) slack_subsystem:
1099        Mutex<std::collections::BTreeMap<String, tokio::sync::oneshot::Sender<()>>>,
1100    /// Per-workspace pump liveness for the Inspector's SLACK section.
1101    /// Keyed by label for the same reason.
1102    pub(crate) slack_connected: Mutex<std::collections::BTreeMap<String, bool>>,
1103    /// Conversations forge has a post in flight into, keyed
1104    /// `(workspace, the resolved conversation)`, each entry a stamp per
1105    /// live call: one post call with its ledger write, or one upload's
1106    /// shares read. The sweep skips a conversation while any stamp is
1107    /// within budget, because the message can reach history before its ts
1108    /// is ledgered; a stamp past the request timeout is expired, never
1109    /// obeyed, so a stalled call cannot silence the conversation.
1110    pub(crate) slack_inflight:
1111        Mutex<std::collections::HashMap<(String, String), Vec<crate::slack::SlackPostStamp>>>,
1112    /// The authenticated user's id per workspace, resolved once by the
1113    /// boot `auth.test` and needed to recognise `<@U...>` mentions.
1114    pub(crate) slack_user_ids: Mutex<std::collections::BTreeMap<String, String>>,
1115    /// Display names resolved per `(workspace, user id)`, so a message from
1116    /// a known author costs no `users.info` call.
1117    pub(crate) slack_user_names: Mutex<std::collections::BTreeMap<(String, String), String>>,
1118    /// `(workspace, user id)` pairs whose failed lookup has been reported, so
1119    /// a persistent failure says so once rather than on every tick.
1120    pub(crate) slack_author_failures: Mutex<std::collections::HashSet<(String, String)>>,
1121    /// `(workspace, user id)` pairs whose failed DRAFT tag lookup has been
1122    /// reported. Kept apart from the author set so a draft's miss does not
1123    /// spend the once-per-author line the sweep relies on.
1124    pub(crate) slack_draft_user_failures: Mutex<std::collections::HashSet<(String, String)>>,
1125    /// `(workspace, conversation, parent ts)` whose failed thread read has
1126    /// been reported, so a persistent failure says so once rather than on
1127    /// every park.
1128    pub(crate) slack_thread_failures: Mutex<std::collections::HashSet<(String, String, String)>>,
1129    /// When each followed thread was last read whole, keyed
1130    /// `(workspace, conversation, parent ts)`. In memory: a restart costs one
1131    /// extra whole read per thread, which is cheaper than a schema.
1132    pub(crate) slack_thread_full_reads:
1133        Mutex<std::collections::HashMap<(String, String, String), std::time::Instant>>,
1134    /// Followed threads whose next walk is their first since the row was
1135    /// created, keyed the same way.
1136    pub(crate) slack_thread_first_walks: Mutex<std::collections::HashSet<(String, String, String)>>,
1137    /// Cache of each thread row's persisted edit stamp, keyed the same way: an
1138    /// edit is measured against this, never against the reply cursor. A miss
1139    /// reads the row, because the stamp has to outlive the process.
1140    pub(crate) slack_thread_edit_watermarks:
1141        Mutex<std::collections::HashMap<(String, String, String), String>>,
1142    /// Conversations whose own-post ledger could not be read this run,
1143    /// keyed `(workspace, conversation)`. The read is retried on every
1144    /// message; this only decides whether the failure is worth a line a
1145    /// reader sees, once rather than per message.
1146    pub(crate) slack_ledger_failures: Mutex<std::collections::HashSet<(String, String)>>,
1147    /// Composed Slack messages held for the user's decision, keyed by
1148    /// draft id and carrying the session that asked. The owner is stored
1149    /// beside it so an answer is only ever applied by the session it was
1150    /// addressed to.
1151    pub(crate) slack_drafts: Mutex<HashMap<uuid::Uuid, ParkedSlackDraft>>,
1152    /// Browser hand-offs parked for the person at a client, keyed by their id
1153    /// and carrying the session that asked. The owner is stored beside each so
1154    /// an answer is only ever applied by the session it was addressed to; the
1155    /// blocked `browser_hand_off` handler awaits the sender, with no timeout,
1156    /// for as long as the person takes.
1157    pub(crate) browser_handoffs: Mutex<HashMap<uuid::Uuid, ParkedBrowserHandOff>>,
1158    /// The per-seat terminals, created lazily on first use and living as long
1159    /// as the seat: `/new`, `/resume` and a handoff leave the PTY, its shell
1160    /// and its scrollback untouched; a release ends the seat and its
1161    /// terminal.
1162    pub(crate) terminals: Mutex<HashMap<SessionSlot, Arc<crate::terminal::TerminalEntry>>>,
1163    /// Terminal hand-offs parked for the person at a client, keyed by their
1164    /// id and carrying the session that asked. The blocked `terminal__hand_off`
1165    /// handler awaits the sender, with no timeout, for as long as the person
1166    /// takes.
1167    pub(crate) terminal_handoffs: Mutex<HashMap<uuid::Uuid, ParkedTerminalHandOff>>,
1168    /// The id the next terminal takes. **One per seat today**, but the wire
1169    /// carries an id so a second terminal per seat is additive rather than a
1170    /// protocol change.
1171    pub(crate) next_terminal_id: std::sync::atomic::AtomicU16,
1172    /// Configuration writes held for the reader's approval, keyed by the
1173    /// write's id and carrying the session that asked. Its own registry for
1174    /// the same reason the draft's is: a held write exists on a seat with no
1175    /// domain, and nothing reaches `forge.toml` until the blocked handler's
1176    /// oneshot is answered.
1177    pub(crate) settings_writes:
1178        Mutex<HashMap<uuid::Uuid, crate::settings_ask::ParkedSettingsWrite>>,
1179    /// Vault calls held for the reader's approval, keyed by the call's id and
1180    /// carrying the session that asked. Its own registry for the same reason
1181    /// the settings write's is: a held call exists on a seat with no domain,
1182    /// and no operation runs in the vault until the blocked handler's oneshot is
1183    /// answered.
1184    pub(crate) parked_vault_asks: Mutex<HashMap<uuid::Uuid, crate::vault_ask::ParkedVaultAsk>>,
1185    /// How many asks have ever arrived for a seat. **The ask's identity on the
1186    /// wire**: one ask replacing another reads as a different ask, and
1187    /// answering one of several leaves the number where it was.
1188    pending_seq: Mutex<HashMap<SessionSlot, u64>>,
1189    /// Slack messages handed to a session recently, keyed by
1190    /// `(project, owner, conversation, ts)`. A sweep re-runs a batch
1191    /// whenever a 429 lands mid-sweep, a watermark write fails, or the
1192    /// process dies between the two - this is what makes that re-run
1193    /// idempotent rather than a re-delivery.
1194    pub(crate) slack_recently_delivered: Mutex<HashMap<SlackDeliveryKey, std::time::Instant>>,
1195    /// Set at boot when the durable Slack subscriptions could not be
1196    /// read. The Inspector SLACK section reads it: without it the empty
1197    /// subscription set would hide the failure.
1198    pub(crate) slack_load_failed: std::sync::atomic::AtomicBool,
1199    /// The last time each workspace's user-id retry ran, so a failing
1200    /// `auth.test` is retried at most once a minute rather than per sweep.
1201    pub(crate) slack_user_id_retries: Mutex<std::collections::BTreeMap<String, SystemTime>>,
1202    /// Set the first time [`Workspace::start_slack_verification`] runs.
1203    /// Subsequent calls early-return to avoid spawning duplicate probes.
1204    pub(crate) slack_verification_started: std::sync::atomic::AtomicBool,
1205    /// Per-project in-flight guard for the lead Connected
1206    /// hook's catalog scan. Inserted synchronously when
1207    /// `respawn_workers_for_lead` starts; removed when
1208    /// the async scan completes and dispatches its SpawnWorker
1209    /// commands. A concurrent second Connected (e.g. a fast /new
1210    /// reconnect) checking this set sees the entry and skips its own
1211    /// respawn, preventing duplicate worker sets while the scan
1212    /// is in flight. The existing `live_workers.is_empty()` gate
1213    /// covers the post-dispatch case.
1214    respawn_in_flight: Mutex<std::collections::HashSet<ProjectKey>>,
1215    /// The cron ids the LAST `fire_due_crons` pass found unwakeable, so
1216    /// the first warning about one is a `WARN` and the repeats are not.
1217    ///
1218    /// Not a cache: nothing but that warning's level reads it, and it
1219    /// holds only what the most recent pass saw, replaced wholesale at the
1220    /// end of each pass. That is what stops an entry outliving its
1221    /// condition - a cron that fires, advances or is deleted is simply
1222    /// absent from the next pass's set, so one that goes unwakeable again
1223    /// is new again and warns at `WARN` again. A set that never cleared
1224    /// would silence a cron that is firing.
1225    unwakeable_crons: Mutex<std::collections::HashSet<forge_primitives::CronId>>,
1226    /// The cron ids the last `fire_due_crons` pass found cooling, with the
1227    /// time each last warned. Replaced wholesale each pass, like
1228    /// `unwakeable_crons`, so an episode that clears drops its marker and a
1229    /// later one warns again.
1230    cooling_crons: Mutex<std::collections::HashMap<forge_primitives::CronId, SystemTime>>,
1231    /// Test-only intercept buffer for app-level Commands. When
1232    /// `Some`, `dispatch` captures the command into the buffer
1233    /// instead of routing it to the spawn::* handler - used by
1234    /// respawn tests to assert what would have been
1235    /// dispatched without spinning up real subprocesses. Always
1236    /// `None` in production (no enable hook outside test cfg).
1237    #[cfg(any(test, feature = "testing"))]
1238    command_intercept: Mutex<Option<Vec<Command>>>,
1239    /// Test-only project overlay. Entries appended via
1240    /// `seed_test_project` are searched first in
1241    /// `find_project_view_by_name` so tests can drive the
1242    /// Connected-hook respawn trigger without writing a
1243    /// real `forge.toml`. Empty in production.
1244    #[cfg(any(test, feature = "testing"))]
1245    test_extra_projects: Mutex<Vec<LoadedProject>>,
1246    /// Test-only stand-in for the CLI's per-user preferences document, so
1247    /// a view fixture reads an ignore preference without the machine's
1248    /// real `$HOME/.claude.json`. `None` means no fixture seeded one, and
1249    /// the read goes to the file.
1250    #[cfg(any(test, feature = "testing"))]
1251    test_user_preferences: Mutex<Option<serde_json::Value>>,
1252    /// Test-only stand-in for a seat's live `claude` pid, so a fixture seat's
1253    /// held loop walks a real process tree with no CLI behind it. A seat with
1254    /// no entry reads the pid off its own handle, as production does.
1255    #[cfg(any(test, feature = "testing"))]
1256    test_claude_pid: Mutex<std::collections::HashMap<SessionSlot, u32>>,
1257    /// Test-only gate every stale read waits on before it scans, so a fixture
1258    /// can hold a read in flight and see what a hold answers first. `None`
1259    /// means reads run as they do in production.
1260    #[cfg(any(test, feature = "testing"))]
1261    pub(crate) test_scan_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
1262}
1263
1264/// Pool entry wrapping the live `Arc<AgentHandle>`, the account key
1265/// the subprocess is bound to, the permission mode that spawn resolved
1266/// for its project, and the session registration a respawn re-sends.
1267pub(crate) struct PooledAgent {
1268    pub handle: Arc<AgentHandle>,
1269    /// The account the subprocess is bound to, resolved at spawn.
1270    pub account: AccountKey,
1271    /// The permission mode this session spawned with, from its
1272    /// project; `None` when the spawn resolved to no project and the
1273    /// launcher default applied. The dispatch path reads it to stamp
1274    /// the same mode onto `/new` and `/resume` re-spawns.
1275    pub permission_mode: Option<forge_primitives::permission::PermissionMode>,
1276    /// The session's registration, captured at spawn; a `/new` or
1277    /// `/resume` respawn re-registers it so the fresh child gets a
1278    /// fresh binding and env set.
1279    pub registration: Option<forge_gateway::binding::Registration>,
1280    /// The id the child currently runs under, as its spawn resolved it
1281    /// and as `Connected` refreshed it. The slot is the pool's key; this
1282    /// is the occupant the CLI knows, and what a caller holding only the
1283    /// slot needs for a CLI argument or a transcript path.
1284    pub session_id: String,
1285}
1286
1287/// Why `insert_live_worker_if_label_absent` refused an insert. Decided
1288/// under the same lock acquisition as the insert, so the refusal and
1289/// the pool state can never disagree.
1290#[derive(Debug)]
1291pub enum LiveWorkerRefusal {
1292    /// A live (non-`Failed`) worker already holds the label.
1293    LabelLive(SessionSlot),
1294    /// The label's previous despawn is still cleaning up: its worktree is
1295    /// mid-removal, so a worker admitted onto the label now would be handed
1296    /// the very directory the removal is about to delete.
1297    CleanupPending,
1298    /// The project's worker cap is reached: `live` workers are up
1299    /// against a cap of `cap`.
1300    AtCap { live: usize, cap: usize },
1301}
1302
1303/// One label's in-flight despawn cleanup, released when it drops.
1304///
1305/// Held from the despawn's mark (taken before its teardown) until the
1306/// cleanup finishes, so a spawn for the same label is refused rather than
1307/// handed a worktree that the removal is deleting. `Drop` rather than a
1308/// plain clear at the end: a cleanup that unwinds mid-removal must not
1309/// leave the label unspawnable for the rest of the run.
1310pub(crate) struct DespawnCleanupPending {
1311    workspace: Arc<Workspace>,
1312    project_key: ProjectKey,
1313    label: String,
1314}
1315
1316impl Drop for DespawnCleanupPending {
1317    fn drop(&mut self) {
1318        self.workspace
1319            .despawn_cleanups
1320            .lock()
1321            .remove(&(self.project_key.clone(), self.label.clone()));
1322    }
1323}
1324
1325/// The session a worker label resumes onto.
1326#[derive(Debug, Clone, PartialEq, Eq)]
1327pub(crate) enum ResumeTarget {
1328    /// A prior session for the label, named by id.
1329    Found {
1330        session_id: String,
1331        /// The matched tag row predates the `project` field, so the
1332        /// adopting project must stamp it: the migration window is one
1333        /// adoption, not one per project forever.
1334        restamp: bool,
1335    },
1336    /// The label has no prior session to resume.
1337    None,
1338}
1339
1340/// Kick off the catalog scan on the tokio runtime. Idempotent via
1341/// `started`; a caller with no runtime gets a warn and an
1342/// immediately-ready flag with an empty catalog rather than a scan
1343/// nobody can await. A monitor on the scan task publishes readiness
1344/// even if the scan dies, so a reader never waits on a readiness that
1345/// will never come.
1346fn spawn_background_catalog_scan(
1347    catalog: &Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>,
1348    db: &Arc<Mutex<Option<crate::store::Db>>>,
1349    config_dir: &Path,
1350    update_tx: &UpdateFanout,
1351    loaded: &Arc<std::sync::atomic::AtomicBool>,
1352    started: &std::sync::atomic::AtomicBool,
1353) {
1354    if started.swap(true, std::sync::atomic::Ordering::AcqRel) {
1355        return;
1356    }
1357    let run = run_background_catalog_scan(
1358        Arc::clone(catalog),
1359        Arc::clone(db),
1360        config_dir.to_path_buf(),
1361        update_tx.clone(),
1362        Arc::clone(loaded),
1363    );
1364    if let Ok(handle) = tokio::runtime::Handle::try_current() {
1365        let task = handle.spawn(run);
1366        let loaded = Arc::clone(loaded);
1367        handle.spawn(async move {
1368            if let Err(error) = task.await {
1369                tracing::warn!(
1370                    target: "forge_workspace::workspace",
1371                    %error,
1372                    "catalog scan task died; publishing readiness so a reader sees an empty catalog rather than waiting",
1373                );
1374                loaded.store(true, std::sync::atomic::Ordering::Release);
1375            }
1376        });
1377    } else {
1378        tracing::warn!(
1379            target: "forge_workspace::workspace",
1380            config_dir = %config_dir.display(),
1381            "no tokio runtime at construction; the catalog scan is skipped and the catalog starts empty",
1382        );
1383        loaded.store(true, std::sync::atomic::Ordering::Release);
1384    }
1385}
1386
1387/// One probe call, boxed so the task that runs it is not generic over the
1388/// future's type.
1389type CliVersionProbe = std::pin::Pin<Box<dyn std::future::Future<Output = CliVersionInfo> + Send>>;
1390
1391/// How the probe task gets a snapshot. [`Workspace::new`] passes the real
1392/// probe; every test constructor passes a script, so the scheduling around
1393/// the probe is drivable without spawning `claude --version` or reaching
1394/// npm.
1395type CliVersionProber = Box<dyn Fn() -> CliVersionProbe + Send + Sync>;
1396
1397/// The real probe: `claude --version` and npm's `latest` dist-tag.
1398fn real_cli_version_prober() -> CliVersionProber {
1399    Box::new(|| Box::pin(forge_agent::env::cli_version::fetch_info()))
1400}
1401
1402/// One service-status probe call, boxed for the same reason the version
1403/// probe's is.
1404type ServiceStatusProbe =
1405    std::pin::Pin<Box<dyn std::future::Future<Output = Option<ServiceIssue>> + Send>>;
1406
1407/// How the service-status probe gets its answer. `Workspace::new` passes the
1408/// real fetch; a test passes a script, so the boot path is drivable without
1409/// reaching the statuspage.
1410type ServiceStatusProber = Box<dyn Fn() -> ServiceStatusProbe + Send + Sync>;
1411
1412/// The real probe: the public statuspage summary.
1413fn real_service_status_prober() -> ServiceStatusProber {
1414    Box::new(|| Box::pin(forge_agent::cloud::service_status::fetch_service_status()))
1415}
1416
1417/// One pricing fetch, boxed so `refresh_pricing` is not generic over the
1418/// future's type.
1419pub type PricingFetch = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
1420
1421/// How the pricing refill gets its table. `Workspace::new` passes the real
1422/// litellm fetch; a test constructor passes a script, so the refill's
1423/// fetch-to-rescan path is drivable without the network.
1424pub type PricingFetcher = std::sync::Arc<dyn Fn() -> PricingFetch + Send + Sync>;
1425
1426/// The real fetch: the litellm pricing file.
1427fn real_pricing_fetcher() -> PricingFetcher {
1428    std::sync::Arc::new(|| Box::pin(forge_agent::env::token_usage::pricing::fetch_litellm()))
1429}
1430
1431/// The fetch a test workspace gets: nothing to fetch, so a kicked refill
1432/// has nothing to reach - the same no-network promise `new_for_test`
1433/// already makes for its boot probes.
1434#[cfg(any(test, feature = "testing"))]
1435fn inert_pricing_fetcher() -> PricingFetcher {
1436    std::sync::Arc::new(|| Box::pin(async { None }))
1437}
1438
1439/// The fetch a constructor that names none gets.
1440#[cfg(any(test, feature = "testing"))]
1441fn default_pricing_fetcher() -> PricingFetcher {
1442    inert_pricing_fetcher()
1443}
1444
1445/// The fetch a constructor that names none gets.
1446#[cfg(not(any(test, feature = "testing")))]
1447fn default_pricing_fetcher() -> PricingFetcher {
1448    real_pricing_fetcher()
1449}
1450
1451/// Kick off the statuspage probe on the tokio runtime: one fetch, then the
1452/// answer is held. A caller with no runtime gets a warn and holds no status
1453/// rather than a task nobody would run.
1454fn spawn_background_service_status_probe(
1455    started: &std::sync::atomic::AtomicBool,
1456    service_status: &Arc<Mutex<Option<ServiceIssue>>>,
1457    update_tx: &UpdateFanout,
1458    prober: ServiceStatusProber,
1459) {
1460    if started.swap(true, std::sync::atomic::Ordering::AcqRel) {
1461        return;
1462    }
1463    // Without this line a probe that stops running is invisible: the only
1464    // other record is the warning for a missing runtime, which is a
1465    // different failure.
1466    tracing::info!(
1467        target: "forge_workspace::workspace",
1468        event_name = "service_status_probe_started",
1469        "the statuspage probe is running",
1470    );
1471    let run = run_service_status_probe(Arc::clone(service_status), update_tx.clone(), prober);
1472    if let Ok(handle) = tokio::runtime::Handle::try_current() {
1473        handle.spawn(run);
1474    } else {
1475        tracing::warn!(
1476            target: "forge_workspace::workspace",
1477            event_name = "service_status_probe_skipped",
1478            "no tokio runtime at construction; the statuspage is not probed and no view reads a service status this run",
1479        );
1480    }
1481}
1482
1483/// The statuspage probe, off the boot path. Its answer is held before it is
1484/// announced, so a view that attaches afterwards reads it rather than having
1485/// missed the one update that carried it.
1486async fn run_service_status_probe(
1487    service_status: Arc<Mutex<Option<ServiceIssue>>>,
1488    update_tx: UpdateFanout,
1489    prober: ServiceStatusProber,
1490) {
1491    let Some(issue) = prober().await else {
1492        return;
1493    };
1494    let update =
1495        SessionUpdate::ServiceStatus { severity: issue.severity, message: issue.message.clone() };
1496    *service_status.lock() = Some(issue);
1497    let _ = update_tx.send(update);
1498}
1499
1500/// The claude version probe, off the boot path. Each result is merged into
1501/// the store, which wakes the views when it changed what they draw.
1502async fn run_cli_version_probe(
1503    cli_version: Arc<Mutex<Option<CliVersionInfo>>>,
1504    update_tx: UpdateFanout,
1505    prober: Arc<CliVersionProber>,
1506) {
1507    let mut interval = tokio::time::interval(CLI_VERSION_REFRESH_INTERVAL);
1508    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1509    loop {
1510        // The first tick completes immediately, so this is the boot fetch;
1511        // later ticks only recover a transient miss.
1512        interval.tick().await;
1513        let snapshot = prober().await;
1514        store_cli_version(&cli_version, &update_tx, snapshot);
1515    }
1516}
1517
1518/// Merge one probe result into the store and wake the views when it moved
1519/// what they draw.
1520fn store_cli_version(
1521    store: &Mutex<Option<CliVersionInfo>>,
1522    update_tx: &UpdateFanout,
1523    next: CliVersionInfo,
1524) {
1525    let changed = {
1526        let mut held = store.lock();
1527        let merged = merge_cli_version(held.as_ref(), next);
1528        // A view draws the two versions, not the snapshot's presence: the
1529        // first probe that resolves nothing holds a snapshot and changes
1530        // nothing on screen, so it wakes nobody.
1531        let held_draws = match held.as_ref() {
1532            Some(held) => (held.installed.as_deref(), held.latest.as_deref()),
1533            None => (None, None),
1534        };
1535        let merged_draws = (merged.installed.as_deref(), merged.latest.as_deref());
1536        let changed = held_draws != merged_draws;
1537        *held = Some(merged);
1538        changed
1539    };
1540    if changed {
1541        let _ = update_tx.send(SessionUpdate::CliVersionChanged);
1542    }
1543}
1544
1545/// Merge a freshly-probed snapshot over the held one, keeping a
1546/// previously-resolved field when the new probe came back `None` for it: a
1547/// failed network probe must not wipe a `latest` an earlier one found.
1548fn merge_cli_version(prev: Option<&CliVersionInfo>, next: CliVersionInfo) -> CliVersionInfo {
1549    let Some(prev) = prev else { return next };
1550    CliVersionInfo {
1551        installed: next.installed.or_else(|| prev.installed.clone()),
1552        latest: next.latest.or_else(|| prev.latest.clone()),
1553    }
1554}
1555
1556/// The boot catalog scan, off the boot path. Reads the workspace's own
1557/// `config_dir` (canonicalized, so the redb tag-cache keys are the same
1558/// ones any other scan writes even when the config dir path carries a
1559/// symlink component) with the redb tag cache - only bytes appended
1560/// since the last scan are read end to end - swaps the grouped catalog
1561/// in one lock, prunes cache rows of transcripts that no longer exist,
1562/// and only then publishes readiness and `SessionUpdate::CatalogLoaded`.
1563async fn run_background_catalog_scan(
1564    catalog: Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>,
1565    db: Arc<Mutex<Option<crate::store::Db>>>,
1566    config_dir: PathBuf,
1567    update_tx: UpdateFanout,
1568    loaded: Arc<std::sync::atomic::AtomicBool>,
1569) {
1570    // The tag cache is one key space with any other scan of this config
1571    // dir. Without a projects tree the scan still runs and finds an
1572    // empty catalog.
1573    let scan_root = catalog_scan_root(&config_dir)
1574        .unwrap_or_else(|| std::fs::canonicalize(&config_dir).unwrap_or(config_dir.clone()));
1575    let tag_cache = std::sync::Arc::new(load_session_tag_cache(db.lock().as_ref()));
1576    let catalog_entries = forge_agent::userdata::catalog::scan::list_sessions(
1577        &scan_root,
1578        None, // every project in the catalog
1579        None, // no limit
1580        0,
1581        forge_agent::userdata::catalog::scan::Workers::Hidden,
1582        Some(&tag_cache),
1583    )
1584    .await;
1585    persist_session_tag_cache(db.lock().as_ref(), &tag_cache);
1586
1587    // Group sessions by project key derived from each session's cwd.
1588    // Sessions without a cwd are skipped - they can't be associated
1589    // with a project view.
1590    let mut grouped: HashMap<ProjectKey, Vec<SDKSessionInfo>> = HashMap::new();
1591    for entry in catalog_entries {
1592        if let Some(cwd) = entry.cwd.as_deref() {
1593            let key = ProjectKey::new(
1594                forge_agent::userdata::catalog::scan::project_key_for_directory(Some(cwd)),
1595            );
1596            grouped.entry(key).or_default().push(entry);
1597        }
1598    }
1599    // The catalog scan returns entries sorted by `last_modified`
1600    // descending; the per-project Vec inherits that ordering thanks
1601    // to push order being preserved.
1602    //
1603    // Sessions recorded live while the scan ran sit in the catalog
1604    // already (record_connected_session) and their transcripts may not
1605    // be on disk yet, so the disk-built map absorbs them rather than
1606    // replacing them.
1607    {
1608        let mut current = catalog.lock();
1609        for (key, entries) in current.drain() {
1610            let slot = grouped.entry(key).or_default();
1611            // The recorded list is newest-first; inserting each at the
1612            // head in reverse lands the newest back on top.
1613            for entry in entries.into_iter().rev() {
1614                if !slot.iter().any(|s| s.session_id == entry.session_id) {
1615                    slot.insert(0, entry);
1616                }
1617            }
1618        }
1619        *current = grouped;
1620    }
1621
1622    if let Some(db) = db.lock().as_ref() {
1623        match crate::store::session_tags::prune_missing(db) {
1624            Ok(0) => {}
1625            Ok(count) => tracing::info!(
1626                target: "forge_workspace::workspace",
1627                count,
1628                "pruned session tag rows whose transcripts no longer exist",
1629            ),
1630            Err(error) => tracing::warn!(
1631                target: "forge_workspace::workspace",
1632                %error,
1633                "pruning stale session tag rows failed; the table keeps rows of deleted transcripts",
1634            ),
1635        }
1636    }
1637
1638    loaded.store(true, std::sync::atomic::Ordering::Release);
1639    let _ = update_tx.send(SessionUpdate::CatalogLoaded);
1640}
1641
1642/// The previous run's tag scans, or an empty cache when there is no
1643/// store: a missing cache costs a full re-scan, never a wrong answer.
1644fn load_session_tag_cache(
1645    db: Option<&crate::store::Db>,
1646) -> forge_agent::userdata::catalog::scan::SessionTagCache {
1647    let prior = db
1648        .map(|db| {
1649            crate::store::session_tags::load_all(db).unwrap_or_else(|error| {
1650                tracing::warn!(
1651                    target: "forge_workspace::workspace",
1652                    %error,
1653                    "loading the session tag cache failed; every transcript will be re-scanned",
1654                );
1655                std::collections::HashMap::new()
1656            })
1657        })
1658        .unwrap_or_default();
1659    forge_agent::userdata::catalog::scan::SessionTagCache::new(prior)
1660}
1661
1662fn persist_session_tag_cache(
1663    db: Option<&crate::store::Db>,
1664    cache: &forge_agent::userdata::catalog::scan::SessionTagCache,
1665) {
1666    let updates = cache.updates();
1667    if let Some(db) = db
1668        && let Err(error) = crate::store::session_tags::store_all(db, &updates)
1669    {
1670        tracing::warn!(
1671            target: "forge_workspace::workspace",
1672            %error,
1673            "storing the session tag cache failed; the next boot re-scans what it covered",
1674        );
1675    }
1676}
1677
1678/// This config dir's redb store: `<app_support>/stores/<stable-key>.redb`.
1679///
1680/// **The key is stable across Rust releases** ([`forge_sdk::config_dir_store_key`]),
1681/// unlike the lock's `config_dir_hash`: a nightly bump must not open a new
1682/// empty store and orphan every row.
1683fn store_path(app_support: &Path, config_dir: &Path) -> PathBuf {
1684    crate::store::path_for(app_support, config_dir)
1685}
1686
1687/// Whether this path is the home directory's `.claude`, the default config
1688/// dir.
1689///
1690/// The pre-split machine-wide store is the default config dir's state, so
1691/// only that config dir may adopt it. A scratch or second config dir booting
1692/// first must not take it; such a dir leaves the legacy file in place.
1693fn is_default_config_dir(config_dir: &Path) -> bool {
1694    let Some(home) = dirs::home_dir() else { return false };
1695    let default = home.join(".claude");
1696    match (config_dir.canonicalize(), default.canonicalize()) {
1697        (Ok(ours), Ok(theirs)) => ours == theirs,
1698        _ => config_dir == default,
1699    }
1700}
1701
1702/// What a boot should do with the pre-split store.
1703enum Adoption {
1704    /// Nothing is stranded: there is no legacy file, this config dir already
1705    /// has its own store, or the legacy file belongs to another config dir.
1706    Settled,
1707    /// The legacy file is this default config dir's state and could not be
1708    /// moved this boot. Opening a fresh store would hide the file for good -
1709    /// the next boot sees a target and never looks again - so this run goes
1710    /// memory-only and the next boot retries.
1711    Deferred,
1712}
1713
1714/// Adopt the pre-split machine-wide store into this config dir's own, once.
1715///
1716/// **Only the default config dir adopts** (see [`is_default_config_dir`]),
1717/// and the file is probe-opened first: a store another forge still holds is
1718/// left where it is rather than renamed behind its back.
1719fn adopt_legacy_store(app_support: &Path, config_dir: &Path, path: &Path) -> Adoption {
1720    let legacy = app_support.join("db.redb");
1721    if path.exists() || !legacy.is_file() {
1722        return Adoption::Settled;
1723    }
1724    if !is_default_config_dir(config_dir) {
1725        tracing::info!(
1726            target: "forge_workspace::workspace",
1727            event_name = "store_legacy_left_in_place",
1728            legacy = %legacy.display(),
1729            config_dir = %config_dir.display(),
1730            "the machine-wide store belongs to the home config dir, so it is left there; a \
1731             custom-only install can move it under stores/ by hand to keep its rows",
1732        );
1733        return Adoption::Settled;
1734    }
1735    adopt_legacy_store_from(&legacy, path)
1736}
1737
1738/// The adoption itself, with the default-dir verdict already decided.
1739fn adopt_legacy_store_from(legacy: &Path, path: &Path) -> Adoption {
1740    match crate::store::Db::open(legacy) {
1741        // The probe takes the file's own lock and drops it again, so a store
1742        // another process holds is refused here rather than moved under it.
1743        Ok(probe) => drop(probe),
1744        Err(error) => {
1745            tracing::warn!(
1746                target: "forge_workspace::workspace",
1747                event_name = "store_legacy_in_use",
1748                %error,
1749                legacy = %legacy.display(),
1750                "the machine-wide store is held by another process; this run has no durable \
1751                 state and the next boot retries the adoption",
1752            );
1753            return Adoption::Deferred;
1754        }
1755    }
1756    match std::fs::rename(legacy, path) {
1757        Ok(()) => {
1758            tracing::info!(
1759                target: "forge_workspace::workspace",
1760                event_name = "store_legacy_adopted",
1761                from = %legacy.display(),
1762                to = %path.display(),
1763                "the machine-wide store is adopted by the default config dir",
1764            );
1765            Adoption::Settled
1766        }
1767        Err(error) => {
1768            tracing::warn!(
1769                target: "forge_workspace::workspace",
1770                event_name = "store_legacy_adopt_failed",
1771                %error,
1772                from = %legacy.display(),
1773                to = %path.display(),
1774                "the machine-wide store could not be adopted; this run has no durable state and \
1775                 the next boot retries",
1776            );
1777            Adoption::Deferred
1778        }
1779    }
1780}
1781
1782/// Open this config dir's redb store, creating the directories first.
1783/// Returns `None` (with a warn) when they can't be created or the DB can't
1784/// open - forge then runs without durable crons, subscriptions, tasks or
1785/// dynamic workers this session (hard rule #14: no cwd fallback).
1786///
1787/// **The store is keyed by the config dir, as the lock is.** A machine-wide
1788/// `<app_support>/db.redb` from before the split is adopted once, by the
1789/// default config dir; a second config dir starts empty, which is the
1790/// isolation this keying exists for.
1791fn open_db(app_support: &Path, config_dir: &Path) -> Option<crate::store::Db> {
1792    // **One key space.** `config_dir_store_key` falls back to the raw path
1793    // when it cannot canonicalise, so a dir that fails once and resolves
1794    // later would open a second, empty store. The boot has already created
1795    // the config dir; a path that still cannot canonicalise gets no durable
1796    // state rather than a key that may move.
1797    let Ok(canonical) = config_dir.canonicalize() else {
1798        tracing::warn!(
1799            target: "forge_workspace::workspace",
1800            event_name = "store_dir_unresolved",
1801            config_dir = %config_dir.display(),
1802            "the config dir cannot be canonicalised, so its store key would not be stable; \
1803             durable crons, subscriptions, tasks and dynamic workers will not persist",
1804        );
1805        return None;
1806    };
1807    let path = store_path(app_support, &canonical);
1808    let stores = path.parent()?;
1809    if let Err(error) = std::fs::create_dir_all(stores) {
1810        tracing::warn!(
1811            target: "forge_workspace::workspace",
1812            event_name = "store_dir_create_failed",
1813            %error,
1814            path = %stores.display(),
1815            "creating the app-support stores dir failed; durable crons, subscriptions, tasks and dynamic workers will not persist",
1816        );
1817        return None;
1818    }
1819    if let Adoption::Deferred = adopt_legacy_store(app_support, config_dir, &path) {
1820        return None;
1821    }
1822    match crate::store::Db::open(&path) {
1823        Ok(db) => Some(db),
1824        Err(error) => {
1825            tracing::warn!(
1826                target: "forge_workspace::workspace",
1827                event_name = "store_open_failed",
1828                %error,
1829                path = %path.display(),
1830                "opening the redb store failed; durable crons, subscriptions, tasks and dynamic workers will not persist",
1831            );
1832            None
1833        }
1834    }
1835}
1836
1837/// The configured projects as the session store keys them: the catalog
1838/// key a `dynamic_workers` row carries, plus the org and name a
1839/// `sessions` row is keyed by.
1840fn project_identities(config: &LoadedConfig) -> Vec<crate::store::sessions::ProjectIdentity> {
1841    config
1842        .projects
1843        .iter()
1844        .map(|project| crate::store::sessions::ProjectIdentity {
1845            key: forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
1846                &project.path.to_string_lossy(),
1847            )),
1848            org: project.org.clone(),
1849            name: project.name.clone(),
1850        })
1851        .collect()
1852}
1853
1854/// The gateway's per-org walk order. Orgs are load-validated to share one
1855/// pin, so the first project of an org states it.
1856fn org_pins(config: &LoadedConfig) -> HashMap<String, forge_gateway::selection::OrgPin> {
1857    let mut pins: HashMap<String, forge_gateway::selection::OrgPin> = HashMap::new();
1858    for project in &config.projects {
1859        let pin =
1860            pins.entry(project.org.clone()).or_insert_with(|| forge_gateway::selection::OrgPin {
1861                accounts: project.accounts.clone(),
1862                fallback_accounts: project.fallback_accounts.clone(),
1863            });
1864        debug_assert_eq!(pin.accounts, project.accounts, "org pin drifted");
1865        debug_assert_eq!(pin.fallback_accounts, project.fallback_accounts, "org pin drifted");
1866    }
1867    pins
1868}
1869
1870/// The scan root for one config dir: the canonical parent of its
1871/// resolved `projects` dir, or the canonical config dir itself when
1872/// the resolved tree is not named `projects` (the walk descends into
1873/// `<root>/projects`, so the target's parent would miss it). `None`
1874/// when there is no projects tree.
1875fn catalog_scan_root(config_dir: &std::path::Path) -> Option<PathBuf> {
1876    let projects = match std::fs::canonicalize(config_dir.join("projects")) {
1877        Ok(projects) => projects,
1878        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1879            tracing::debug!(
1880                target: "forge_workspace::workspace",
1881                config_dir = %config_dir.display(),
1882                "no projects tree; the catalog scan finds nothing here",
1883            );
1884            return None;
1885        }
1886        Err(error) => {
1887            tracing::warn!(
1888                target: "forge_workspace::workspace",
1889                config_dir = %config_dir.display(),
1890                %error,
1891                "cannot resolve this config dir's projects tree; its sessions are invisible to the scan",
1892            );
1893            return None;
1894        }
1895    };
1896    if projects.file_name().is_some_and(|name| name.to_str() == Some("projects")) {
1897        Some(projects.parent().map_or_else(|| config_dir.to_path_buf(), PathBuf::from))
1898    } else {
1899        Some(std::fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf()))
1900    }
1901}
1902
1903/// A forge command's answer: what the reader is told, and how loudly.
1904///
1905/// **Returned rather than emitted where the command is handled**, because the
1906/// answer has to land AFTER the words that asked for it: the dispatcher echoes
1907/// those once the command has landed, and a notice sent from the handler
1908/// arrived first, which every client that draws the stream in order put above
1909/// the prompt it answers.
1910type ForgeAnswer = (NoticeSeverity, String);
1911
1912/// The line a forge name invoked wrongly is answered with.
1913///
1914/// Answered rather than acted on, and never left to fall through as a prompt:
1915/// a mistyped command reaching the model reads as a question.
1916fn forge_misuse(usage: &str) -> ForgeAnswer {
1917    (NoticeSeverity::Error, usage.to_owned())
1918}
1919
1920/// Record the surface a spawn hands its child.
1921///
1922/// The child fetches this list once and keeps it for its life, so this line
1923/// is the only statement of what it was given; the boot init frame's own
1924/// answer is recorded beside it.
1925fn record_session_tool_surface(slot: &SessionSlot, lead: bool, names: &[&str]) {
1926    tracing::debug!(
1927        target: "forge_workspace::mcp",
1928        event_name = "session_tool_surface",
1929        slot = %slot.display(),
1930        lead,
1931        count = names.len(),
1932        tools = %names.join(","),
1933        "the forge tool surface handed to this spawn",
1934    );
1935}
1936
1937#[cfg(test)]
1938mod record_session_tool_surface_tests {
1939    use super::record_session_tool_surface;
1940    use crate::SessionSlot;
1941    use std::sync::{Arc, Mutex};
1942    use tracing::field::{Field, Visit};
1943    use tracing_subscriber::layer::SubscriberExt as _;
1944
1945    #[derive(Clone, Debug)]
1946    struct CaughtEvent {
1947        level: tracing::Level,
1948        target: String,
1949        fields: Vec<(String, String)>,
1950    }
1951
1952    impl CaughtEvent {
1953        fn field(&self, name: &str) -> Option<&str> {
1954            self.fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
1955        }
1956    }
1957
1958    #[derive(Clone, Default)]
1959    struct Caught(Arc<Mutex<Vec<CaughtEvent>>>);
1960
1961    struct Fields(Vec<(String, String)>);
1962
1963    impl Visit for Fields {
1964        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1965            self.0.push((field.name().to_owned(), format!("{value:?}")));
1966        }
1967
1968        fn record_str(&mut self, field: &Field, value: &str) {
1969            self.0.push((field.name().to_owned(), value.to_owned()));
1970        }
1971
1972        fn record_bool(&mut self, field: &Field, value: bool) {
1973            self.0.push((field.name().to_owned(), value.to_string()));
1974        }
1975
1976        fn record_u64(&mut self, field: &Field, value: u64) {
1977            self.0.push((field.name().to_owned(), value.to_string()));
1978        }
1979
1980        fn record_i64(&mut self, field: &Field, value: i64) {
1981            self.0.push((field.name().to_owned(), value.to_string()));
1982        }
1983    }
1984
1985    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
1986        fn on_event(
1987            &self,
1988            event: &tracing::Event<'_>,
1989            _ctx: tracing_subscriber::layer::Context<'_, S>,
1990        ) {
1991            let mut fields = Fields(Vec::new());
1992            event.record(&mut fields);
1993            self.0.lock().expect("capture").push(CaughtEvent {
1994                level: *event.metadata().level(),
1995                target: event.metadata().target().to_owned(),
1996                fields: fields.0,
1997            });
1998        }
1999    }
2000
2001    /// **The spawn's line is the artifact a "which list was that session
2002    /// handed" report is answered from**, so it is pinned field by field:
2003    /// a block that stops emitting, drops the slot, or changes the level
2004    /// fails here rather than leaving the record silently thinner.
2005    #[test]
2006    fn the_record_names_the_slot_kind_count_and_every_tool() {
2007        let caught = Caught::default();
2008        let subscriber = tracing_subscriber::registry().with(caught.clone());
2009        let slot = SessionSlot::worker("Busytools", "forge", "surface");
2010        let displayed = slot.display();
2011        tracing::subscriber::with_default(subscriber, || {
2012            record_session_tool_surface(&slot, false, &["agents__list", "browser_form_state"]);
2013        });
2014
2015        let events = caught.0.lock().expect("capture");
2016        let event = events.last().expect("the record was emitted");
2017        assert_eq!(
2018            event.level,
2019            tracing::Level::DEBUG,
2020            "a spawn's surface is the session's own fact"
2021        );
2022        assert_eq!(
2023            event.target, "forge_workspace::mcp",
2024            "it lands under the default filter's target"
2025        );
2026        assert_eq!(event.field("event_name"), Some("session_tool_surface"));
2027        assert_eq!(event.field("slot"), Some(displayed.as_str()));
2028        assert_eq!(event.field("lead"), Some("false"));
2029        assert_eq!(event.field("count"), Some("2"));
2030        assert_eq!(event.field("tools"), Some("agents__list,browser_form_state"));
2031    }
2032}
2033
2034impl Workspace {
2035    /// Builds a Workspace, kicks off the background catalog scan and the
2036    /// claude version probe, and loads `<config_dir>/forge.toml`. Errors if
2037    /// `forge.toml` is missing or malformed (e.g. no `[[orgs]]` entries, no
2038    /// `[[orgs.projects]]` entries, unknown account references). No
2039    /// Agents are spawned on success. The session catalog starts empty
2040    /// and fills when the scan lands - see [`Workspace::start_catalog_scan`].
2041    pub fn new(config_dir: PathBuf) -> Result<Self, WorkspaceError> {
2042        Self::new_impl(
2043            config_dir,
2044            None,
2045            true,
2046            Some(real_cli_version_prober()),
2047            Some(real_service_status_prober()),
2048            Some(real_pricing_fetcher()),
2049        )
2050    }
2051
2052    /// Like [`Workspace::new`] but puts forge's whole app-support base -
2053    /// the redb store and the single-instance lock - under a tempdir
2054    /// inside `config_dir` rather than the real machine
2055    /// `app_support_dir`, so tests never touch the user's durable store
2056    /// or contend for their live lock. The catalog scan does NOT
2057    /// auto-start; tests opt in via [`Workspace::start_catalog_scan`]
2058    /// so the catalog stays empty until a fixture asks for it. Neither
2059    /// boot probe starts either: a test workspace reaches no network.
2060    #[cfg(any(test, feature = "testing"))]
2061    pub fn new_for_test(config_dir: PathBuf) -> Result<Self, WorkspaceError> {
2062        Self::new_for_test_impl(config_dir, None, None)
2063    }
2064
2065    /// [`Workspace::new_for_test`] with the pricing refill driven by
2066    /// `fetcher` rather than by the inert default, so the refill's
2067    /// fetch-to-rescan path is exercisable without the network.
2068    #[cfg(any(test, feature = "testing"))]
2069    pub fn new_for_test_with_pricing_fetcher(
2070        config_dir: PathBuf,
2071        fetcher: PricingFetcher,
2072    ) -> Result<Self, WorkspaceError> {
2073        Self::new_for_test_impl(config_dir, None, Some(fetcher))
2074    }
2075
2076    /// [`Workspace::new_for_test`] with `prober` driving the claude version
2077    /// probe in place of the real one, so a test drives the constructor's
2078    /// own launch without spawning `claude --version` or reaching npm.
2079    #[cfg(any(test, feature = "testing"))]
2080    pub fn new_for_test_with_cli_version_prober(
2081        config_dir: PathBuf,
2082        prober: CliVersionProber,
2083    ) -> Result<Self, WorkspaceError> {
2084        Self::new_for_test_impl(config_dir, Some(prober), None)
2085    }
2086
2087    #[cfg(any(test, feature = "testing"))]
2088    fn new_for_test_impl(
2089        config_dir: PathBuf,
2090        cli_version_prober: Option<CliVersionProber>,
2091        pricing_fetcher: Option<PricingFetcher>,
2092    ) -> Result<Self, WorkspaceError> {
2093        let app_support = config_dir.join("app-support");
2094        let workspace = Self::new_impl(
2095            config_dir,
2096            Some(app_support),
2097            false,
2098            cli_version_prober,
2099            None,
2100            pricing_fetcher,
2101        )?;
2102        // Tests never start the listener; the boot gate reads open so
2103        // spawn paths are exercisable, and the I2 test flips it back to
2104        // closed explicitly when it needs the refusal.
2105        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
2106        Ok(workspace)
2107    }
2108
2109    /// Run the catalog scan in the background and signal readiness
2110    /// when it lands. `[`Workspace::new`] calls this during
2111    /// construction; tests built on `new_for_test` opt in explicitly so
2112    /// the catalog stays empty until a fixture asks for it. Idempotent -
2113    /// a second call is a no-op.
2114    pub fn start_catalog_scan(&self) {
2115        spawn_background_catalog_scan(
2116            &self.catalog,
2117            &self.db,
2118            &self.config_dir,
2119            &self.update_tx,
2120            &self.catalog_loaded,
2121            &self.catalog_scan_started,
2122        );
2123    }
2124
2125    /// Whether the background catalog scan has populated the catalog.
2126    /// False from construction until the scan lands; always true when
2127    /// constructed without a tokio runtime (nothing would run it).
2128    ///
2129    /// [`Self::resumable_sessions`] reads it: a lead's list is the catalog's
2130    /// group once the scan has landed, and the directory's own read until
2131    /// then, so the two sources are told apart rather than an unscanned
2132    /// catalog being read as a project with no sessions. Tests also await it
2133    /// to sequence a fixture against the scan.
2134    pub fn catalog_ready(&self) -> bool {
2135        self.catalog_loaded.load(std::sync::atomic::Ordering::Acquire)
2136    }
2137
2138    /// The process's subsystem supervisor. Every long-lived background task
2139    /// registers here, so a death is named and a restart is bounded.
2140    pub fn supervisor(&self) -> &Arc<forge_supervise::Supervisor> {
2141        &self.supervisor
2142    }
2143
2144    /// Kick off the claude version probe on the tokio runtime: one fetch at
2145    /// boot, then a re-probe every [`CLI_VERSION_REFRESH_INTERVAL`]. The
2146    /// constructor is the only production caller, and the prober it takes
2147    /// is what lets a test drive this same launch. Idempotent - a second
2148    /// call is a no-op.
2149    fn start_cli_version_probe(&self, prober: CliVersionProber) {
2150        // **No runtime means no probe this run.** The registry would defer
2151        // the member until a runtime appeared, but a workspace built outside
2152        // one is a test's or a caller's construction, not a boot: say so
2153        // once and hold no version rather than start a task nobody would
2154        // run.
2155        if tokio::runtime::Handle::try_current().is_err() {
2156            tracing::warn!(
2157                target: "forge_workspace::workspace",
2158                event_name = "cli_version_probe_skipped",
2159                "no tokio runtime at construction; the claude version probe is skipped and the views show no claude version this run",
2160            );
2161            return;
2162        }
2163        let cli_version = Arc::clone(&self.cli_version);
2164        let update_tx = self.update_tx.clone();
2165        let prober = Arc::new(prober);
2166        self.supervisor.spawn("cli_version_probe", forge_supervise::Policy::backoff(), move || {
2167            let cli_version = Arc::clone(&cli_version);
2168            let update_tx = update_tx.clone();
2169            let prober = Arc::clone(&prober);
2170            async move { run_cli_version_probe(cli_version, update_tx, prober).await }
2171        });
2172    }
2173
2174    /// The `claude` CLI versions the core holds: what is installed on this
2175    /// machine and what npm publishes, both `None` until the boot probe
2176    /// resolves them. The same answer for every viewer, so a view reads it
2177    /// here rather than probing for itself. A change arrives as
2178    /// [`SessionUpdate::CliVersionChanged`].
2179    pub fn cli_version(&self) -> Option<CliVersionInfo> {
2180        self.cli_version.lock().clone()
2181    }
2182
2183    /// Shared constructor body. `app_support` supplies the app-support
2184    /// base dir; `None` resolves the real machine `app_support_dir` and
2185    /// degrades to no lock and no durable store on failure (hard rule
2186    /// #14: no cwd fallback).
2187    ///
2188    /// `cli_version_prober` and `service_status_prober` are `None` for a
2189    /// workspace built by a test. Both probes fetch over the network at boot
2190    /// and announce the answer on the core's own stream, so a test workspace
2191    /// that ran them would both depend on a remote service's health and hand
2192    /// every test a slot-less update it never emitted.
2193    fn new_impl(
2194        config_dir: PathBuf,
2195        app_support: Option<PathBuf>,
2196        catalog_scan: bool,
2197        cli_version_prober: Option<CliVersionProber>,
2198        service_status_prober: Option<ServiceStatusProber>,
2199        pricing_fetcher: Option<PricingFetcher>,
2200    ) -> Result<Self, WorkspaceError> {
2201        let config = load_from_dir(&config_dir)?;
2202
2203        // Create forge's own config subfolder before anything writes into
2204        // it (the lock, the cron + state stores all live under it). Hard-
2205        // fail if it can't be created: forge can persist nothing without a
2206        // writable config dir, so degrading is pointless.
2207        crate::config::ensure_forge_data_dir(&config_dir).map_err(|source| {
2208            WorkspaceError::DataDirUnavailable {
2209                path: crate::config::forge_data_dir(&config_dir),
2210                source,
2211            }
2212        })?;
2213
2214        // forge's machine-local app-support base, resolved once: the
2215        // single-instance lock and the redb store both sit under it.
2216        // `Some` comes from the test constructor, which points the whole
2217        // base at a tempdir. `None` degrades to no lock and no durable
2218        // store rather than falling back to cwd (hard rule #14).
2219        let app_support = match app_support {
2220            Some(dir) => Some(dir),
2221            None => match forge_sdk::app_support_dir() {
2222                Ok(dir) => Some(dir),
2223                Err(error) => {
2224                    tracing::warn!(
2225                        target: "forge_workspace::workspace",
2226                        %error,
2227                        "app-support dir unresolved; the single-instance guard is skipped and durable state will not persist",
2228                    );
2229                    None
2230                }
2231            },
2232        };
2233
2234        // Single-instance guard: forge runs one process per config dir.
2235        // The held File is stored on `Self` for the process lifetime;
2236        // flock auto-releases on exit/crash. A second forge on the same
2237        // config dir hard-fails here.
2238        let single_instance_lock = match app_support.as_deref() {
2239            Some(base) => match crate::single_instance::acquire(&config_dir, base) {
2240                Ok(lock) => lock,
2241                Err(crate::single_instance::AcquireError::AlreadyRunning { pid }) => {
2242                    return Err(WorkspaceError::AlreadyRunning { pid });
2243                }
2244            },
2245            None => None,
2246        };
2247        // The guard fell open (lockfile unopenable or flock unsupported).
2248        // forge still boots, but the cron store's single-writer assumption
2249        // no longer holds - surface it loudly rather than leaving it at the
2250        // module-internal warn `acquire` already logged.
2251        if single_instance_lock.is_none() {
2252            tracing::error!(
2253                target: "forge_workspace::workspace",
2254                config_dir = %config_dir.display(),
2255                "single-instance guard unavailable; on-disk state (crons, usage cache, settings) is NOT protected against a second forge on this config dir - check the dir is writable and on a flock-capable filesystem",
2256            );
2257        }
2258
2259        // Open the machine-local redb store: durable crons, Gotify and Slack
2260        // subscriptions all live in it. A failure to resolve the app-
2261        // support dir or open the DB degrades to no durable state this run
2262        // (non-fatal, like the state cache) - no cwd fallback (hard rule #14).
2263        let mut db = app_support.as_deref().and_then(|base| open_db(base, &config_dir));
2264
2265        // Load durable forge crons into the in-memory working set. Boot
2266        // catch-up for entries that came due while forge was down runs after
2267        // construction, once the dispatch machinery is live. A failed load
2268        // leaves the set empty while rows may still be on disk, so mark the
2269        // run's crons unavailable rather than letting `cron__list` report an
2270        // empty registry.
2271        let mut crons_load_failed = false;
2272        let crons = if let Some(db) = &db {
2273            crate::store::cron::list(db).unwrap_or_else(|error| {
2274                tracing::warn!(
2275                    target: "forge_workspace::workspace",
2276                    %error,
2277                    "loading durable crons failed; starting with none this run",
2278                );
2279                crons_load_failed = true;
2280                Vec::new()
2281            })
2282        } else {
2283            crons_load_failed = true;
2284            Vec::new()
2285        };
2286        let tasks = match &db {
2287            Some(db) => crate::store::tasks::list(db).unwrap_or_else(|error| {
2288                tracing::warn!(
2289                    target: "forge_workspace::workspace",
2290                    %error,
2291                    "loading durable tasks failed; starting with none this run",
2292                );
2293                Vec::new()
2294            }),
2295            None => Vec::new(),
2296        };
2297        let gotify_subs = match &db {
2298            Some(db) => crate::store::gotify::list(db).unwrap_or_else(|error| {
2299                tracing::warn!(
2300                    target: "forge_workspace::workspace",
2301                    %error,
2302                    "loading durable Gotify subscriptions failed; starting with none",
2303                );
2304                Vec::new()
2305            }),
2306            None => Vec::new(),
2307        };
2308        let mut slack_load_failed = false;
2309        let slack_subs = match &db {
2310            Some(db) => crate::store::slack::list(db).unwrap_or_else(|error| {
2311                tracing::warn!(
2312                    target: "forge_workspace::workspace",
2313                    %error,
2314                    "loading durable Slack subscriptions failed; starting with none",
2315                );
2316                slack_load_failed = true;
2317                Vec::new()
2318            }),
2319            None => Vec::new(),
2320        };
2321
2322        // The boot moves anything still in `dynamic_workers` onto
2323        // `sessions`, so a worker persisted before this build has a row
2324        // the re-spawn wave can read, and drops the retired table once
2325        // nothing is left in it. Non-fatal, like the loads above.
2326        let mut swept_clean = false;
2327        if let Some(db) = &db {
2328            match crate::store::sessions::migrate_from_dynamic_workers(
2329                db,
2330                &project_identities(&config),
2331            ) {
2332                Ok(outcome) => {
2333                    if outcome.unkeyable > 0 {
2334                        tracing::warn!(
2335                            target: "forge_workspace::workspace",
2336                            unkeyable = outcome.unkeyable,
2337                            "the retired worker table holds rows no configured project can key; \
2338                             they stay there and the table is not dropped",
2339                        );
2340                    }
2341                    swept_clean = outcome.drained();
2342                }
2343                Err(error) => tracing::warn!(
2344                    target: "forge_workspace::workspace",
2345                    %error,
2346                    "migrating persisted workers into the sessions table failed; the retired \
2347                     table is left in place for the next boot",
2348                ),
2349            }
2350        }
2351        // The boot wave derives the open set from `seat_state`, so a row
2352        // written before the field existed is seeded `idle` here: the first
2353        // boot on this build opens every project whose lead ever spawned,
2354        // and every boot after reads an explicit state. Non-fatal, like the
2355        // sweep above.
2356        if let Some(db) = &db {
2357            match crate::store::sessions::seed_seat_state(db) {
2358                Ok(seeded) if seeded > 0 => tracing::info!(
2359                    target: "forge_workspace::sessions",
2360                    event_name = "seat_state_seeded",
2361                    rows = seeded,
2362                    "seeded the seats written before the seat-state field",
2363                ),
2364                Ok(_) => {}
2365                Err(error) => tracing::warn!(
2366                    target: "forge_workspace::sessions",
2367                    event_name = "seat_state_seed_failed",
2368                    %error,
2369                    "seeding the seats written before the seat-state field failed; the rows it \
2370                     did not reach read as absent, and the boot opens nothing for them",
2371                ),
2372            }
2373        }
2374        // Drop the retired table only once every row it held has been
2375        // copied, and reclaim its pages - a copied table is still
2376        // allocated. A sweep that errored, that left a row it could not
2377        // key, or that did not run at all leaves rows there and only
2378        // there, so dropping on any of those would lose a worker the user
2379        // believes is durable, silently and with nothing to say which
2380        // label went. A store that never had the table drops nothing.
2381        if swept_clean && let Some(db) = db.as_mut() {
2382            match crate::store::dynamic_workers::drop_table(db) {
2383                Ok(true) => {
2384                    if let Err(error) = db.compact() {
2385                        tracing::warn!(
2386                            target: "forge_workspace::workspace",
2387                            %error,
2388                            "compacting the store after dropping the retired worker table failed",
2389                        );
2390                    }
2391                }
2392                Ok(false) => {}
2393                Err(error) => tracing::warn!(
2394                    target: "forge_workspace::workspace",
2395                    %error,
2396                    "dropping the retired dynamic_workers table failed; it stays for the next boot",
2397                ),
2398            }
2399        }
2400        // The catalog table outlived its module: deleting the machinery
2401        // left the rows behind, and nothing reads or writes them now, so
2402        // there is nothing to drain first. A store that never cached a
2403        // catalog drops nothing.
2404        if let Some(db) = db.as_mut() {
2405            match crate::store::model_catalog::drop_table(db) {
2406                Ok(true) => {
2407                    if let Err(error) = db.compact() {
2408                        tracing::warn!(
2409                            target: "forge_workspace::workspace",
2410                            %error,
2411                            "compacting the store after dropping the retired model catalog table failed",
2412                        );
2413                    }
2414                }
2415                Ok(false) => {}
2416                Err(error) => tracing::warn!(
2417                    target: "forge_workspace::workspace",
2418                    %error,
2419                    "dropping the retired model_catalog table failed; it stays for the next boot",
2420                ),
2421            }
2422        }
2423        // The fired-prompt pairings had no retention: every fire added a row
2424        // and nothing took one away. A boot drops the ones past the window.
2425        if let Some(db) = &db {
2426            match crate::store::cron_names::prune_expired(db, std::time::SystemTime::now()) {
2427                Ok(0) => {}
2428                Ok(removed) => tracing::debug!(
2429                    target: "forge_workspace::workspace",
2430                    event_name = "cron_names_pruned",
2431                    removed,
2432                    "dropped fired-prompt pairings older than the retention window",
2433                ),
2434                Err(error) => tracing::warn!(
2435                    target: "forge_workspace::workspace",
2436                    event_name = "cron_names_prune_failed",
2437                    %error,
2438                    "sweeping old fired-prompt pairings failed; they stay until the next boot",
2439                ),
2440            }
2441        }
2442        // The settings table held one row, the `/spinner` override, which went
2443        // with the spinner.
2444        if let Some(db) = db.as_mut() {
2445            match crate::store::settings::drop_table(db) {
2446                Ok(true) => {
2447                    if let Err(error) = db.compact() {
2448                        tracing::warn!(
2449                            target: "forge_workspace::workspace",
2450                            %error,
2451                            "compacting the store after dropping the retired settings table failed",
2452                        );
2453                    }
2454                }
2455                Ok(false) => {}
2456                Err(error) => tracing::warn!(
2457                    target: "forge_workspace::workspace",
2458                    %error,
2459                    "dropping the retired settings table failed; it stays for the next boot",
2460                ),
2461            }
2462        }
2463
2464        // Resolved here rather than lazily so a malformed `[[slack]]`
2465        // entry refuses the boot, the way the rest of forge.toml does.
2466        // The client goes through the same TLS-trust helper the Gotify
2467        // seam uses, so NODE_EXTRA_CA_CERTS behaves identically.
2468        let slack_http = forge_agent::http_trust::with_extra_roots(reqwest::Client::builder())
2469            .build()
2470            .map_err(|error| WorkspaceError::ConfigInvalid {
2471                path: crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2472                message: format!("slack http client: {error}"),
2473            })?;
2474        let slack = Arc::new(
2475            crate::slack::SlackWorkspaces::from_config(&config.slack, &slack_http).map_err(
2476                |message| WorkspaceError::ConfigInvalid {
2477                    path: crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2478                    message,
2479                },
2480            )?,
2481        );
2482        let systemone = crate::systemone::client_for(
2483            config.systemone.as_ref(),
2484            &crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2485        )?
2486        .map(Arc::new);
2487        // Built above the struct rather than with it: the vault session
2488        // announces a rotation through this fan-out, and the session is made
2489        // before the struct's own field exists.
2490        let update_tx = UpdateFanout::default();
2491        let vault = match config.vault.as_ref() {
2492            Some(vault_config) => Some(Arc::new(
2493                crate::vault::VaultSession::with_client(
2494                    vault_config,
2495                    app_support.as_deref(),
2496                    &config_dir,
2497                    crate::vault::production_client(&vault_config.url).map_err(|error| {
2498                        WorkspaceError::ConfigInvalid {
2499                            path: crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2500                            message: format!("[vault] {error}"),
2501                        }
2502                    })?,
2503                    update_tx.clone(),
2504                )
2505                .map_err(|error| WorkspaceError::ConfigInvalid {
2506                    path: crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2507                    message: format!("[vault] {error}"),
2508                })?,
2509            )),
2510            None => None,
2511        };
2512
2513        // Catalog scan reads against the workspace's canonical
2514        // `config_dir` (where forge.toml lives). Each spawn binds to
2515        // its own account `config_dir` separately; multi-account
2516        // catalog merge is a separate concern.
2517        // The scan itself runs in the background (#794): reading every
2518        // transcript end to end for its worker tag was the bulk of the
2519        // pre-paint pause. The catalog starts empty and fills when the
2520        // scan lands; nothing on a spawn path waits for it.
2521        let catalog = Arc::new(Mutex::new(HashMap::new()));
2522
2523        let accounts = Arc::new(forge_gateway::AccountPool::new(&config.accounts));
2524        // The forward leg's client comes from the host port, like every
2525        // other outbound path: the inference upstream then carries the
2526        // same extra trust roots the probes do.
2527        let forward_http = forge_agent::cloud::AgentHost
2528            .streaming_http_client(
2529                forge_gateway::forward::FORWARD_CONNECT_TIMEOUT,
2530                forge_gateway::forward::FORWARD_IDLE_TIMEOUT,
2531            )
2532            .map_err(|error| WorkspaceError::ConfigInvalid {
2533                path: crate::config::forge_data_dir(&config_dir).join("forge.toml"),
2534                message: format!("forward-leg http client: {error}"),
2535            })?;
2536        let gateway =
2537            Arc::new(forge_gateway::forward::Gateway::new(Arc::clone(&accounts), forward_http));
2538        // Hand the gateway each org's walk order so an unbound session
2539        // can select an account from its path alone - the restart case,
2540        // where the bindings are gone but the config is loaded.
2541        gateway.set_org_pins(org_pins(&config));
2542        gateway.set_rotation_numbers(config.gateway_rotation);
2543
2544        // Seed account usage from the machine-local store so usage
2545        // reads have data immediately at cold boot.
2546        // Anthropic's /api/oauth/usage rate-limiter can stall the first
2547        // live probe for 30 s+; without seed data every account reads as
2548        // unknown during that window, so nothing is demoted for being at
2549        // its cap. The background poller refreshes these snapshots on the
2550        // configured interval (60 s by default) - the cache is purely
2551        // "last known value" seed.
2552        let state = match &db {
2553            Some(db) => crate::account_cache::load(db),
2554            None => crate::account_cache::ForgeState::empty(),
2555        };
2556        accounts.seed_from_cache(&state.account_usage);
2557
2558        let gateway_port = config.gateway_port;
2559        let (kick_dispatcher_tx, kick_dispatcher_rx) = mpsc::unbounded_channel::<KickRequest>();
2560        let config_dictate = config.dictate.clone();
2561        let db = Arc::new(Mutex::new(db));
2562        let catalog_loaded = Arc::new(std::sync::atomic::AtomicBool::new(false));
2563        let catalog_scan_started = std::sync::atomic::AtomicBool::new(false);
2564        if catalog_scan {
2565            spawn_background_catalog_scan(
2566                &catalog,
2567                &db,
2568                &config_dir,
2569                &update_tx,
2570                &catalog_loaded,
2571                &catalog_scan_started,
2572            );
2573        }
2574        // The version facts are the same for every viewer, so the core
2575        // probes them once and both views read one answer.
2576        let cli_version = Arc::new(Mutex::new(None));
2577        // The statuspage answer is the same for every viewer too, and it
2578        // has to outlive the view that used to fetch it.
2579        let service_status: Arc<Mutex<Option<ServiceIssue>>> = Arc::new(Mutex::new(None));
2580        let (settings, _settings_rx) = tokio::sync::watch::channel(None);
2581        let workspace = Self {
2582            config_dir,
2583            config: parking_lot::RwLock::new(Arc::new(config)),
2584            settings,
2585            settings_version: std::sync::OnceLock::new(),
2586            settings_write: parking_lot::Mutex::new(()),
2587            reload_generation: std::sync::atomic::AtomicU64::new(0),
2588            reload_gate: std::sync::Mutex::new(()),
2589            systemone: parking_lot::RwLock::new(systemone),
2590            vault: parking_lot::RwLock::new(vault),
2591            voice: std::sync::OnceLock::new(),
2592            voice_preview_error: std::sync::Mutex::new(None),
2593            catalog,
2594            app_support,
2595            pool: Mutex::new(HashMap::new()),
2596            pricing_fetcher: pricing_fetcher.unwrap_or_else(default_pricing_fetcher),
2597            #[cfg(any(test, feature = "testing"))]
2598            test_spawn_handle: Mutex::new(None),
2599            #[cfg(any(test, feature = "testing"))]
2600            test_spawn_server: Mutex::new(None),
2601            accounts,
2602            gateway,
2603            gateway_ready: std::sync::atomic::AtomicBool::new(false),
2604            shutting_down: std::sync::atomic::AtomicBool::new(false),
2605            gateway_port,
2606            gateway_url: Mutex::new(None),
2607            gateway_bind_error: Mutex::new(None),
2608            dictate: Arc::new(crate::dictate::DictateState::new(&config_dictate)),
2609            dictate_runtime: Mutex::new(crate::dictate::DictateRuntime::default()),
2610            browser_host: std::sync::OnceLock::new(),
2611            live_seats_lock: std::sync::Mutex::new(()),
2612            live_seats_generation: std::sync::atomic::AtomicU64::new(0),
2613            #[cfg(any(test, feature = "testing"))]
2614            live_seats_gate: Mutex::new(None),
2615            attached_clients: std::sync::atomic::AtomicUsize::new(0),
2616            document_handoffs: parking_lot::Mutex::new(HashMap::new()),
2617            dictate_catalogue: Mutex::new(crate::catalogue::CatalogueState::default()),
2618            dictate_install: Mutex::new(crate::install::InstallState::default()),
2619            plugin_cli: Mutex::new(crate::plugins::PluginCli::real()),
2620            plugin_run: Mutex::new(None),
2621            plugin_action_running: std::sync::atomic::AtomicBool::new(false),
2622            dictate_activate: Mutex::new(crate::install::ActivateState::default()),
2623            dictate_bench: Mutex::new(crate::bench::BenchState::default()),
2624            dictate_bench_cancel: std::sync::atomic::AtomicBool::new(false),
2625            speaking_bench: Mutex::new(crate::bench::SpeakingBenchState::default()),
2626            speaking_bench_cancel: std::sync::atomic::AtomicBool::new(false),
2627            bench_claim: std::sync::atomic::AtomicBool::new(false),
2628            read_aloud_error: Mutex::new(None),
2629            #[cfg(any(test, feature = "testing"))]
2630            test_catalogue_source: Mutex::new(None),
2631            #[cfg(any(test, feature = "testing"))]
2632            test_cleanup_source: Mutex::new(None),
2633            #[cfg(any(test, feature = "testing"))]
2634            test_catalogue_dir: Mutex::new(None),
2635            #[cfg(any(test, feature = "testing"))]
2636            test_read_aloud_dir: Mutex::new(None),
2637            update_tx,
2638            command_senders: Mutex::new(HashMap::new()),
2639            session_teardowns: Mutex::new(HashMap::new()),
2640            live_workers: Mutex::new(HashMap::new()),
2641            idle_sweeps: Mutex::new(HashMap::new()),
2642            worktree_claims: Arc::new(parking_lot::Mutex::new(HashMap::new())),
2643            despawn_cleanups: Mutex::new(HashSet::new()),
2644            spawn_failures: Mutex::new(HashMap::new()),
2645            deaths: Mutex::new(HashMap::new()),
2646            heal_progress: Mutex::new(HashMap::new()),
2647            healing: std::sync::OnceLock::new(),
2648            held_work_seats: crate::work::HeldSeats::default(),
2649            domain_handles: Mutex::new(HashMap::new()),
2650            review_origin: Mutex::new(HashMap::new()),
2651            review_activity: Mutex::new(HashMap::new()),
2652            supervisor: Arc::new(forge_supervise::Supervisor::new()),
2653            dictate_preflight_started: std::sync::atomic::AtomicBool::new(false),
2654            voice_prewarm_started: std::sync::atomic::AtomicBool::new(false),
2655            voice_prewarm_dispatched: std::sync::atomic::AtomicBool::new(false),
2656            kick_dispatcher_tx,
2657            kick_dispatcher_rx_slot: Mutex::new(Some(kick_dispatcher_rx)),
2658            _single_instance_lock: single_instance_lock,
2659            crons: Mutex::new(crons),
2660            crons_load_failed: std::sync::atomic::AtomicBool::new(crons_load_failed),
2661            tasks: Mutex::new(tasks),
2662            parked_by_slot: Mutex::new(HashMap::new()),
2663            pending_handoffs: Mutex::new(HashMap::new()),
2664            swapping: Mutex::new(HashMap::new()),
2665            swap_epochs: std::sync::atomic::AtomicU64::new(0),
2666            held_prompts: Mutex::new(HashMap::new()),
2667            gotify_subs: Mutex::new(gotify_subs),
2668            db,
2669            catalog_loaded,
2670            catalog_scan_started,
2671            cli_version,
2672            last_fatal_error: Mutex::new(None),
2673            service_status,
2674            service_status_probe_started: std::sync::atomic::AtomicBool::new(false),
2675            gotify_connected: Mutex::new(false),
2676            gotify_app_index: Mutex::new(HashMap::new()),
2677            gotify_subsystem: Mutex::new(None),
2678            slack,
2679            slack_subs: Mutex::new(slack_subs),
2680            slack_subsystem: Mutex::new(std::collections::BTreeMap::new()),
2681            slack_connected: Mutex::new(std::collections::BTreeMap::new()),
2682            slack_inflight: Mutex::new(std::collections::HashMap::new()),
2683            slack_user_ids: Mutex::new(std::collections::BTreeMap::new()),
2684            slack_user_names: Mutex::new(std::collections::BTreeMap::new()),
2685            slack_author_failures: Mutex::new(std::collections::HashSet::new()),
2686            slack_draft_user_failures: Mutex::new(std::collections::HashSet::new()),
2687            slack_thread_failures: Mutex::new(std::collections::HashSet::new()),
2688            slack_thread_full_reads: Mutex::new(std::collections::HashMap::new()),
2689            slack_thread_first_walks: Mutex::new(std::collections::HashSet::new()),
2690            slack_thread_edit_watermarks: Mutex::new(std::collections::HashMap::new()),
2691            slack_ledger_failures: Mutex::new(std::collections::HashSet::new()),
2692            slack_drafts: Mutex::new(HashMap::new()),
2693            browser_handoffs: Mutex::new(HashMap::new()),
2694            terminals: Mutex::new(HashMap::new()),
2695            terminal_handoffs: Mutex::new(HashMap::new()),
2696            next_terminal_id: std::sync::atomic::AtomicU16::new(1),
2697            settings_writes: Mutex::new(HashMap::new()),
2698            parked_vault_asks: Mutex::new(HashMap::new()),
2699            pending_seq: Mutex::new(HashMap::new()),
2700            slack_recently_delivered: Mutex::new(HashMap::new()),
2701            slack_load_failed: std::sync::atomic::AtomicBool::new(slack_load_failed),
2702            slack_user_id_retries: Mutex::new(std::collections::BTreeMap::new()),
2703            slack_verification_started: std::sync::atomic::AtomicBool::new(false),
2704            respawn_in_flight: Mutex::new(std::collections::HashSet::new()),
2705            unwakeable_crons: Mutex::new(std::collections::HashSet::new()),
2706            cooling_crons: Mutex::new(std::collections::HashMap::new()),
2707            #[cfg(any(test, feature = "testing"))]
2708            command_intercept: Mutex::new(None),
2709            #[cfg(any(test, feature = "testing"))]
2710            test_extra_projects: Mutex::new(Vec::new()),
2711            #[cfg(any(test, feature = "testing"))]
2712            test_user_preferences: Mutex::new(None),
2713            #[cfg(any(test, feature = "testing"))]
2714            test_claude_pid: Mutex::new(std::collections::HashMap::new()),
2715            #[cfg(any(test, feature = "testing"))]
2716            test_scan_gate: Mutex::new(None),
2717        };
2718        if let Some(prober) = cli_version_prober {
2719            workspace.start_cli_version_probe(prober);
2720        }
2721        if let Some(prober) = service_status_prober {
2722            workspace.start_service_status_probe(prober);
2723        }
2724        if workspace.db.lock().is_none() {
2725            // One user-visible notice for the whole best-effort-persist
2726            // class (durable crons, subscriptions): the
2727            // store is gone this run, so every one of those warns would
2728            // otherwise fire per-op into the log only.
2729            let _ = workspace.update_tx.send(SessionUpdate::ServiceStatus {
2730                severity: forge_primitives::cloud::service_status::ServiceSeverity::Warning,
2731                message: "Machine-local store unavailable this run; crons, tasks, Gotify and Slack subscriptions will not persist".to_owned(),
2732            });
2733        }
2734        Ok(workspace)
2735    }
2736
2737    /// Effective `[server]` settings: whether the socket's listener starts
2738    /// and where it binds. Read by the binary entry point, which serves the
2739    /// socket - the workspace never does.
2740    pub fn server_config(&self) -> forge_primitives::ServerConfig {
2741        self.config_arc().server.clone()
2742    }
2743
2744    /// The live config as one owned snapshot. A reload swaps the whole
2745    /// value, so every reader takes the snapshot it will use rather than a
2746    /// reference that a reload could pull out from under it.
2747    pub(crate) fn config_arc(&self) -> Arc<LoadedConfig> {
2748        Arc::clone(&self.config.read())
2749    }
2750
2751    /// Whether a spawn's resolution ran entirely inside one settled config:
2752    /// the generation is odd while a reload swaps state, so the start must
2753    /// be even and equal to the end.
2754    fn resolution_held(generation: u64, settled: u64) -> bool {
2755        generation.is_multiple_of(2) && generation == settled
2756    }
2757
2758    /// Re-read `<config_dir>/forge.toml` and apply what can change without
2759    /// a restart. A reload is a whole-config replacement: the parsed value
2760    /// goes through the same loader a boot uses, and only a valid file
2761    /// replaces the live config. An invalid file returns its error and
2762    /// leaves the previous config untouched, so a half-written save never
2763    /// loses a good config.
2764    ///
2765    /// What a reader sees after this returns equals what a boot on the same
2766    /// file would load. Running sessions keep what they spawned with; only
2767    /// their next use reads the new values.
2768    ///
2769    /// # Errors
2770    ///
2771    /// The file could not be read, parsed or validated, or the systemone
2772    /// client could not be built. Nothing was applied.
2773    pub fn reload_config(self: &Arc<Self>) -> Result<ConfigReload, WorkspaceError> {
2774        let next = crate::config::load_from_dir(&self.config_dir)?;
2775        let forge_toml = crate::config::forge_data_dir(&self.config_dir).join("forge.toml");
2776        let systemone =
2777            crate::systemone::client_for(next.systemone.as_ref(), &forge_toml)?.map(Arc::new);
2778        // The vault session is rebuilt only when its own section changed: a
2779        // config save that does not touch `[vault]` must not lock a live
2780        // vault and make the next tool call ask for the master password.
2781        let vault = match next.vault.as_ref() {
2782            Some(vault_config) if self.config.read().vault.as_ref() == Some(vault_config) => {
2783                self.vault.read().clone()
2784            }
2785            Some(vault_config) => Some(Arc::new(
2786                crate::vault::VaultSession::with_client(
2787                    vault_config,
2788                    self.app_support.as_deref(),
2789                    &self.config_dir,
2790                    crate::vault::production_client(&vault_config.url).map_err(|error| {
2791                        WorkspaceError::ConfigInvalid {
2792                            path: forge_toml.clone(),
2793                            message: format!("[vault] {error}"),
2794                        }
2795                    })?,
2796                    self.update_tx.clone(),
2797                )
2798                .map_err(|error| WorkspaceError::ConfigInvalid {
2799                    path: forge_toml.clone(),
2800                    message: format!("[vault] {error}"),
2801                })?,
2802            )),
2803            None => None,
2804        };
2805
2806        let previous = self.config.read().clone();
2807        let reload = previous.diff(&next);
2808        let added_accounts: Vec<forge_gateway::AccountKey> = next
2809            .accounts
2810            .iter()
2811            .filter(|account| {
2812                !previous.accounts.iter().any(|old| old.display_name == account.display_name)
2813            })
2814            .map(|account| forge_gateway::AccountKey(account.display_name.clone()))
2815            .collect();
2816
2817        // Odd while the swap runs. A spawn that resolves across it retries;
2818        // see `resolution_held`. **The gate is held for exactly that window**,
2819        // so a spawn that sees the odd number waits on it rather than
2820        // spinning on the counter.
2821        {
2822            let _settling =
2823                self.reload_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2824            self.reload_generation.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2825            self.accounts.replace_accounts(&next.accounts);
2826            self.gateway.set_org_pins(org_pins(&next));
2827            // The setter rebuilds the rotation state, which would empty the
2828            // live cooldowns and streaks. Only a live change to the numbers is
2829            // allowed to do that: a port-only reload reports `server.port` as
2830            // restart-only and must keep the state.
2831            if reload.changed.iter().any(|path| path.starts_with("gateway.")) {
2832                self.gateway.set_rotation_numbers(next.gateway_rotation);
2833            }
2834            *self.systemone.write() = systemone;
2835            *self.vault.write() = vault;
2836            *self.config.write() = Arc::new(next);
2837            self.reload_generation.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2838        }
2839        // The page reads the same catalogue the reload summary does, so its
2840        // frame follows every applied edit; a save that moved no drawn value
2841        // publishes nothing.
2842        self.publish_settings();
2843
2844        // A new account starts `Loading`; kick the same probe a boot runs
2845        // so it does not wait for the poller's next tick.
2846        for key in added_accounts {
2847            let weak = Arc::downgrade(self);
2848            let span = tracing::info_span!("account_loading", account = %key.0);
2849            tokio::spawn(
2850                async move { crate::account_loader::run_account_loading(key, weak).await }
2851                    .instrument(span),
2852            );
2853        }
2854
2855        // A project-only edit moves the roster, not the pool: the server
2856        // roster reads it on the next home read, and an `AccountsChanged`
2857        // frame here would claim a pool move that did not happen.
2858        if reload.moved("accounts.") {
2859            self.announce_accounts_changed();
2860        }
2861        Ok(reload)
2862    }
2863
2864    /// Effective `[client]` settings: which of the sets forge ships a
2865    /// client draws with. Carried by the greeting, so a client never reads
2866    /// `forge.toml` itself.
2867    pub fn client_config(&self) -> forge_primitives::ClientConfig {
2868        self.config_arc().client.clone()
2869    }
2870
2871    /// Name the build the settings frame carries, and publish the first
2872    /// document. The server calls this once at boot, before the socket
2873    /// serves; a second call keeps the first name.
2874    pub fn set_settings_version(&self, version: &str) {
2875        if self.settings_version.set(version.to_owned()).is_ok() {
2876            self.publish_settings();
2877        }
2878    }
2879
2880    /// The effective-config document as it stood at the last load. Every
2881    /// connection takes its own receiver; a reload that changed a drawn value
2882    /// sends a new one to all of them.
2883    pub fn subscribe_settings(
2884        &self,
2885    ) -> tokio::sync::watch::Receiver<Option<Arc<forge_primitives::SettingsDocument>>> {
2886        self.settings.subscribe()
2887    }
2888
2889    /// Apply a batched settings save to `forge.toml`.
2890    ///
2891    /// The file is read under the write lock, the page's fingerprint is
2892    /// compared with it, the edits are applied to its text, and the candidate
2893    /// is validated with the boot loader before an atomic replace. A save
2894    /// that fails validation writes nothing; the reload and its push are the
2895    /// watcher's, so every client learns the outcome from one frame.
2896    ///
2897    /// # Errors
2898    ///
2899    /// The file moved since the page loaded it, an edit or the candidate is
2900    /// not valid, a guard refused it, or the write failed.
2901    pub fn save_settings(
2902        &self,
2903        fingerprint: &str,
2904        edits: &[SettingsEditRequest],
2905    ) -> Result<(), SettingsSaveError> {
2906        let _one_at_a_time = self.settings_write.lock();
2907        let path = self.config_path();
2908        let candidate = self.settings_candidate(&path, fingerprint, edits)?;
2909        // **The file may have moved between the read and the write.**
2910        // `write_atomically` re-reads it after the temp exists and before
2911        // the rename, which is the last moment the check can run.
2912        write_atomically(&path, &candidate, Some(fingerprint)).map_err(|error| match error {
2913            WriteRefusal::Stale => SettingsSaveError::Stale,
2914            WriteRefusal::Io(error) => {
2915                SettingsSaveError::Io(format!("forge.toml could not be written: {error}"))
2916            }
2917        })
2918    }
2919
2920    /// Validate a save's candidate without writing it.
2921    ///
2922    /// The same checks the save runs, over the whole batch, so a caller can
2923    /// refuse a write the file would never accept before asking the reader
2924    /// about it. Not the authority: [`Self::save_settings`] repeats every
2925    /// check under its write lock.
2926    pub(crate) fn validate_settings(
2927        &self,
2928        fingerprint: &str,
2929        edits: &[SettingsEditRequest],
2930    ) -> Result<(), SettingsSaveError> {
2931        let path = self.config_path();
2932        self.settings_candidate(&path, fingerprint, edits).map(|_candidate| ())
2933    }
2934
2935    /// Build the save's candidate text and run it through every check short
2936    /// of the write.
2937    fn settings_candidate(
2938        &self,
2939        path: &std::path::Path,
2940        fingerprint: &str,
2941        edits: &[SettingsEditRequest],
2942    ) -> Result<String, SettingsSaveError> {
2943        let current = std::fs::read_to_string(path).map_err(|error| {
2944            SettingsSaveError::Io(format!("forge.toml could not be read: {error}"))
2945        })?;
2946        if crate::config::fingerprint_of(&current) != fingerprint {
2947            return Err(SettingsSaveError::Stale);
2948        }
2949        let config = self.config_arc();
2950        let version = self.settings_version.get().map(String::as_str).unwrap_or_default();
2951        let document = crate::settings::document(&config, version, path);
2952
2953        // **The live-session and loading-account guards run before the
2954        // loader.** A removal that empties an org fails the loader's own
2955        // "no projects" rule, and the guard's refusal owes the reader first;
2956        // the departures are read off the batch itself here, then read again
2957        // from the loaded candidate below.
2958        let (departed_projects, departed_accounts) =
2959            departures_from_edits(&config, edits, &document);
2960        self.refuse_departures(&config, &departed_projects, &departed_accounts)?;
2961
2962        // A project's tree must be there before the save lands: the loader
2963        // accepts any string, and a row pointing at nothing is a project
2964        // that can never start. An add carries the path in its value; a set
2965        // on the key replaces it. `~` expands as the loader expands it.
2966        for edit in edits {
2967            let path_value = match edit.op {
2968                SettingsEditOp::Add if edit.area == "projects" => {
2969                    edit.value.as_ref().and_then(|value| value.get("path"))
2970                }
2971                SettingsEditOp::Set
2972                    if document.areas.iter().flat_map(|area| &area.keys).any(|key| {
2973                        key.path == edit.key_path && key.pattern == "orgs[].projects[].path"
2974                    }) =>
2975                {
2976                    edit.value.as_ref()
2977                }
2978                _ => continue,
2979            };
2980            let named = path_value.and_then(serde_json::Value::as_str);
2981            let holds = named.is_some_and(|path| crate::config::expand_home(path).is_dir());
2982            if !holds {
2983                return Err(SettingsSaveError::Refused {
2984                    message: format!(
2985                        "a project needs a path that is a directory; `{}` is not",
2986                        named.unwrap_or("<unset>"),
2987                    ),
2988                    key_path: Some(edit.key_path.clone()),
2989                });
2990            }
2991        }
2992
2993        let candidate = crate::settings_write::apply(&current, edits, &document)
2994            .map_err(SettingsSaveError::from)?;
2995
2996        let after = match crate::config::load_from_text(path.to_path_buf(), &candidate) {
2997            Ok(config) => config,
2998            Err(error) => {
2999                // Anchor the refusal at the first edit that fails on its own;
3000                // the loader's own words carry the line and key it found.
3001                for edit in edits {
3002                    let Ok(one) = crate::settings_write::apply(
3003                        &current,
3004                        std::slice::from_ref(edit),
3005                        &document,
3006                    ) else {
3007                        continue;
3008                    };
3009                    if let Err(one_error) = crate::config::load_from_text(path.to_path_buf(), &one)
3010                    {
3011                        return Err(SettingsSaveError::Invalid {
3012                            message: one_error.to_string(),
3013                            key_path: Some(edit.key_path.clone()),
3014                        });
3015                    }
3016                }
3017                return Err(SettingsSaveError::Invalid {
3018                    message: error.to_string(),
3019                    key_path: None,
3020                });
3021            }
3022        };
3023
3024        // **The guards are identity diffs, not op checks.** An entry the
3025        // save leaves absent (removed, or renamed through `name` /
3026        // `display_name`) is what the reload strands: a project's sessions
3027        // keep running but leave every roster, and a retired account still
3028        // loading leaves `all_accounts_loaded` false for the process's life
3029        // (#2681). Both are refused by name.
3030        let after_projects: HashSet<(String, String)> = after
3031            .projects
3032            .iter()
3033            .map(|project| (project.org.clone(), project.name.clone()))
3034            .collect();
3035        let after_accounts: HashSet<String> =
3036            after.accounts.iter().map(|account| account.display_name.clone()).collect();
3037        self.refuse_departures(&config, &after_projects, &after_accounts)?;
3038
3039        if candidate == current {
3040            return Err(SettingsSaveError::Refused {
3041                message: "the file already matches this save; nothing was written".to_owned(),
3042                key_path: None,
3043            });
3044        }
3045        Ok(candidate)
3046    }
3047
3048    /// Refuse a save that drops an entry something live depends on.
3049    fn refuse_departures(
3050        &self,
3051        before: &LoadedConfig,
3052        after_projects: &HashSet<(String, String)>,
3053        after_accounts: &HashSet<String>,
3054    ) -> Result<(), SettingsSaveError> {
3055        let before_projects: HashSet<(String, String)> = before
3056            .projects
3057            .iter()
3058            .map(|project| (project.org.clone(), project.name.clone()))
3059            .collect();
3060        let mut departed: Vec<&(String, String)> =
3061            before_projects.difference(after_projects).collect();
3062        departed.sort();
3063        for (org, project) in departed {
3064            let live = self.live_sessions_of(org, project);
3065            if !live.is_empty() {
3066                return Err(SettingsSaveError::Refused {
3067                    message: format!(
3068                        "project `{org} / {project}` has live sessions: {}; close them before \
3069                         removing or renaming it",
3070                        live.join(", "),
3071                    ),
3072                    key_path: None,
3073                });
3074            }
3075        }
3076
3077        let before_accounts: HashSet<String> =
3078            before.accounts.iter().map(|account| account.display_name.clone()).collect();
3079        let mut departed: Vec<&String> = before_accounts.difference(after_accounts).collect();
3080        departed.sort();
3081        let held = self.accounts.ordered_account_keys();
3082        for name in departed {
3083            let key = AccountKey((*name).clone());
3084            if held.contains(&key)
3085                && self.accounts.loading_state(&key) == forge_gateway::LoadingState::Loading
3086            {
3087                return Err(SettingsSaveError::Refused {
3088                    message: format!(
3089                        "account `{name}` is still loading its usage; the save refuses \
3090                         until its probe settles",
3091                    ),
3092                    key_path: None,
3093                });
3094            }
3095        }
3096        Ok(())
3097    }
3098
3099    /// The sessions a project currently has live, by their slots' display
3100    /// names. A removal refuses while any exists: the reload leaves a live
3101    /// session running but invisible.
3102    fn live_sessions_of(&self, org: &str, project: &str) -> Vec<String> {
3103        let mut live: Vec<String> = self
3104            .pool
3105            .lock()
3106            .keys()
3107            .chain(self.domain_handles.lock().keys())
3108            .filter(|slot| slot.org() == org && slot.project() == project)
3109            .map(SessionSlot::display)
3110            .collect();
3111        live.sort_unstable();
3112        live.dedup();
3113        live
3114    }
3115
3116    /// The effective-config document as the socket holds it, built from the
3117    /// live config where the server has not published one yet (a test
3118    /// workspace, or the first moments before the greeting). The settings
3119    /// MCP tools read this one document, so a key they report and the value
3120    /// the page draws cannot disagree.
3121    pub(crate) fn settings_snapshot(&self) -> Arc<forge_primitives::SettingsDocument> {
3122        if let Some(document) = self.settings.borrow().clone() {
3123            return document;
3124        }
3125        let version = self.settings_version.get().map(String::as_str).unwrap_or_default();
3126        Arc::new(crate::settings::document(&self.config_arc(), version, &self.config_path()))
3127    }
3128
3129    /// Build the document from the live config and publish it when it
3130    /// differs from what the socket already holds.
3131    fn publish_settings(&self) {
3132        let version = self.settings_version.get().map(String::as_str).unwrap_or_default();
3133        let config = self.config_arc();
3134        let document = Arc::new(crate::settings::document(&config, version, &self.config_path()));
3135        if self.settings.borrow().as_ref() != Some(&document) {
3136            self.settings.send_replace(Some(document));
3137        }
3138    }
3139
3140    /// The server browser's own knobs: `[browser]`'s encoding rate and
3141    /// quality, and its idle release's two bounds. Absent section keeps the
3142    /// defaults the struct carries.
3143    pub fn browser_settings(&self) -> crate::BrowserSettings {
3144        self.config_arc().browser
3145    }
3146
3147    /// The profile names `forge.toml` resolved: each org's, and each
3148    /// project's override keyed by `(org, project)`. **Project names are
3149    /// unique inside an org**, so the pair is the key the fleet resolves a
3150    /// seat against.
3151    pub fn browser_profile_config(
3152        &self,
3153    ) -> (
3154        std::collections::HashMap<String, String>,
3155        std::collections::HashMap<(String, String), String>,
3156    ) {
3157        let config = self.config_arc();
3158        let orgs = config.org_browser_profiles.clone();
3159        let projects = config
3160            .projects
3161            .iter()
3162            .map(|project| {
3163                (
3164                    (project.org.clone(), project.name.clone()),
3165                    project.browser_profile.name().to_owned(),
3166                )
3167            })
3168            .collect();
3169        (orgs, projects)
3170    }
3171
3172    /// The ceiling on one handed document, in bytes: `[documents]
3173    /// max_size_mib` over the crate's own default. Read by the document
3174    /// read, which refuses a document over it.
3175    pub fn document_max_bytes(&self) -> u64 {
3176        self.config_arc().document_max_bytes
3177    }
3178
3179    /// Note a client connection attached, and give it back when it goes.
3180    /// The transport's per-connection guard calls these.
3181    pub fn note_client_attached(&self) {
3182        self.attached_clients.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3183    }
3184
3185    /// The counterpart of [`Self::note_client_attached`].
3186    pub fn note_client_detached(&self) {
3187        self.attached_clients.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
3188    }
3189
3190    /// Whether any client connection is attached. Only a client that draws a
3191    /// document viewer counts: a hand-off's ask needs one that does.
3192    pub(crate) fn client_attached(&self) -> bool {
3193        self.attached_clients.load(std::sync::atomic::Ordering::Relaxed) > 0
3194    }
3195
3196    /// Install the server's browser fleet. **Boot-time and once**: the fleet
3197    /// outlives every session, and a second install would be two browsers
3198    /// answering one tool surface.
3199    pub fn install_browser_host(&self, host: Arc<dyn crate::browser::BrowserHost>) {
3200        let _ = self.browser_host.set(host);
3201    }
3202
3203    /// The browser fleet, when this workspace has one.
3204    pub fn browser_host(&self) -> Option<Arc<dyn crate::browser::BrowserHost>> {
3205        self.browser_host.get().cloned()
3206    }
3207
3208    /// The push-to-talk key from forge.toml `[dictate] bind`. The value
3209    /// rides the seat record a client last read, so a reload reaches the
3210    /// key handler on the next seat read rather than the next event.
3211    pub fn dictate_bind(&self) -> crate::dictate::DictateBind {
3212        self.config_arc().dictate.bind
3213    }
3214
3215    /// How press/release maps onto starting and stopping a take, from
3216    /// forge.toml `[dictate] mode`. It rides the seat record like `bind`,
3217    /// so a reload reaches the key handler on the next seat read.
3218    pub fn dictate_mode(&self) -> crate::dictate::DictateMode {
3219        self.config_arc().dictate.mode
3220    }
3221
3222    /// Whether dictation is on at all. The key handler reads this so a
3223    /// press with `[dictate]` disabled is dead rather than a refusal:
3224    /// the box doc's S0 is "nothing at all", and with the section
3225    /// absent every Cmd chord would otherwise pop an error.
3226    pub fn dictate_enabled(&self) -> bool {
3227        self.config_arc().dictate.enabled
3228    }
3229
3230    /// The `[plugins]` auto-update policy from forge.toml. Read by the
3231    /// plugins pane for the boot auto-update gate and the pane's
3232    /// auto-update state display.
3233    pub fn plugin_settings(&self) -> crate::config::PluginSettings {
3234        self.config_arc().plugins.clone()
3235    }
3236
3237    /// Remember plugin updates applied by a pane run or by boot
3238    /// auto-update: the visible record of what moved, from where, and
3239    /// the ref a rollback restores. One transaction for the whole
3240    /// batch. A no-op with a warn when the store is closed.
3241    pub fn record_plugin_updates(&self, records: &[forge_primitives::plugins::PluginUpdateRecord]) {
3242        if let Some(db) = self.db.lock().as_ref()
3243            && let Err(error) = crate::store::plugins::record_updates(db, records)
3244        {
3245            tracing::warn!(
3246                target: "forge_workspace::workspace",
3247                count = records.len(),
3248                error = %error,
3249                "failed to persist the plugin update records",
3250            );
3251        }
3252    }
3253
3254    /// Every remembered plugin update, latest write per installed
3255    /// entry. Empty when the store is closed or unreadable.
3256    pub fn plugin_update_records(&self) -> Vec<forge_primitives::plugins::PluginUpdateRecord> {
3257        self.db
3258            .lock()
3259            .as_ref()
3260            .and_then(|db| crate::store::plugins::update_records(db).ok())
3261            .map(|records| records.into_values().collect())
3262            .unwrap_or_default()
3263    }
3264
3265    /// Forget the record for one installed entry after its rollback
3266    /// ran: the previous version it named is now current.
3267    pub fn clear_plugin_update_record(&self, plugin_id: &str, scope: &str) {
3268        if let Some(db) = self.db.lock().as_ref()
3269            && let Err(error) = crate::store::plugins::clear_update_record(db, plugin_id, scope)
3270        {
3271            tracing::warn!(
3272                target: "forge_workspace::workspace",
3273                plugin = %plugin_id,
3274                error = %error,
3275                "failed to clear a plugin update record",
3276            );
3277        }
3278    }
3279
3280    /// The projects a boot opens, in forge.toml order.
3281    ///
3282    /// With no project named, that is every configured project whose lead
3283    /// row is open (`idle` or `in_turn`) - the seats the user last had
3284    /// open. A closed lead is left shut, and a project whose lead never
3285    /// spawned has no row to open. A seat whose turn was cut off carries
3286    /// the restart note; a parked one is resumed with nothing.
3287    ///
3288    /// `only` names one project explicitly, which opens whether its row is
3289    /// open or closed: naming it is the user's own act, and closing is not
3290    /// forgetting. A name no project declares is returned as itself so the
3291    /// spawn handler reports it like any other unknown project.
3292    ///
3293    /// A project renamed in `forge.toml` starts with no row, because the
3294    /// row is keyed by its org and name: the rename is the same act that
3295    /// loses the stored session id, and there is nothing stable underneath
3296    /// to key by instead.
3297    pub fn boot_wave_targets(&self, only: Option<&str>) -> Vec<BootWaveTarget> {
3298        let config = self.config_arc();
3299        let mut targets = Vec::new();
3300        for project in &config.projects {
3301            if only.is_some_and(|name| project.name != name) {
3302                continue;
3303            }
3304            let slot = SessionSlot::lead(&project.org, &project.name);
3305            // The migration seeds a row written before the field `idle`,
3306            // so an absent state after it means the row itself is absent:
3307            // this lead has never spawned. A seat left open reopens; a
3308            // closed one does so only when named, and neither has an
3309            // interrupted turn to report unless the row says `in_turn`.
3310            let state = self.stored_seat_state(&slot);
3311            if only.is_some() || state.is_some_and(crate::store::sessions::SeatState::is_open) {
3312                let resume_kick = match state {
3313                    Some(crate::store::sessions::SeatState::InTurn) => {
3314                        Some(LEAD_RESTART_NOTE.to_owned())
3315                    }
3316                    _ => None,
3317                };
3318                targets.push(BootWaveTarget { project_name: project.name.clone(), resume_kick });
3319            }
3320        }
3321        if let (Some(name), true) = (only, targets.is_empty()) {
3322            // No configured project carries the name. Return it so the
3323            // spawn handler answers with the same unknown-project refusal a
3324            // named launch has always given.
3325            targets.push(BootWaveTarget { project_name: name.to_owned(), resume_kick: None });
3326        }
3327        targets
3328    }
3329
3330    /// Every project listed in `forge.toml`, each carrying its catalog
3331    /// sessions sorted by last-activity descending - `sessions[0]` is
3332    /// the lead. Empty `sessions` means the project has nothing on
3333    /// disk yet; the project still surfaces in the returned Vec.
3334    pub fn list_projects(&self) -> Vec<ProjectView> {
3335        // A catalog row is a transcript file, named by the id the CLI
3336        // wrote it under, so openness is answered by the ids forge
3337        // currently holds rather than by the row's slot.
3338        let open_sessions: std::collections::HashSet<String> =
3339            self.pool.lock().values().map(|entry| entry.session_id.clone()).collect();
3340
3341        // One catalog acquire for the whole walk - the loop body is
3342        // a HashMap lookup + bounded cloning over owned session info
3343        // and never re-enters the catalog, so holding the lock for
3344        // the duration is cheap. The prior per-iteration
3345        // acquire/drop showed up as the dominant hot spot in
3346        // `ui::render` under projects with ~14 entries.
3347        let catalog = self.catalog.lock();
3348        // Iterate config.projects, with the test-only overlay appended
3349        // so unit tests can seed projects via `seed_test_project`
3350        // and exercise paths that read `list_projects` (matches the
3351        // `project_root_for_key` / `find_project_view_by_name` overlay
3352        // pattern).
3353        #[cfg(any(test, feature = "testing"))]
3354        let extra = self.test_extra_projects.lock();
3355        let config = self.config_arc();
3356        let project_iter: Box<dyn Iterator<Item = &LoadedProject>> = {
3357            #[cfg(any(test, feature = "testing"))]
3358            {
3359                Box::new(config.projects.iter().chain(extra.iter()))
3360            }
3361            #[cfg(not(any(test, feature = "testing")))]
3362            {
3363                Box::new(config.projects.iter())
3364            }
3365        };
3366        let mut views = Vec::with_capacity(config.projects.len());
3367        for project in project_iter {
3368            let key =
3369                ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(
3370                    Some(&project.path.to_string_lossy()),
3371                ));
3372
3373            let sessions: Vec<SessionView> = catalog
3374                .get(&key)
3375                .map(|entries| {
3376                    entries
3377                        .iter()
3378                        .map(|info| SessionView {
3379                            session: forge_primitives::SessionId::new(info.session_id.clone()),
3380                            label: info.summary.clone(),
3381                            is_open: open_sessions.contains(&info.session_id),
3382                            last_activity: Some(
3383                                UNIX_EPOCH + Duration::from_millis(info.last_modified),
3384                            ),
3385                        })
3386                        .collect()
3387                })
3388                .unwrap_or_default();
3389
3390            views.push(ProjectView {
3391                key,
3392                name: project.name.clone(),
3393                org: project.org.clone(),
3394                path: project.path.clone(),
3395                display_path: project.display_path.clone(),
3396                accounts: project.accounts.clone(),
3397                fallback_accounts: project.fallback_accounts.clone(),
3398                has_model: project.model.is_some(),
3399                sessions,
3400            });
3401        }
3402        views
3403    }
3404
3405    /// The handoff trigger this seat runs under: the project's table over the
3406    /// top-level one, with the defaults under both.
3407    pub(crate) fn handoff_policy_for(&self, slot: &SessionSlot) -> crate::config::HandoffPolicy {
3408        let project = self.project_for_slot(slot).and_then(|project| project.handoff);
3409        let config = self.config_arc();
3410        crate::config::HandoffPolicy::resolve(&config.handoff, project.as_ref())
3411    }
3412
3413    /// Validate that `name` matches a project in `forge.toml`. Used
3414    /// by callers (e.g. the server's boot) to fail fast on an unknown
3415    /// CLI positional arg before setup, rather than surface the
3416    /// same error later via [`Self::get_agent_handle`].
3417    pub fn validate_project_name(&self, name: &str) -> Result<(), WorkspaceError> {
3418        self.find_project_by_name(name).map(|_| ())
3419    }
3420
3421    /// Stamp [`LEAD_DELEGATION_PREAMBLE`] onto a Lead session's launch
3422    /// settings. No-op for a worker.
3423    fn apply_lead_delegation(settings: &mut SessionLaunchSettings, kind: crate::mcp::SessionKind) {
3424        if matches!(kind, crate::mcp::SessionKind::Lead) {
3425            settings.delegation_preamble = Some(LEAD_DELEGATION_PREAMBLE.to_owned());
3426        }
3427    }
3428
3429    /// The slot a spawn fills, from the role its caller stated. The label
3430    /// is used as given: a worker must never be resolved through the
3431    /// registry, which answers with the lead's slot once its entry is
3432    /// gone, and nothing recovers either answer from the key's shape - a
3433    /// key that looks like a worker is not evidence, and a project named
3434    /// `worker_foo` once classified as a worker for exactly that reason.
3435    /// The slot a spawn's role states under `project`. The one source
3436    /// for both a session's tool surface and its address, so the two
3437    /// cannot disagree.
3438    fn slot_for_spawn(
3439        role: &crate::protocol::SpawnRole,
3440        project: &LoadedProject,
3441    ) -> crate::SessionSlot {
3442        match role {
3443            crate::protocol::SpawnRole::Lead => {
3444                crate::SessionSlot::lead(&project.org, &project.name)
3445            }
3446            crate::protocol::SpawnRole::Worker { label, .. } => {
3447                crate::SessionSlot::worker(&project.org, &project.name, label)
3448            }
3449        }
3450    }
3451
3452    /// Hands out the `Arc<AgentHandle>` for the requested session,
3453    /// spawning the underlying Agent lazily if it isn't already pooled.
3454    /// Idempotent - repeated calls for the same target return the same
3455    /// handle (no second subprocess). `settings` only apply to the
3456    /// spawn; subsequent calls reuse the existing Agent and ignore the
3457    /// parameter.
3458    ///
3459    /// Each fresh spawn runs the gateway's account selection and
3460    /// stamps the chosen account's env onto the spawned `claude`
3461    /// subprocess, so it talks to the gateway listener rather than
3462    /// upstream. Selection state lives in the in-memory usage cache;
3463    /// nothing about account choice is persisted across forge
3464    /// launches.
3465    ///
3466    /// Workspace does not track which handle the caller is "using" -
3467    /// that's the caller's concern.
3468    /// `role` is what the caller states this session to be. There is no
3469    /// keyless form: a role forge cannot state is one it would have to
3470    /// guess from the live-worker registry, which answers with the lead's
3471    /// slot for a worker whose entry is gone.
3472    pub fn get_agent_handle(
3473        self: &Arc<Self>,
3474        target: SessionTarget,
3475        settings: SessionLaunchSettings,
3476        role: &crate::protocol::SpawnRole,
3477    ) -> Result<Arc<AgentHandle>> {
3478        self.get_agent_handle_at_key(target, settings, None, role, true)
3479    }
3480
3481    /// Like [`Self::get_agent_handle`] but takes the key the caller
3482    /// already resolved, so the pool entry lands under the same key the
3483    /// caller announced. `None` for callers that have not resolved one.
3484    ///
3485    /// `role` is what the caller states this spawn to be. It is the one
3486    /// source for both the tool surface and the slot's label, so a worker
3487    /// cannot be handed a lead's tool surface or a lead's address.
3488    ///
3489    /// `reopen` is the opening door's word: a user's click, the boot wave
3490    /// and a cron wake open the seat they name, while the heal's recovery
3491    /// of a death must leave a seat the reader closed shut.
3492    pub(crate) fn get_agent_handle_at_key(
3493        self: &Arc<Self>,
3494        target: SessionTarget,
3495        mut settings: SessionLaunchSettings,
3496        resolved_key: Option<SessionSlot>,
3497        role: &crate::protocol::SpawnRole,
3498        reopen: bool,
3499    ) -> Result<Arc<AgentHandle>> {
3500        // Every session forge launches passes through here, so the tools
3501        // forge has replaced are denied at this one point rather than at
3502        // each spawn entry - a fourth entry cannot forget them. The role
3503        // states the kind, and the slot it is checked against below cannot
3504        // disagree with it.
3505        let spawn_kind = match role {
3506            crate::protocol::SpawnRole::Lead => crate::mcp::SessionKind::Lead,
3507            crate::protocol::SpawnRole::Worker { .. } => crate::mcp::SessionKind::Worker,
3508        };
3509        // `interactive` is passed as `true` because this point cannot know
3510        // it, and the question denial must not be decided wrongly here: a
3511        // worker's own spawn writes that name when the flag calls for it,
3512        // and the merge below leaves it in place.
3513        crate::spawn::apply_blocked_tools(&mut settings, spawn_kind, true);
3514        // A shutdown is tearing sessions down and starts none: a session
3515        // that began behind its drain would sit outside the set the exit
3516        // waits on, and its tree would outlive forge.
3517        if self.is_shutting_down() {
3518            return Err(forge_sdk::Error::Connection {
3519                reason: "forge is shutting down; no new session can start".to_owned(),
3520            }
3521            .into());
3522        }
3523        // The boot gate is a spawn precondition, not just a launchpad
3524        // decoration: a child stamped before the listener is bound
3525        // points at a base URL nothing answers.
3526        if !self.gateway_ready.load(std::sync::atomic::Ordering::Acquire) {
3527            return Err(forge_sdk::Error::Connection {
3528                reason: format!(
3529                    "the gateway listener on port {} is not ready; the boot gate is shut, so \
3530                     the session would be stamped at a base URL nothing serves",
3531                    self.gateway_port
3532                ),
3533            }
3534            .into());
3535        }
3536        // The caller may already have resolved the slot - the spawn
3537        // handlers do, because the bucket they announce under
3538        // `Spawning` has to be the one the child connects under.
3539        let session_slot = match resolved_key {
3540            Some(slot) => slot,
3541            None => self.resolve_slot(&target)?,
3542        };
3543        // The id the child runs under. `--session-id` and `--resume` are
3544        // the CLI's own arguments and stay ids; the store row records it
3545        // under the slot before the child starts, so a restart resolves
3546        // the same occupant from the same slot.
3547        let stored = self.stored_resume_id(&session_slot, settings.force_new)?;
3548        let resuming = stored.is_some();
3549        // One shared config dir: every account's child reads the same
3550        // MCP servers, plugins and settings, so the per-account dir is
3551        // gone along with the field that carried it.
3552        let account_dir = self.config_dir.clone();
3553
3554        // Fast path: cache hit, but only while a session task still
3555        // consumes the slot. A pooled handle a task has left behind
3556        // answers nothing - the seat takes no prompt and no child starts -
3557        // and handing it back made every later spawn fast-path a seat that
3558        // could never start again (#2683).
3559        let pooled = {
3560            let pool = self.pool.lock();
3561            pool.get(&session_slot)
3562                .map(|existing| (Arc::clone(&existing.handle), existing.session_id.clone()))
3563        };
3564        if let Some((existing, session_id)) = pooled {
3565            if self.pooled_handle_has_task(&session_slot) {
3566                return Ok(existing);
3567            }
3568            tracing::info!(
3569                target: "forge_workspace::workspace",
3570                event_name = "pooled_handle_without_task",
3571                slot = %session_slot.display(),
3572                occupant = %session_id,
3573                "a pooled handle has no session task behind it; rebuilding the session",
3574            );
3575            self.release_session_if_current(&session_slot, &existing);
3576        }
3577
3578        // Resolve which account this spawn lands under: the walk over
3579        // the accounts declaring the project's model, which is the same
3580        // walk the gateway runs on a session's first request. Both the
3581        // project and its model are required - without a project there
3582        // is no org, no pin and no model for the walk to run over, and
3583        // an unregistered spawn would hand the child the account's real
3584        // credential and a direct base URL.
3585        // Resolving a project and selecting its account reads two different
3586        // snapshots (the config and the pool). A reload between them would
3587        // pair an old pin with the new pool, so the generation is read
3588        // before and re-checked after; a straddled resolution retries.
3589        let (project, account_key) = loop {
3590            let generation = self.reload_generation.load(std::sync::atomic::Ordering::Acquire);
3591            if !generation.is_multiple_of(2) {
3592                // The swap holds `reload_gate` from odd to even, so taking it
3593                // is waiting for the generation to settle - no spin.
3594                drop(self.reload_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
3595                continue;
3596            }
3597            let Some(project) = self.project_for_target(&target) else {
3598                return Err(WorkspaceError::SpawnResolvesToNoProject {
3599                    slot: session_slot.display(),
3600                }
3601                .into());
3602            };
3603            // The role and the slot are two statements of the same thing -
3604            // the tool surface comes from the first and the address from
3605            // the second - so a caller that states them differently is
3606            // refused rather than handed a worker's address with a lead's
3607            // tools.
3608            if Self::slot_for_spawn(role, &project) != session_slot {
3609                return Err(WorkspaceError::SpawnRoleSlotDisagree {
3610                    role: role.clone(),
3611                    slot: session_slot.display(),
3612                }
3613                .into());
3614            }
3615            // The gateway's walk is the one place a session's account is
3616            // decided: it looks the org's pin up, filters cooling accounts
3617            // and walks the accounts declaring the model. The spawn only
3618            // registers the answer, and that registration is what binds.
3619            let Some(model) = project.model.as_deref() else {
3620                return Err(WorkspaceError::ProjectModelMissing {
3621                    project: project.name.clone(),
3622                    org: project.org.clone(),
3623                }
3624                .into());
3625            };
3626            let account_key = self.select_account_for_project(&project, model)?;
3627            let settled = self.reload_generation.load(std::sync::atomic::Ordering::Acquire);
3628            if Self::resolution_held(generation, settled) {
3629                break (project, account_key);
3630            }
3631        };
3632        // The account and model checks have passed, so this spawn is
3633        // committed: record the id its lead will run under, either the
3634        // one the store held or the one minted in
3635        // `lead_session_key_for`. Nothing is written before this point -
3636        // a refusal ahead of it would pin an id no child ever adopts,
3637        // and the next boot would resume onto it.
3638        // **One rollback for everything between the id record and the pool
3639        // insert.** Any refusal in between - the loader, a missing project,
3640        // the bind - would otherwise leave a live-looking domain handle and
3641        // an id no child ever adopted.
3642        let mut rollback = SpawnRollback {
3643            workspace: Arc::clone(self),
3644            slot: session_slot.clone(),
3645            stored: stored.clone(),
3646            // Read before the spawn's own `idle` write, so a refusal puts
3647            // back the state the row held rather than the one the spawn set.
3648            prior_seat_state: self.stored_seat_state(&session_slot),
3649            domain_created: false,
3650            armed: true,
3651        };
3652        // **The opening door is the caller's word.** Naming a closed
3653        // project, or a wake that names it, runs this path with `reopen`
3654        // set and the seat it starts is open; every other seat-state write,
3655        // the heal's included, leaves a closed row closed.
3656        let session_id = self.record_spawn_id(&session_slot, stored.clone(), reopen);
3657        tracing::info!(
3658            target: "forge_workspace::account",
3659            slot = %session_slot.display(),
3660            session_id = %session_id,
3661            account = %account_key.0,
3662            "spawn bound to account",
3663        );
3664
3665        // Slow path: spawn a fresh Agent bound to the workspace's
3666        // shared config_dir. The Agent stores it as a typed field;
3667        // every in-process accessor (oauth, settings, catalog scans)
3668        // reads it from there, and the spawned `claude` subprocess
3669        // inherits it as `CLAUDE_CONFIG_DIR` so each session reads/
3670        // writes the right account's user-data tree.
3671        let account_env = self.accounts.env(&account_key).unwrap_or_default();
3672        apply_project_permission_mode(Some(&project), &mut settings);
3673        let project_permission_mode = Some(project.permission_mode);
3674        // The project env merges BEFORE the gateway registers: the
3675        // stamp must land last, or a project env carrying a
3676        // base-url key would point the child away from the listener
3677        // while it still holds the dummy credential - the silent bypass
3678        // the stamp exists to close, reopened one layer up.
3679        let merged_env = session_env_for(&project, &account_env);
3680        // Register the session and let the gateway stamp the child's
3681        // env: the base URL names this listener, the credential the
3682        // child holds is a dummy, and the real one stays in the
3683        // gateway.
3684        let registration = self.accounts.provider(&account_key).map(|provider| {
3685            forge_gateway::binding::Registration {
3686                org: project.org.clone(),
3687                project: project.name.clone(),
3688                session: session_id.clone(),
3689                account: account_key.clone(),
3690                provider,
3691            }
3692        });
3693        let mut session_env = match &registration {
3694            Some(registration) => self
3695                .gateway
3696                .bindings
3697                .register(registration, &self.gateway_listener_url(), &merged_env)
3698                .into_map(),
3699            None => merged_env,
3700        };
3701        // The project's model fills the CLI's model slots: one model
3702        // for everything the CLI does - main, subagents, background
3703        // slots. A /model change re-seats the primary immediately; the
3704        // slots follow on the next respawn.
3705        if let Some(model) = project.model.as_ref() {
3706            for var in MODEL_SLOT_VARIABLES {
3707                session_env.insert((*var).to_owned(), model.clone());
3708            }
3709            // And the parameters the model's `[models]` table carries,
3710            // for the same reason the slots do: they describe this model,
3711            // so they follow the project's pin. An env layer that already
3712            // names the key is the user's own word on it and wins.
3713            let config = self.config_arc();
3714            if let Some(window) =
3715                config.models.get(model).and_then(|parameters| parameters.auto_compact_window)
3716                && !session_env.contains_key(AUTO_COMPACT_WINDOW_ENV)
3717            {
3718                session_env.insert(AUTO_COMPACT_WINDOW_ENV.to_owned(), window.to_string());
3719            }
3720        }
3721        // Telling the CLI the project's model is what stops the
3722        // caller's pin - forge defaults it to the literal "opus" - from
3723        // resolving through an account's alias mapping into a model the
3724        // project never declared.
3725        apply_project_model(Some(&project), &mut settings);
3726
3727        // Hoist DomainSession creation to BEFORE Agent::spawn. A caller
3728        // may have registered one at `session_key` already (the cron
3729        // and gotify delivery paths register a worker's domain before
3730        // its spawn); reuse it when present so the registrant keeps its
3731        // handle reference. Otherwise create
3732        // fresh with conn = None and update post-Agent::spawn at line
3733        // ~470.
3734        let (domain_arc, domain_created) = {
3735            let mut handles = self.domain_handles.lock();
3736            // A DomainSession is often already registered at
3737            // `session_key` (the cron and gotify delivery paths register
3738            // a worker's domain before dispatching its spawn). Reuse it,
3739            // so whoever registered it keeps the handle reference it was
3740            // given; otherwise create fresh with conn = None and fill
3741            // the handle in after `Agent::spawn`. A handle this spawn
3742            // created is removed again if the bind refuses.
3743            if let Some(existing) = handles.get(&session_slot).cloned() {
3744                (existing, false)
3745            } else {
3746                let fresh = Arc::new(Mutex::new(DomainSession::new(session_slot.clone(), None)));
3747                handles.insert(session_slot.clone(), Arc::clone(&fresh));
3748                (fresh, true)
3749            }
3750        };
3751        rollback.domain_created = domain_created;
3752        // Carry `--new` onto the domain so a project lead's
3753        // Connected-time respawn skips the store lookup and brings its
3754        // workers up fresh alongside the fresh lead.
3755        domain_arc.lock().spawned_force_new = settings.force_new;
3756        // The boot wave's restart note belongs to this spawn only when it
3757        // actually resumed; a fresh spawn (`--new` included) clears what a
3758        // previous spawn left.
3759        domain_arc.lock().pending_lead_resume_note =
3760            resume_kick_for(resuming, settings.resume_kick.take());
3761        // The effort this spawn asked for, so a session whose hook has
3762        // not reported one yet still answers with the level it runs at.
3763        domain_arc.lock().configured_effort =
3764            crate::domain_session::configured_effort_from_settings(&settings);
3765        // The mode, the same way: forge stamps its effective default into
3766        // the launch settings, so this is the mode the session was spawned
3767        // in rather than a guess.
3768        domain_arc.lock().configured_permission_mode =
3769            crate::domain_session::configured_permission_mode_from_settings(&settings);
3770        // Carry the row's provenance the same way: the tag-write
3771        // rollback runs long after this spawn returned and needs to know
3772        // whether the row it would delete is this spawn's to take.
3773        domain_arc.lock().spawn_wrote_row =
3774            matches!(role, crate::protocol::SpawnRole::Worker { wrote_row: true, .. });
3775
3776        // Build the per-session `forge` MCP server. ONE server name;
3777        // tool surface depends on whether this spawn is for a project
3778        // lead or a worker. Both kinds reach any other session by its
3779        // slot; a lead additionally gets the four verbs that act on its
3780        // own project (spawn / despawn / update / capacity). See
3781        // `crate::mcp::SessionKind` for the rationale.
3782        // One source for both answers: the slot's label decides the kind,
3783        // so a worker's tool surface and a worker's address cannot
3784        // disagree.
3785        let session_kind = if session_slot.is_lead() {
3786            crate::mcp::SessionKind::Lead
3787        } else {
3788            crate::mcp::SessionKind::Worker
3789        };
3790        let forge_server = {
3791            let workspace_facade = crate::mcp::peers::facade::ProdWorkspaceFacade::from_arc(self);
3792            let worker_facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(self);
3793            let browser_facade =
3794                crate::mcp::browser::facade::ProdBrowserFacade::from_workspace(self);
3795            let terminal_facade =
3796                crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(self);
3797            let documents_facade =
3798                crate::mcp::documents::facade::ProdDocumentFacade::from_workspace(self);
3799            let review_facade = crate::mcp::review::facade::ProdReviewFacade::from_arc(self);
3800            let cron_facade = crate::mcp::cron::facade::ProdCronFacade::from_arc(self);
3801            let gotify_facade = crate::mcp::gotify::facade::ProdGotifyFacade::from_arc(self);
3802            let slack_facade = crate::mcp::slack::facade::ProdSlackFacade::from_arc(self);
3803            let settings_facade = crate::mcp::settings::facade::ProdSettingsFacade::from_arc(self);
3804            let tasks_facade = crate::mcp::tasks::facade::ProdTasksFacade::from_arc(self);
3805            let systemone_facade = self.systemone.read().as_ref().map(|client| {
3806                crate::mcp::systemone::facade::ProdSystemOneFacade::new(Arc::clone(client))
3807                    .into_arc()
3808            });
3809            let families = match role {
3810                crate::protocol::SpawnRole::Lead => crate::mcp::McpFamily::all(),
3811                crate::protocol::SpawnRole::Worker { mcp_families, .. } => {
3812                    crate::mcp::resolve_mcp_families(mcp_families.as_deref())
3813                }
3814            };
3815            crate::mcp::build_forge_server(
3816                crate::mcp::ForgeServerFacades {
3817                    workspace: workspace_facade,
3818                    worker: worker_facade,
3819                    browser: browser_facade,
3820                    terminal: terminal_facade,
3821                    documents: documents_facade,
3822                    review: review_facade,
3823                    cron: cron_facade,
3824                    gotify: gotify_facade,
3825                    slack: slack_facade,
3826                    settings: settings_facade,
3827                    tasks: tasks_facade,
3828                    systemone: systemone_facade,
3829                    voice: self.voice_service(),
3830                    vault: self.vault.read().as_ref().map(|_| {
3831                        crate::mcp::vault::facade::ProdVaultFacade::from_arc(self).into_arc()
3832                    }),
3833                },
3834                &families,
3835                &session_slot,
3836                session_kind,
3837            )
3838        };
3839        record_session_tool_surface(
3840            &session_slot,
3841            session_slot.is_lead(),
3842            &forge_server.tool_names(),
3843        );
3844
3845        #[cfg(any(test, feature = "testing"))]
3846        {
3847            // The stand-in below replaces the call the server would have been
3848            // handed to, so this is the only way a test sees the one the spawn
3849            // composed.
3850            *self.test_spawn_server.lock() = Some(forge_server.clone());
3851        }
3852        let spawn_agent = || {
3853            forge_agent::Agent::spawn(
3854                account_dir.clone(),
3855                Some(account_key.0.clone()),
3856                vec![("forge".to_owned(), forge_server)],
3857                session_env,
3858            )
3859        };
3860        // A test that installed a stand-in reads the settings this spawn
3861        // hands its child, which nothing else can observe.
3862        #[cfg(any(test, feature = "testing"))]
3863        let handle = self.take_test_spawn_handle().unwrap_or_else(spawn_agent);
3864        #[cfg(not(any(test, feature = "testing")))]
3865        let handle = spawn_agent();
3866        // Project-rooted targets (`Default` / `Named`) resume the
3867        // project's lead session when the on-disk catalog has one,
3868        // and fall back to a fresh session in that project's cwd
3869        // otherwise. Pool key = lead's session id from the catalog
3870        // so it stays consistent with the running session id.
3871        // A project-rooted target (`Default` / `Named`) resumes the
3872        // project's lead session when its row holds an id, and starts a
3873        // fresh session under a minted one otherwise. The id is the
3874        // CLI's argument, so it is named here rather than derived.
3875        match target {
3876            SessionTarget::Default | SessionTarget::Named(_) => {
3877                let project = self.project_for_target(&target).ok_or_else(|| {
3878                    WorkspaceError::ProjectNotFound {
3879                        name: session_slot.project().to_owned(),
3880                        path: crate::config::forge_data_dir(&self.config_dir).join("forge.toml"),
3881                    }
3882                })?;
3883                Self::apply_lead_delegation(&mut settings, session_kind);
3884                let cwd = project.path.to_string_lossy().to_string();
3885                if resuming {
3886                    handle.resume_or_new_session(session_id.clone(), cwd, settings)?;
3887                } else {
3888                    handle.new_session(Some(session_id.clone()), cwd, settings)?;
3889                }
3890            }
3891            SessionTarget::Session(slot) => {
3892                let cwd = self.resume_cwd_for_slot(&slot);
3893                handle.resume_session(session_id.clone(), cwd, settings)?;
3894            }
3895            SessionTarget::FreshInProject { cwd, .. } => {
3896                // Worker spawn: a fresh session in the tree the spawn
3897                // resolved - its own `cwd` when it stated one, else the
3898                // project's. Skip the lead-resume path so each worker
3899                // runs under the id its caller already minted and
3900                // recorded.
3901                let cwd = cwd.as_ref().map_or_else(
3902                    || project.path.to_string_lossy().to_string(),
3903                    |cwd| cwd.to_string_lossy().into_owned(),
3904                );
3905                handle.new_session(Some(session_id.clone()), cwd, settings)?;
3906            }
3907        }
3908        // The session is up; whatever is buffered on it belongs to the live
3909        // SessionTask to drain on Connected.
3910        let arc = Arc::new(handle);
3911
3912        // Insert: a concurrent caller's live session wins the slot and hands
3913        // its handle back (ours drops at end-of-scope); an entry whose task
3914        // is gone is stale and this spawn replaces it. Single-user scope
3915        // makes the race effectively impossible, so the check is about the
3916        // stale entry a crashed task left, not about two live spawns.
3917        //
3918        // **A session may not bind to a project the config no longer
3919        // holds.** A save's identity diff and this bind are serialized on
3920        // the settings write lock: either the save lands first (this
3921        // refuses, and the child drops at end of scope) or the session is
3922        // pooled first (the save's guard sees it and refuses). Without
3923        // that, a spawn resolving mid-save leaves a session running but
3924        // off every roster.
3925        let (cmd_rx, teardown_done, replaced_sender) = {
3926            let _settings = self.settings_write.lock();
3927            let held = self.config_arc();
3928            // **A save that has landed but that the watcher has not reloaded
3929            // yet is not in `held`.** The file's own fingerprint says the
3930            // config moved; the file's projects are then the set a bind must
3931            // check, or a spawn in the reload window binds to a project the
3932            // reload is about to remove.
3933            let path = self.config_path();
3934            let text = std::fs::read_to_string(&path).ok();
3935            let current = match text
3936                .as_ref()
3937                .filter(|text| crate::config::fingerprint_of(text) != held.raw_fingerprint)
3938            {
3939                Some(text) => Some(crate::config::load_from_text(path, text)?),
3940                None => None,
3941            };
3942            let present = match current.as_ref() {
3943                Some(config) => config.projects.iter().any(|project| {
3944                    project.org == session_slot.org() && project.name == session_slot.project()
3945                }),
3946                None => held.projects.iter().any(|project| {
3947                    project.org == session_slot.org() && project.name == session_slot.project()
3948                }),
3949            };
3950            if !present {
3951                // The guard rolls this spawn's side effects back on the way
3952                // out, or the project reads live for ever and a boot resumes
3953                // onto a transcript that never existed.
3954                return Err(WorkspaceError::SpawnResolvesToNoProject {
3955                    slot: session_slot.display(),
3956                }
3957                .into());
3958            }
3959            let mut pool = self.pool.lock();
3960            // **A non-opening spawn stops at a close that landed after its
3961            // own start.** The heal's `fire` reads `Closed` before it
3962            // calls in, and the close can still land while the spawn
3963            // resolves its account. Checked here, in the block that
3964            // inserts the pool entry under the same lock the close's
3965            // release takes: the close either writes `Closed` before this
3966            // read (refused now) or removes the entry this spawn inserts.
3967            if !reopen
3968                && matches!(
3969                    self.stored_seat_state(&session_slot),
3970                    Some(crate::store::sessions::SeatState::Closed)
3971                )
3972            {
3973                arc.kill_process_group();
3974                return Err(
3975                    WorkspaceError::SeatClosedDuringSpawn { slot: session_slot.display() }.into()
3976                );
3977            }
3978            if let Some(existing) = pool.get(&session_slot) {
3979                // A concurrent spawn won the slot: its handle is the one to
3980                // hand back, but only while a task consumes it. A handle
3981                // whose task is gone is this spawn's to replace, the same
3982                // stale entry the fast path refuses above. **Race-only**: the
3983                // fast path reads the same state, so no single-threaded test
3984                // can reach this branch without a seam; it exists for the
3985                // entry another spawn inserted while this one built a handle.
3986                if self.pooled_handle_has_task(&session_slot) {
3987                    rollback.armed = false;
3988                    return Ok(Arc::clone(&existing.handle));
3989                }
3990            }
3991            // **Registered before the entry is visible, so a pooled handle
3992            // never exists without a task behind it.** The teardown signal
3993            // and the command sender are what a later spawn reads as "a task
3994            // consumes this slot"; inserting the pool entry first left a
3995            // window where a concurrent spawn released the winner and both
3996            // tasks ran (#2683). The order is the invariant and no test can
3997            // observe its violation: the entry goes in last, under the lock
3998            // the readers take. A shutdown here starts no session, and the
3999            // child this spawn already started goes with it.
4000            let Some(teardown_done) = self.mint_session_teardown(&session_slot) else {
4001                arc.kill_process_group();
4002                return Err(forge_sdk::Error::Connection {
4003                    reason: "forge is shutting down; the session was not started".to_owned(),
4004                }
4005                .into());
4006            };
4007            let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Command>();
4008            let replaced_sender =
4009                self.command_senders.lock().insert(session_slot.clone(), cmd_tx).is_some();
4010            pool.insert(
4011                session_slot.clone(),
4012                PooledAgent {
4013                    handle: Arc::clone(&arc),
4014                    account: account_key.clone(),
4015                    permission_mode: project_permission_mode,
4016                    registration: registration.clone(),
4017                    session_id: session_id.clone(),
4018                },
4019            );
4020            // The session is pooled: its id and domain are its own now.
4021            rollback.armed = false;
4022            (cmd_rx, teardown_done, replaced_sender)
4023        };
4024        if replaced_sender {
4025            tracing::info!(
4026                target: "forge_workspace::workspace",
4027                event_name = "orphaned_command_sender_replaced",
4028                slot = %session_slot.display(),
4029                "a command sender had no live task behind it; the rebuilt session takes the slot",
4030            );
4031        }
4032        {
4033            // `domain_arc` was hoisted above the spawn. Stamp the live
4034            // `Arc<AgentHandle>` onto its `conn` slot
4035            // now that the handle exists. Whoever holds the
4036            // `Arc<Mutex<...>>` from before the spawn keeps reading
4037            // from the same one - no second `domain_session_for`
4038            // round-trip after the spawn lands.
4039            domain_arc.lock().conn = Some(Arc::clone(&arc));
4040            let domain = Arc::clone(&domain_arc);
4041            let task = SessionTask {
4042                key: session_slot,
4043                handle: Arc::clone(&arc),
4044                command_rx: cmd_rx,
4045                domain,
4046                update_tx: self.update_tx.clone(),
4047                // Every spawn now emits Connected on its first connect:
4048                // nothing replaces a live session's agent in-process any
4049                // more, so there is no SessionReplaced case to seed.
4050                connected_once: false,
4051                workspace: Arc::downgrade(self),
4052                // Filled by the first `Connected`, which is where this task
4053                // is handed the history it can later replay.
4054                compaction_count: 0,
4055                successor: None,
4056                teardown_done: Some(teardown_done),
4057            };
4058            let span = tracing::info_span!(
4059                "session_task",
4060                slot = %task.key.display(),
4061            );
4062            tokio::spawn(task.run().instrument(span));
4063        }
4064
4065        Ok(arc)
4066    }
4067
4068    /// Crate-internal accessor for the boot-time loading task to
4069    /// drive `LoadingState` transitions on the workspace's account
4070    /// map. Not exposed beyond the crate - the field stays private
4071    /// so only intentional callers reach in.
4072    pub(crate) fn account_pool(&self) -> &forge_gateway::AccountPool {
4073        &self.accounts
4074    }
4075
4076    /// The gateway's bind failure, if the listener could not start.
4077    /// The launchpad surfaces this instead of "loading accounts" so a
4078    /// dead bind does not read as an endless wait.
4079    pub fn gateway_bind_error(&self) -> Option<String> {
4080        self.gateway_bind_error.lock().clone()
4081    }
4082
4083    /// Whether the gateway listener has bound its port. The preflight
4084    /// screen's gateway row renders this.
4085    pub fn gateway_ready(&self) -> bool {
4086        self.gateway_ready.load(std::sync::atomic::Ordering::Acquire)
4087    }
4088
4089    /// The port the gateway's listener binds.
4090    pub fn gateway_port(&self) -> u16 {
4091        self.gateway_port
4092    }
4093
4094    /// The base URL children are stamped with: the listener's BOUND
4095    /// address, not the config-derived one - the config names the port
4096    /// that was asked for, the listener names the port that was bound,
4097    /// and the stamp must carry the one that answers.
4098    fn gateway_listener_url(&self) -> String {
4099        match self.gateway_url.lock().clone() {
4100            Some(url) => url,
4101            // Unreachable behind the boot gate: the gate is open only
4102            // after the listener stored its bound address.
4103            None => format!("http://127.0.0.1:{}", self.gateway_port),
4104        }
4105    }
4106
4107    /// `true` when every `[[accounts]]` entry has reached a terminal
4108    /// `LoadingState` AND the gateway's listener is bound. The boot wave
4109    /// and the cron spawn path gate on this - spawning while an account
4110    /// is still resolving means the walk runs over a partial map, and
4111    /// spawning before the listener is bound stamps a base URL nothing
4112    /// answers - and a view reads it for its accounts card. Public so a
4113    /// view can read the pool's settledness without reaching into the
4114    /// crate-private account map.
4115    pub fn all_accounts_loaded(&self) -> bool {
4116        self.accounts.all_loaded() && self.gateway_ready.load(std::sync::atomic::Ordering::Acquire)
4117    }
4118
4119    /// Whether a spawn in `project_key` would find an account: the
4120    /// gateway's walk over the project's declared model, which is what a
4121    /// spawn runs and what a project row's refusal reads. `false` for a
4122    /// project without a model, or one whose org serves nothing it
4123    /// declares, which is the refusal a row draws.
4124    pub fn project_would_bind(&self, project_key: &ProjectKey) -> bool {
4125        let Some(project) = self.project_for_key(project_key) else {
4126            return false;
4127        };
4128        let Some(model) = project.model.as_deref() else {
4129            return false;
4130        };
4131        self.gateway.select_for(&project.org, model).is_ok()
4132    }
4133
4134    /// The cooling that would refuse a spawn in `project_key`, or `None`
4135    /// when a spawn would find an account.
4136    ///
4137    /// Matches the walk's budget failure alone, rather than taking any
4138    /// error, because that failure is the one carrying a reset to wait
4139    /// for - and only a refusal that clears on its own is owed a
4140    /// deferral. The walk's other failure is the config-level one, and
4141    /// `forge.toml` refuses a project whose model no account in its org
4142    /// serves (`ProjectModelUndeclared`), so it is not reachable from a
4143    /// loaded config today. Keep the match narrow anyway: an empty walk
4144    /// with nothing to wait for must fail as before rather than retry
4145    /// forever, and this is the line that says so.
4146    pub(crate) fn project_walk_cooling(&self, project_key: &ProjectKey) -> Option<ProjectCooling> {
4147        let project = self.project_for_key(project_key)?;
4148        let model = project.model.as_deref()?;
4149        match self.gateway.select_for(&project.org, model) {
4150            Err(forge_gateway::SelectFailure::BudgetExhausted { tried, org, model, reset_in }) => {
4151                Some(ProjectCooling { org, model, tried, reset_in })
4152            }
4153            _ => None,
4154        }
4155    }
4156
4157    /// Snapshot of `(AccountKey display name, LoadingState)` pairs in
4158    /// declaration order, read by the accounts surface. The order
4159    /// matches `forge.toml`'s `[[accounts]]` declarations so the rows
4160    /// sit next to the user's mental model of which-account-is-which.
4161    pub fn account_loading_snapshot(&self) -> Vec<AccountLoadingRow> {
4162        self.accounts
4163            .account_names()
4164            .into_iter()
4165            .map(|display_name| {
4166                let key = AccountKey(display_name.clone());
4167                let last_error = self.accounts.usage_error(&display_name);
4168                let retry_after = self.accounts.next_probe_after(&key);
4169                let auth = self.accounts.auth(&display_name);
4170                AccountLoadingRow {
4171                    display_name,
4172                    state: self.accounts.loading_state(&key),
4173                    last_error,
4174                    retry_after,
4175                    auth: auth.unwrap_or(crate::views::AccountAuth::Token),
4176                    at_cap: self.accounts.is_saturated(&key),
4177                    cooling: self.gateway.is_cooling(&key),
4178                }
4179            })
4180            .collect()
4181    }
4182
4183    /// The account pool as a view draws it: the rows the home snapshot
4184    /// carries, and the payload [`SessionUpdate::AccountsChanged`] pushes.
4185    ///
4186    /// **One builder for both paths**, so a client folding the frame and a
4187    /// client reading the snapshot draw the same pool.
4188    pub fn account_pool_view(&self) -> crate::views::AccountPoolView {
4189        let loading = self.account_loading_snapshot();
4190        let usage = loading
4191            .iter()
4192            .map(|row| crate::views::AccountUsageView {
4193                display_name: row.display_name.clone(),
4194                snapshot: self.usage_for(&row.display_name),
4195            })
4196            .collect();
4197        crate::views::AccountPoolView {
4198            loading,
4199            all_loaded: self.all_accounts_loaded(),
4200            gateway: crate::views::AccountGatewayView {
4201                ready: self.gateway_ready(),
4202                port: self.gateway_port(),
4203                bind_error: self.gateway_bind_error(),
4204            },
4205            usage,
4206        }
4207    }
4208
4209    /// How the account called `display_name` authenticates - the input
4210    /// the auth-repair hints branch on. `None` when the name isn't a
4211    /// configured account.
4212    pub fn account_auth_for(&self, display_name: &str) -> Option<crate::views::AccountAuth> {
4213        self.accounts.auth(display_name)
4214    }
4215
4216    /// The account name back, when that account is saturated or bailed:
4217    /// the walk takes one of those only when nothing else in the pin
4218    /// declares the project's model, so a spawn landing there is worth
4219    /// saying out loud. `None` when it has room, and for an unknown name.
4220    pub(crate) fn degraded_account_name(&self, account: &str) -> Option<String> {
4221        let key = AccountKey(account.to_owned());
4222        let degraded = self.accounts.is_saturated(&key)
4223            || self.accounts.loading_state(&key) == forge_gateway::LoadingState::Bailed;
4224        degraded.then(|| account.to_owned())
4225    }
4226
4227    /// The `forge.toml` this workspace loaded. Preflight names it as
4228    /// one of the two ways past an account that will not authenticate;
4229    /// an edit applies to new work as the file changes. The other is
4230    /// repairing the account's own credentials, which a poller picks up
4231    /// in place without one.
4232    pub fn config_path(&self) -> PathBuf {
4233        crate::config::forge_data_dir(&self.config_dir).join("forge.toml")
4234    }
4235
4236    /// The config dir this workspace owns: `forge.toml`'s parent dir, and
4237    /// the dir every session's child runs under, so a session's transcript
4238    /// lives in its `projects/` tree.
4239    pub fn config_dir(&self) -> &Path {
4240        &self.config_dir
4241    }
4242
4243    /// Per-model dictation progress for the preflight screen. Empty
4244    /// `models` means `[dictate] enabled` is false and preflight has no
4245    /// Dictation section to draw.
4246    pub fn dictate_snapshot(&self) -> crate::dictate::DictateSnapshot {
4247        self.dictate.snapshot.lock().clone()
4248    }
4249
4250    /// Where the dictation models land. `None` when the platform has no
4251    /// usable cache directory and none was configured.
4252    pub fn dictate_models_dir(&self) -> Option<PathBuf> {
4253        self.config_arc().dictate.models_dir()
4254    }
4255
4256    /// The connection that was streaming a take for `key` has gone: the
4257    /// take is DROPPED, not submitted - its reader is gone, so nothing it
4258    /// produced would land anywhere, and stopping it now spends nothing
4259    /// further on it.
4260    ///
4261    /// Only a take by THIS connection is closed: an entry with no sink
4262    /// is not a connection's to close, and a take that ended with
4263    /// another one started since is not this connection's to close. The
4264    /// seat is free the moment this returns, so a client that reconnects
4265    /// and starts again is not refused by the take it left behind.
4266    /// Answers whether there was one to close.
4267    pub fn dictate_close(&self, key: &SessionSlot, initiator: u64) -> bool {
4268        let mut runtime = self.dictate_runtime.lock();
4269        let live = runtime
4270            .recordings
4271            .get(key)
4272            .is_some_and(|live| live.sink.is_some() && live.initiator == Some(initiator));
4273        if live {
4274            let Some(take) = runtime.recordings.remove(key) else {
4275                return false;
4276            };
4277            // `false` is the abandon the runner already honours for Esc:
4278            // the capture is released and its audio is transcribed no
4279            // further. A channel nobody is reading yet still takes the
4280            // value.
4281            let _ = take.stop.try_send(false);
4282            return true;
4283        }
4284        // A take already submitted is still this connection's to cancel:
4285        // its transcript would have no reader left either.
4286        if let Some(take) = runtime
4287            .finishing
4288            .iter()
4289            .find(|take| &take.key == key && take.initiator == Some(initiator))
4290        {
4291            let _ = take.stop.try_send(false);
4292            return true;
4293        }
4294        false
4295    }
4296
4297    /// The dictate axes in force: `forge.toml` over the crate's own
4298    /// defaults. What a capturing client starts on and resets to.
4299    pub fn dictate_axes(&self) -> crate::dictate::DictateAxes {
4300        self.config_arc().dictate.axes()
4301    }
4302
4303    /// Push one frame of client-captured audio into the seat's live take,
4304    /// answering whether the samples were kept.
4305    ///
4306    /// **The take must be `initiator`'s own.** A frame carries no seat, and
4307    /// a seat's live take can be another connection's - decision 3's own
4308    /// scenario, where a client whose start was refused still has its
4309    /// microphone open for the frames already on the wire - so a push from
4310    /// any other connection is dropped rather than landing in someone
4311    /// else's dictation. `false` also means the seat has no live take
4312    /// (never started, refused, or already resolved) or the take has
4313    /// stopped (the cap, or the speaker let go). A caller drops the frame
4314    /// rather than holding it, because the take's own answer is what a
4315    /// reader sees either way.
4316    pub fn dictate_push(&self, key: &SessionSlot, samples: &[f32], initiator: Option<u64>) -> bool {
4317        let runtime = self.dictate_runtime.lock();
4318        runtime.frame_sink_for(key, initiator).is_some_and(|sink| sink.push_mono(samples))
4319    }
4320
4321    /// Stop the in-flight model fetch. Whatever reached the disk stays
4322    /// there as a `.part`, so the next run resumes; the snapshot then
4323    /// carries [`crate::DictateFailure::Cancelled`] and forge quits,
4324    /// because there is no dictation-less runtime to fall back to.
4325    pub fn cancel_dictate_preflight(&self) {
4326        self.dictate.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
4327    }
4328
4329    /// Fetch, verify and load the dictation models. One task per forge
4330    /// run, started beside the account loaders; a no-op when dictation
4331    /// is switched off.
4332    ///
4333    /// **This is the prewarm the run keeps resident**: the engine it parks
4334    /// in the state serves every take, so a press pays no model load, and
4335    /// the task it runs on is spawned rather than awaited, so none of the
4336    /// fetch, the verify or the load is on the boot path. The one event it
4337    /// writes says which of the two ended it.
4338    ///
4339    /// The active models are resolved first - the config key, the runtime
4340    /// pick, or the compiled pin, per role - and the resolution's failure
4341    /// is the same stop a failed fetch is, naming the config key that
4342    /// could not be answered.
4343    pub fn start_dictate_preflight(self: &Arc<Self>) {
4344        // Before the gate below, so the flag reads "the wave asked for the
4345        // prewarm" and a second call cannot start a second one.
4346        if self.dictate_preflight_started.swap(true, std::sync::atomic::Ordering::AcqRel) {
4347            return;
4348        }
4349        let settings = self.config_arc().dictate.clone();
4350        if !settings.enabled {
4351            return;
4352        }
4353        let state = Arc::clone(&self.dictate);
4354        let this = Arc::clone(self);
4355        let updates = self.update_sender();
4356        let span = tracing::info_span!("dictate_preflight");
4357        tokio::spawn(
4358            async move {
4359                let started = std::time::Instant::now();
4360                let resolved = match this.resolve_active(&settings).await {
4361                    Ok(resolved) => resolved,
4362                    Err(message) => {
4363                        tracing::warn!(
4364                            event_name = "dictate_prewarm_failed",
4365                            failure = %message,
4366                            "no dictation model is resident; a take will be refused"
4367                        );
4368                        state.fail(crate::dictate::DictateFailure::Other { message }, None);
4369                        let models = this.dictate_models();
4370                        let _ = updates.send(SessionUpdate::DictateModelsChanged { models });
4371                        return;
4372                    }
4373                };
4374                state.set_active(&resolved);
4375                // The page's rows move with the resolution: a role the
4376                // config or a runtime pick names is not the pin it read a
4377                // moment ago.
4378                let models = this.dictate_models();
4379                let _ = updates.send(SessionUpdate::DictateModelsChanged { models });
4380
4381                let cfg = crate::dictate::preflight_config(&settings, &resolved);
4382                crate::dictate::run_dictate_preflight(cfg, Arc::clone(&state)).await;
4383                // `run_dictate_preflight` parks the engine in the state
4384                // only on success, and every failure path ends the run,
4385                // so a held engine is the whole availability signal.
4386                if state.engine.lock().is_some() {
4387                    let files: Vec<&str> =
4388                        resolved.iter().map(|(_, model)| model.spec.file.as_str()).collect();
4389                    tracing::info!(
4390                        event_name = "dictate_prewarm_ready",
4391                        elapsed_ms = started.elapsed().as_millis(),
4392                        models = ?files,
4393                        "the dictation models are resident; a take pays no load"
4394                    );
4395                    // A variant the config resolved from the feed is on
4396                    // disk now: record it so the page and the bench see it.
4397                    this.record_resolved_installs(&resolved);
4398                    let _ = updates.send(SessionUpdate::DictateAvailability);
4399                } else if let Some(failure) = &state.snapshot.lock().failure {
4400                    // The screen carries this for a view; the log is where a
4401                    // headless run reads it.
4402                    if failure.is_cancelled() {
4403                        tracing::debug!(
4404                            event_name = "dictate_prewarm_cancelled",
4405                            "the prewarm was stopped before it finished"
4406                        );
4407                    } else {
4408                        tracing::warn!(
4409                            event_name = "dictate_prewarm_failed",
4410                            failure = ?failure,
4411                            "no dictation model is resident; a take will be refused"
4412                        );
4413                    }
4414                }
4415            }
4416            .instrument(span),
4417        );
4418    }
4419
4420    /// Resolve `project_key` to the per-session chip the Projects pane
4421    /// renders next to each row: the account a spawn in it would land
4422    /// on, and that account's current visual-state category. Returns
4423    /// `None` when the project is unknown, declares no model, or its
4424    /// org serves nothing it declares.
4425    ///
4426    /// The walk is per org and model, so every row in a project shows
4427    /// the same account - which is what a spawn does.
4428    ///
4429    /// State derivation:
4430    /// - `LoadingState::Bailed` splits by the recorded failure class,
4431    ///   the same split the account rows render: auth failures
4432    ///   (`Unauthorized` / `Expired`) -> `SessionChipState::Bailed`
4433    ///   (red with a `⚠ ` prefix); transient classes and an unrecorded
4434    ///   bail -> `Degraded` (warning yellow, the pollers heal it).
4435    /// - `Ready` + any usage window at the cap (the same
4436    ///   `is_saturated` signal the walk demotes an account for) ->
4437    ///   `AtCap` (yellow; the session still spawns but will throttle).
4438    /// - Every account serving the model inside a rotation cooldown ->
4439    ///   `RateLimited`, naming the account whose cooldown lifts first:
4440    ///   the one the first spawn after the thaw lands on. The walk still
4441    ///   refuses a spawn; only this read looks past the cooldown.
4442    /// - Otherwise -> `Normal` (DIM; default chip).
4443    pub fn session_chip_for(&self, project_key: &ProjectKey) -> Option<SessionChipInfo> {
4444        let project = self.project_for_key(project_key)?;
4445        let model = project.model.as_deref()?;
4446        let account_key = match self.gateway.select_for(&project.org, model) {
4447            Ok(key) => key,
4448            Err(forge_gateway::SelectFailure::BudgetExhausted { .. }) => {
4449                let key = self.gateway.cooling_account_serving(&project.org, model)?;
4450                return Some(SessionChipInfo {
4451                    account_name: key.0,
4452                    state: SessionChipState::RateLimited,
4453                });
4454            }
4455            Err(_) => return None,
4456        };
4457
4458        let loading = self.accounts.loading_state(&account_key);
4459        let saturated = self.accounts.is_saturated(&account_key);
4460        let last_error = self.accounts.usage_error(&account_key.0);
4461
4462        let state = match loading {
4463            forge_gateway::LoadingState::Bailed => match last_error {
4464                Some(
4465                    forge_gateway::UsageFetchStatus::Unauthorized
4466                    | forge_gateway::UsageFetchStatus::Expired,
4467                ) => SessionChipState::Bailed,
4468                _ => SessionChipState::Degraded,
4469            },
4470            forge_gateway::LoadingState::Ready if saturated => SessionChipState::AtCap,
4471            _ => SessionChipState::Normal,
4472        };
4473
4474        Some(SessionChipInfo { account_name: account_key.0, state })
4475    }
4476
4477    /// The account the gateway walks for a project's declared model,
4478    /// with the walk's failures turned into the vocabulary a spawn
4479    /// reports.
4480    fn select_account_for_project(
4481        &self,
4482        project: &LoadedProject,
4483        model: &str,
4484    ) -> Result<AccountKey, anyhow::Error> {
4485        use forge_gateway::SelectFailure;
4486        self.gateway.select_for(&project.org, model).map_err(|failure| match failure {
4487            SelectFailure::NoEligibleAccount { org, model } => {
4488                WorkspaceError::NoAccountServesProjectModel {
4489                    project: project.name.clone(),
4490                    org,
4491                    model,
4492                    accounts: project
4493                        .accounts
4494                        .iter()
4495                        .chain(project.fallback_accounts.iter())
4496                        .cloned()
4497                        .collect::<Vec<String>>()
4498                        .join(", "),
4499                }
4500                .into()
4501            }
4502            SelectFailure::BudgetExhausted { org, model, reset_in, .. } => {
4503                WorkspaceError::AllAccountsCooling {
4504                    project: project.name.clone(),
4505                    org,
4506                    model,
4507                    seconds: reset_in.as_secs(),
4508                }
4509                .into()
4510            }
4511            SelectFailure::UnknownOrg { org } => anyhow::anyhow!(
4512                "project '{}' names org '{org}', which the gateway holds no pin for",
4513                project.name
4514            ),
4515        })
4516    }
4517
4518    /// Spawn one boot-time loading task per `[[accounts]]` entry in
4519    /// `forge.toml`. Each task runs the per-account loading state
4520    /// machine (in the crate-private `account_loader` module) until
4521    /// the account reaches a terminal `LoadingState` (`Ready` or
4522    /// `Bailed`). Called once at startup, replacing the older
4523    /// "single-probe-then-poll" boot model with the explicit loading
4524    /// gate that the launchpad now consults via
4525    /// `AccountStateMap::all_loaded()`.
4526    pub fn start_account_loading_tasks(self: &Arc<Self>) {
4527        for key in self.accounts.ordered_account_keys() {
4528            let span = tracing::info_span!("account_loading", account = %key.0);
4529            let weak = Arc::downgrade(self);
4530            tokio::spawn(
4531                async move {
4532                    crate::account_loader::run_account_loading(key, weak).await;
4533                }
4534                .instrument(span),
4535            );
4536        }
4537    }
4538
4539    /// Enqueue a worker kick on the dispatcher channel (#259). The
4540    /// drainer task fires one `Command::Prompt` per
4541    /// `KICK_DISPATCH_INTERVAL`, so simultaneous boot-time kicks
4542    /// across a team of N workers spread out as N × INTERVAL instead
4543    /// of all hitting Anthropic's per-IP burst limit in the same tick.
4544    ///
4545    /// Send errors (channel closed - workspace has shut down) are
4546    /// logged at `error` because they signal a kick was dropped after
4547    /// `maybe_kick_worker_on_connected` already decided to send. The
4548    /// worker stays idle in that case; this is rare in practice
4549    /// (workspace shutdown drains all sessions before the kick path
4550    /// could fire), but the log line preserves diagnosability.
4551    pub(crate) fn enqueue_kick(&self, request: KickRequest) {
4552        if let Err(err) = self.kick_dispatcher_tx.send(request) {
4553            tracing::error!(
4554                target: "forge_workspace::workspace",
4555                error = %err,
4556                "enqueue_kick: channel closed (workspace shutting down?); kick dropped",
4557            );
4558        }
4559    }
4560
4561    /// Spawn the worker-kick drainer task (#259). Takes the receiver
4562    /// out of `kick_dispatcher_rx_slot` and starts a tokio task that
4563    /// loops on `recv()`, calls
4564    /// [`Self::dispatch_forged_prompt`] for each request, then sleeps
4565    /// `KICK_DISPATCH_INTERVAL` before the next pull.
4566    ///
4567    /// Call once at construction, AFTER `Workspace::new` returns and
4568    /// the result is Arc-wrapped (mirrors `start_account_loading_tasks`
4569    /// / `start_usage_poller` shape). Subsequent calls find the slot
4570    /// empty and no-op, so a caller's programming error can't
4571    /// duplicate the drainer.
4572    ///
4573    /// The drainer holds an `Arc::downgrade(self)` so the task exits
4574    /// cleanly when the workspace is dropped: each `upgrade()` returns
4575    /// `None` and the loop breaks. No explicit shutdown signal needed.
4576    pub fn start_kick_dispatcher(self: &Arc<Self>) {
4577        let Some(mut rx) = self.kick_dispatcher_rx_slot.lock().take() else {
4578            tracing::debug!(
4579                target: "forge_workspace::workspace",
4580                "start_kick_dispatcher called more than once; ignoring",
4581            );
4582            return;
4583        };
4584        let weak = Arc::downgrade(self);
4585        let span = tracing::info_span!("kick_dispatcher");
4586        tokio::spawn(
4587            async move {
4588                while let Some(req) = rx.recv().await {
4589                    let Some(workspace) = weak.upgrade() else {
4590                        return; // Workspace dropped; exit cleanly.
4591                    };
4592                    let session_key = req.slot.clone();
4593                    if let Err(err) =
4594                        workspace.dispatch_forged_prompt(&session_key, req.prompt_body)
4595                    {
4596                        tracing::error!(
4597                            target: "forge_workspace::workspace",
4598                            slot = %session_key.display(),
4599                            error = ?err,
4600                            "kick dispatcher: dispatch failed; kick dropped",
4601                        );
4602                    }
4603                    // Drop the Arc before sleeping so the workspace
4604                    // isn't held alive across the interval.
4605                    drop(workspace);
4606                    tokio::time::sleep(KICK_DISPATCH_INTERVAL).await;
4607                }
4608            }
4609            .instrument(span),
4610        );
4611    }
4612
4613    /// Bind the gateway's inference listener and open the boot gate
4614    /// once the port is ours. Called once at boot, from the server's
4615    /// startup. On success the launchpad's gate opens; on failure
4616    /// the gate STAYS SHUT and every spawn attempt
4617    /// are refused at the spawn entries with the reason attached - the
4618    /// refusal is the protection, not the log line.
4619    pub fn start_gateway_listener(self: &Arc<Self>) {
4620        let port = self.gateway_port;
4621        let weak = Arc::downgrade(self);
4622        self.supervisor.spawn("gateway_listener", forge_supervise::Policy::Never, move || {
4623            let weak = weak.clone();
4624            async move {
4625                let Some(workspace) = weak.upgrade() else { return };
4626                match forge_gateway::listener::GatewayListener::bind(port).await {
4627                    Ok(listener) => {
4628                        *workspace.gateway_url.lock() = Some(listener.local_url());
4629                        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
4630                        workspace.announce_accounts_changed();
4631                        tracing::info!(
4632                            target: "forge_workspace::workspace",
4633                            port,
4634                            "gateway listener bound",
4635                        );
4636                        let handler: Arc<dyn forge_gateway::listener::RouteHandler> =
4637                            workspace.gateway.clone();
4638                        listener.run(handler).await;
4639                    }
4640                    Err(error) => {
4641                        *workspace.gateway_bind_error.lock() = Some(error.to_string());
4642                        workspace.announce_accounts_changed();
4643                        tracing::error!(
4644                            target: "forge_workspace::workspace",
4645                            error = %error,
4646                            "the gateway listener could not start; the boot gate stays shut and \
4647                             spawn attempts are refused until forge restarts",
4648                        );
4649                    }
4650                }
4651            }
4652        });
4653    }
4654
4655    /// Whether this is the only live forge on the machine: no other config
4656    /// dir's lock is held.
4657    ///
4658    /// **A boot may change shared app-support state only when this is true.**
4659    /// The test beside the store tests pins the peer case.
4660    ///
4661    /// **The app-support tree is machine-wide**, so a boot that changes
4662    /// something shared in it - the browser host's per-version prune - has
4663    /// to establish this first, or it deletes a peer's state.
4664    pub fn sole_instance(&self) -> bool {
4665        match self.app_support.as_deref() {
4666            Some(base) => !crate::single_instance::another_instance_running(&self.config_dir, base),
4667            None => false,
4668        }
4669    }
4670
4671    /// Announce the roster on every rotation change: a cooldown a live 429
4672    /// starts (a write the gateway publishes) and the clock-driven thaw
4673    /// after it (which no write announces, so this task sleeps to it).
4674    ///
4675    /// **A project row's `would_bind` is derived from the selection walk**,
4676    /// so both moments change what the home draws with nothing else to hang
4677    /// an update on. Started once at boot; the registry makes a second call
4678    /// a no-op, since a second watcher would double every frame.
4679    pub fn start_rotation_announcer(self: &Arc<Self>) {
4680        let weak = Arc::downgrade(self);
4681        // **The subscribe happens before the member starts**, so a cooldown
4682        // written between this call and the driver's first poll is seen.
4683        let rotation = self.gateway.rotation_watch();
4684        self.supervisor.spawn(
4685            "rotation_announcer",
4686            forge_supervise::Policy::backoff(),
4687            move || {
4688                let weak = weak.clone();
4689                let mut rotation = rotation.clone();
4690                async move {
4691                    let Some(workspace) = weak.upgrade() else { return };
4692                    drop(workspace);
4693                    loop {
4694                        let Some(workspace) = weak.upgrade() else { return };
4695                        let thaw =
4696                            workspace.gateway.soonest_cooldown_end(std::time::SystemTime::now());
4697                        drop(workspace);
4698                        match thaw {
4699                            None => {
4700                                // Nothing is cooling: the next wake is a write.
4701                                if rotation.changed().await.is_err() {
4702                                    return;
4703                                }
4704                            }
4705                            Some(end) => {
4706                                let wait = end
4707                                    .duration_since(std::time::SystemTime::now())
4708                                    .unwrap_or(std::time::Duration::ZERO);
4709                                // A cooldown that started or was replaced restarts
4710                                // the sleep; the thaw itself ends it.
4711                                tokio::select! {
4712                                    () = tokio::time::sleep(wait) => {}
4713                                    changed = rotation.changed() => {
4714                                        if changed.is_err() {
4715                                            return;
4716                                        }
4717                                    }
4718                                }
4719                            }
4720                        }
4721                        let Some(workspace) = weak.upgrade() else { return };
4722                        workspace.announce_accounts_changed();
4723                    }
4724                }
4725            },
4726        );
4727    }
4728
4729    /// Spawn the background account-usage poller on the interval
4730    /// `[usage] poll_seconds` resolves to (60 s by default). Fetches
4731    /// OAuth usage for every `[[accounts]]` entry via the per-
4732    /// account config-dir's credentials file (no Agent spawn
4733    /// required), writes each result into `AccountStateMap.by_key`.
4734    /// The session page draws its 5h/7d utilization from that cache,
4735    /// and the gateway's account selection reads it too.
4736    ///
4737    /// Called once at boot, AFTER `start_account_loading_tasks`
4738    /// (which subsumed the old `spawn_initial_account_probe` in #246).
4739    /// The supervisor's registry is the idempotency: a second call while
4740    /// the poller is running is a no-op, so a caller's programming error
4741    /// can't multiply the poll rate.
4742    pub fn start_usage_poller(self: &Arc<Self>) {
4743        let weak = Arc::downgrade(self);
4744        let poll_interval = self.config_arc().usage_poll_interval;
4745        let span = tracing::info_span!("usage_poller");
4746        self.supervisor.spawn("usage_poller", forge_supervise::Policy::backoff(), move || {
4747            let weak = weak.clone();
4748            let span = span.clone();
4749            async move {
4750                // Skip the immediate-fire tick: the boot-time loading
4751                // tasks (`start_account_loading_tasks`, #246) already
4752                // drove the live probes. Firing again right away would
4753                // burn another round of Anthropic-side per-IP rate-
4754                // limiter capacity for no gain. First tick of this
4755                // interval lands one poll interval after boot.
4756                let mut interval = tokio::time::interval_at(
4757                    tokio::time::Instant::now() + poll_interval,
4758                    poll_interval,
4759                );
4760                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
4761                loop {
4762                    interval.tick().await;
4763                    let Some(workspace) = weak.upgrade() else {
4764                        return; // Workspace dropped; exit cleanly.
4765                    };
4766                    // A reload's `[usage] poll_seconds` lands on the tick
4767                    // after it, so the loop never holds a stale interval.
4768                    let configured = workspace.config_arc().usage_poll_interval;
4769                    if configured != interval.period() {
4770                        interval = tokio::time::interval_at(
4771                            tokio::time::Instant::now() + configured,
4772                            configured,
4773                        );
4774                        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
4775                    }
4776                    workspace.refresh_account_usage_once().await;
4777                }
4778            }
4779            .instrument(span)
4780        });
4781    }
4782
4783    /// One pass of the usage poller: fetch OAuth usage for every
4784    /// configured account in parallel, write each result back to
4785    /// `AccountStateMap`. Per-account fetch errors are logged at
4786    /// `warn` so persistent auth failures (revoked token, expired
4787    /// refresh, missing credentials file) surface in default log
4788    /// output. Snapshot-mapping errors stay at `debug` because they
4789    /// indicate a response-shape drift rather than something the
4790    /// user needs to act on. Public so tests can drive a
4791    /// deterministic refresh without waiting for the poll tick.
4792    pub async fn refresh_account_usage_once(self: &Arc<Self>) {
4793        // Skip accounts inside an active backoff window - a recent
4794        // probe failed and re-probing now would just re-trip the same
4795        // rate limit. `scheduler_should_probe` ORs the backoff gate
4796        // (`should_probe_now`) with the one-shot reset-clear override
4797        // hook (`has_just_cleared_cap_window`): cold-cache accounts
4798        // probe immediately, and a snapshot whose resets_at moment has
4799        // passed gets a fresh probe via the override even if the
4800        // backoff timer is still active.
4801        let entries = self.accounts.probe_entries();
4802
4803        // Disarm any account where the override hook was the deciding
4804        // factor (should_probe_now was false but the hook said yes).
4805        // One-shot semantics: each successful probe arms the hook once
4806        // via set_usage; the override fires once, and subsequent
4807        // stale-reset state respects the backoff schedule until a
4808        // fresh snapshot lands.
4809        let due: Vec<AccountKey> = entries.iter().map(|(key, _, _)| key.clone()).collect();
4810        self.accounts.disarm_backoff_overrides(&due);
4811        // Sequential probes. Anthropic's `/api/oauth/usage` endpoint
4812        // has a per-IP burst limit; parallel spawns trip the limit
4813        // and produce HTTP 429s even well under the user's own quota.
4814        // Serial execution staggers requests by per-probe latency
4815        // (~hundreds of ms), within the configured poll interval (60 s
4816        // by default).
4817        let mut any_success = false;
4818        for (key, provider, env) in entries {
4819            // The backend owns the probe; an auth failure surfaces and
4820            // the account stays bailed until the credential heals.
4821            let fetch_result = crate::provider_probe::probe_via_backend(provider, &env).await;
4822            match fetch_result {
4823                Ok(snapshot) => {
4824                    self.record_usage_success(&key, snapshot);
4825                    any_success = true;
4826                }
4827                Err(forge_gateway::ProbeError::Unmappable(message)) => {
4828                    self.accounts.set_last_error(
4829                        &key,
4830                        forge_gateway::UsageFetchStatus::Other,
4831                        None,
4832                    );
4833                    // This arm writes the pool too: `set_last_error` moves
4834                    // `last_error` and the re-probe instant, both of which
4835                    // cross on the account row. A pass whose only write is
4836                    // this one would otherwise announce nothing.
4837                    self.announce_accounts_changed();
4838                    tracing::debug!(
4839                        target: "forge_workspace::account",
4840                        account = %key.0,
4841                        error = %message,
4842                        "usage_poll snapshot mapping failed",
4843                    );
4844                }
4845                Err(err) => {
4846                    let status = classify_oauth_usage_error(&err);
4847                    // Pull the server-provided Retry-After out of the
4848                    // 429 variant so the next probe schedules against
4849                    // Anthropic's actual reset time rather than our
4850                    // local guess.
4851                    let retry_after = match &err {
4852                        forge_gateway::ProbeError::Fetch(
4853                            forge_primitives::usage::oauth::OauthUsageError::RateLimited {
4854                                retry_after,
4855                                ..
4856                            },
4857                        ) => *retry_after,
4858                        _ => None,
4859                    };
4860                    self.accounts.set_last_error(&key, status, retry_after);
4861                    // A 429 is a reading even though it heals nothing: keep
4862                    // its windows so the rail can say which one is spent.
4863                    if let Some(snapshot) = err.rate_limited_snapshot() {
4864                        self.accounts.set_limit_reading(&key, snapshot.clone());
4865                    }
4866                    self.announce_accounts_changed();
4867                    // Branch the log message by error class so anyone
4868                    // reading triage logs gets the right framing.
4869                    // The previous shape said "persistent failures
4870                    // usually mean stale OAuth credentials" for every
4871                    // error variant - which misclassified 429
4872                    // rate-limits (a transient Anthropic throttle) as
4873                    // an auth/credentials issue and sent users hunting
4874                    // for /login problems that didn't exist.
4875                    let message: &str = match status {
4876                        forge_gateway::UsageFetchStatus::RateLimited => {
4877                            "usage_poll fetch rate-limited by Anthropic; sub-second Retry-After is treated as 'no hint' and we back off exponentially"
4878                        }
4879                        forge_gateway::UsageFetchStatus::Expired
4880                        | forge_gateway::UsageFetchStatus::Unauthorized => {
4881                            auth_repair_hint(provider)
4882                        }
4883                        forge_gateway::UsageFetchStatus::NetworkFailed => {
4884                            "usage_poll fetch failed with network error; will retry on next tick"
4885                        }
4886                        forge_gateway::UsageFetchStatus::Other => {
4887                            "usage_poll fetch failed with unhandled error class; see error field for details"
4888                        }
4889                    };
4890                    // A 429 is a healthy poller meeting its rate limit,
4891                    // which the backoff above already handles; the other
4892                    // classes mean the account's figures are missing for
4893                    // a reason someone may need to fix.
4894                    if matches!(status, forge_gateway::UsageFetchStatus::RateLimited) {
4895                        tracing::debug!(
4896                            target: "forge_workspace::account",
4897                            account = %key.0,
4898                            error = %err,
4899                            retry_after_secs = ?retry_after.map(|d| d.as_secs()),
4900                            status = ?status,
4901                            "{message}",
4902                        );
4903                    } else {
4904                        tracing::warn!(
4905                            target: "forge_workspace::account",
4906                            account = %key.0,
4907                            error = %err,
4908                            retry_after_secs = ?retry_after.map(|d| d.as_secs()),
4909                            status = ?status,
4910                            "{message}",
4911                        );
4912                    }
4913                }
4914            }
4915        }
4916
4917        // Persist the snapshot to disk so the next forge launch's usage
4918        // reads have seed data even if Anthropic 429s the first probe.
4919        // Skipped when no probe succeeded this round -
4920        // no point rewriting the file with the same contents.
4921        if any_success {
4922            let snapshots = self.accounts.snapshots_for_cache();
4923            let account_count = snapshots.len();
4924            // A redb write is a small mmap'd transaction, not the old
4925            // load-merge-write of a TOML file, so it runs inline on the
4926            // held store handle like the cron + gotify writes do.
4927            if let Some(db) = self.db.lock().as_ref() {
4928                crate::account_cache::store(db, &snapshots);
4929            }
4930            tracing::info!(
4931                target: "forge_workspace::account_cache",
4932                event_name = "account_cache_written",
4933                accounts = account_count,
4934                "usage cache updated in the store after a successful poll round",
4935            );
4936        }
4937    }
4938
4939    /// Write one successful poll result. A healed account is
4940    /// immediately eligible for new spawns: the walk reads the state
4941    /// map directly, so there is nothing to recompute. Existing
4942    /// sessions keep the account they bound to.
4943    pub(crate) fn record_usage_success(
4944        &self,
4945        key: &AccountKey,
4946        snapshot: forge_primitives::usage::UsageSnapshot,
4947    ) {
4948        use forge_gateway::LoadingState;
4949        let previous = self.accounts.usage(&key.0);
4950        let was_loading = self.accounts.loading_state(key);
4951        let was_error = self.accounts.usage_error(&key.0);
4952        let healed = was_loading == LoadingState::Bailed;
4953        // A window at the cap with a reset ahead is proven exhaustion:
4954        // the gateway cools the account until that reset and every
4955        // bound session re-selects. Read off the incoming snapshot -
4956        // the pre-write pool state is the stale one.
4957        let probe_reset_at = snapshot
4958            .binding_reset_at()
4959            .and_then(|reset| reset.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
4960            .map(|duration| duration.as_secs());
4961        self.accounts.set_usage(key, snapshot);
4962        if let Some(reset_at) = probe_reset_at {
4963            self.gateway.report_probe_limit(key, Some(reset_at));
4964        }
4965        if healed {
4966            tracing::info!(
4967                target: "forge_workspace::account",
4968                event_name = "account_healed",
4969                account = %key.0,
4970                outcome = "ready",
4971                "Bailed -> Ready on a clean usage poll; the walk serves it for new sessions",
4972            );
4973        }
4974        // A poll that found the figures already held is no news, and
4975        // announcing it costs every home subscriber a full read for an
4976        // answer that cannot differ. Everything the wire carries is
4977        // compared: the loading state, the last probe failure (a success
4978        // after a transient failure clears it, and the figures can be
4979        // identical; its hold-down instant is cleared with it, so the error
4980        // is the whole of that change), and the usage's own fields.
4981        // `fetched_at` is the one exclusion - it records when the reading
4982        // was taken rather than what it says, and it moves on every probe.
4983        let moved = was_loading != self.accounts.loading_state(key)
4984            || was_error != self.accounts.usage_error(&key.0)
4985            || match (&previous, &self.accounts.usage(&key.0)) {
4986                (Some(before), Some(after)) => !before.same_usage(after),
4987                (None, None) => false,
4988                _ => true,
4989            };
4990        if moved {
4991            self.announce_accounts_changed();
4992        }
4993    }
4994
4995    /// The newest usage reading the pool holds for an account, by display
4996    /// name: a rate-limited response's windows when one landed after the
4997    /// last successful probe, else that probe's snapshot. `None` while the
4998    /// account has reported nothing. A view renders the 5h / 7d bars from
4999    /// this reading.
5000    pub fn usage_for(&self, display_name: &str) -> Option<forge_primitives::usage::UsageSnapshot> {
5001        self.accounts.latest_reading(display_name)
5002    }
5003
5004    /// The account the gateway is serving `key`'s session with right
5005    /// now, which a re-selection may have moved off the spawn-time
5006    /// pick. The lookup goes through the pooled registration: bindings
5007    /// are keyed by the segment the child's base URL was stamped with,
5008    /// which is the id the occupant runs under.
5009    pub fn bound_account_for(&self, key: &SessionSlot) -> Option<AccountKey> {
5010        let (org, project, session) = {
5011            let pool = self.pool.lock();
5012            let registration = pool.get(key).and_then(|entry| entry.registration.as_ref())?;
5013            (registration.org.clone(), registration.project.clone(), registration.session.clone())
5014        };
5015        self.gateway.bindings.binding_for(&org, &project, &session)
5016    }
5017
5018    /// Stamp a respawn's gateway registration for a child that runs
5019    /// under `session_id`: the four gateway-owned keys ride
5020    /// `launch_settings` as overrides, the binding moves to that
5021    /// segment, and the one it replaces is dropped. The pool entry
5022    /// records the same id, so `bound_account_for` reads the binding the
5023    /// child actually answers to. The gateway half is a no-op when the
5024    /// slot is not pooled or carries no registration: the launch then
5025    /// keeps the account env the original spawn laid down.
5026    ///
5027    /// The tools forge has replaced are denied unconditionally, because a
5028    /// respawn's settings are built by a view and carry no spawn-time
5029    /// flags - so `/new` and `/resume` are exactly where those denials
5030    /// would otherwise come back. A worker's own denials come back with
5031    /// them: the kind decides the worktree pins, and the row's own
5032    /// `interactive` flag decides the question.
5033    pub(crate) fn stamp_respawn_overrides(
5034        &self,
5035        slot: &SessionSlot,
5036        session_id: &str,
5037        launch_settings: &mut SessionLaunchSettings,
5038    ) {
5039        let kind = if slot.is_lead() {
5040            crate::mcp::SessionKind::Lead
5041        } else {
5042            crate::mcp::SessionKind::Worker
5043        };
5044        // A lead is the session whose row the user is looking at, so the
5045        // question is never denied to it. A worker's row answers for it,
5046        // and a row that never carried the flag was spawned under the
5047        // non-interactive default.
5048        let interactive = match kind {
5049            crate::mcp::SessionKind::Lead => true,
5050            crate::mcp::SessionKind::Worker => self.stored_interactive_for(slot).unwrap_or(false),
5051        };
5052        crate::spawn::apply_blocked_tools(launch_settings, kind, interactive);
5053        // **A respawn rebuilds both of a lead's blocks.** A worker's charter
5054        // is re-delivered from the store by the caller, and a lead has no
5055        // stored row to read one from - its two blocks are stamped at spawn
5056        // and were lost here, so a `/new`, a `/resume` or a handoff handed
5057        // back a lead with neither its mission nor its delegation rules.
5058        if matches!(kind, crate::mcp::SessionKind::Lead) {
5059            crate::spawn::apply_lead_charter(launch_settings);
5060            Self::apply_lead_delegation(launch_settings, kind);
5061        }
5062        let (registration, replaced) = {
5063            let mut pool = self.pool.lock();
5064            let Some(entry) = pool.get_mut(slot) else { return };
5065            let Some(registration) = entry.registration.as_mut() else { return };
5066            let replaced = std::mem::replace(&mut registration.session, session_id.to_owned());
5067            let owned = registration.clone();
5068            session_id.clone_into(&mut entry.session_id);
5069            (owned, replaced)
5070        };
5071        launch_settings.env_overrides = self.gateway.bindings.respawn_env_overrides(
5072            &registration,
5073            &replaced,
5074            &self.gateway_listener_url(),
5075        );
5076    }
5077
5078    /// Read the last poll-attempt failure for an account, if any.
5079    /// `None` when the most recent poll succeeded (or no attempt
5080    /// has been made yet). The class is what tells an empty bar apart
5081    /// from an upstream failure (the HTTP 429 case is especially common
5082    /// when multiple forge instances poll the same Anthropic account).
5083    pub fn usage_error_for(&self, display_name: &str) -> Option<forge_gateway::UsageFetchStatus> {
5084        self.accounts.usage_error(display_name)
5085    }
5086
5087    /// Resolves a `SessionTarget` to the slot it names. A
5088    /// project-rooted target resolves to that project's lead slot; a
5089    /// specific session names its own. For `Named` with no matching
5090    /// project, returns [`WorkspaceError::ProjectNotFound`].
5091    pub(crate) fn resolve_slot(
5092        &self,
5093        target: &SessionTarget,
5094    ) -> Result<SessionSlot, WorkspaceError> {
5095        match target {
5096            SessionTarget::Default => {
5097                let config = self.config_arc();
5098                let project = config.default_project();
5099                Ok(SessionSlot::lead(&project.org, &project.name))
5100            }
5101            SessionTarget::Named(name) => {
5102                let project = self.find_project_by_name(name)?;
5103                Ok(SessionSlot::lead(&project.org, &project.name))
5104            }
5105            SessionTarget::Session(slot) | SessionTarget::FreshInProject { slot, .. } => {
5106                Ok(slot.clone())
5107            }
5108        }
5109    }
5110
5111    /// The project a spawn under `target` belongs to. A project-rooted
5112    /// target names it directly; a specific session slot names it by
5113    /// org and name.
5114    fn project_for_target(&self, target: &SessionTarget) -> Option<LoadedProject> {
5115        match target {
5116            SessionTarget::Default => {
5117                let config = self.config_arc();
5118                Some(config.default_project().clone())
5119            }
5120            SessionTarget::Named(name) => self.find_project_view_by_name(name),
5121            SessionTarget::Session(slot) | SessionTarget::FreshInProject { slot, .. } => self
5122                .config_arc()
5123                .projects
5124                .iter()
5125                .find(|project| project.org == slot.org() && project.name == slot.project())
5126                .cloned(),
5127        }
5128    }
5129
5130    /// The project `slot` names, if `forge.toml` still declares it.
5131    pub(crate) fn project_for_slot(&self, slot: &SessionSlot) -> Option<LoadedProject> {
5132        let config = self.config_arc();
5133        let find =
5134            |project: &LoadedProject| project.org == slot.org() && project.name == slot.project();
5135        if let Some(project) = config.projects.iter().find(|project| find(project)) {
5136            return Some(project.clone());
5137        }
5138        #[cfg(any(test, feature = "testing"))]
5139        if let Some(project) = self.test_extra_projects.lock().iter().find(|project| find(project))
5140        {
5141            return Some(project.clone());
5142        }
5143        None
5144    }
5145
5146    /// The Slack workspace bound to `slot`'s project: the project's own
5147    /// binding, else its org's. `None` when the slot names no configured
5148    /// project or nothing binds one - both leave the Slack tools refusing
5149    /// with the key to add.
5150    pub(crate) fn slack_workspace_for_slot(&self, slot: &SessionSlot) -> Option<String> {
5151        self.project_for_slot(slot).and_then(|project| project.slack_workspace)
5152    }
5153
5154    /// Look up a project by `name` from `forge.toml`. Returns
5155    /// [`WorkspaceError::ProjectNotFound`] when no project carries
5156    /// that name.
5157    fn find_project_by_name(&self, name: &str) -> Result<LoadedProject, WorkspaceError> {
5158        self.config_arc().projects.iter().find(|project| project.name == name).cloned().ok_or_else(
5159            || WorkspaceError::ProjectNotFound {
5160                name: name.to_owned(),
5161                path: crate::config::forge_data_dir(&self.config_dir).join("forge.toml"),
5162            },
5163        )
5164    }
5165
5166    /// The charter the store holds for `slot`, if any. A worker's mission
5167    /// lives in its conversation, so a `/new` that emptied it has to
5168    /// re-deliver this or the worker comes back with no idea what it is
5169    /// for. Only a worker has one, and a lead's absence is the ordinary
5170    /// answer here.
5171    pub(crate) fn stored_charter_for(&self, slot: &SessionSlot) -> Option<String> {
5172        let db = self.db.lock();
5173        let db = db.as_ref()?;
5174        crate::store::sessions::get(db, slot.org(), slot.project(), slot.label())
5175            .ok()
5176            .flatten()
5177            .and_then(|row| row.charter)
5178    }
5179
5180    /// Whether the store's row for `slot` says its session may ask its user
5181    /// a question. `None` when the store holds no row: a lead has none, and
5182    /// a worker without one was never spawned.
5183    pub(crate) fn stored_interactive_for(&self, slot: &SessionSlot) -> Option<bool> {
5184        let db = self.db.lock();
5185        let db = db.as_ref()?;
5186        crate::store::sessions::get(db, slot.org(), slot.project(), slot.label())
5187            .ok()
5188            .flatten()
5189            .and_then(|row| row.interactive)
5190    }
5191
5192    /// The id the store holds for `slot`, unless `--new` (`force_new`)
5193    /// forces a fresh session. `Ok(Some(id))` => resume that id;
5194    /// `Ok(None)` => start fresh under a minted one. `force_new`
5195    /// overrides a stored id - that is what makes the boot wave's leads
5196    /// come up fresh under `--new`, while every non-boot spawn leaves
5197    /// `force_new` false and resumes.
5198    ///
5199    /// The store is the only source. A row with no id is a session that
5200    /// has never run, so the caller mints rather than re-deriving one
5201    /// from the transcripts. A store that cannot be read is an error
5202    /// rather than a mint: the session it names would be forked, and the
5203    /// new id written over the row that could not be read.
5204    fn stored_resume_id(
5205        &self,
5206        slot: &SessionSlot,
5207        force_new: bool,
5208    ) -> Result<Option<String>, WorkspaceError> {
5209        if force_new {
5210            return Ok(None);
5211        }
5212        match self.stored_session_id(slot.org(), slot.project(), slot.label()) {
5213            Ok(id) => Ok(id),
5214            Err(source) => {
5215                tracing::error!(
5216                    target: "forge_workspace::sessions",
5217                    org = slot.org(),
5218                    project = slot.project(),
5219                    label = slot.label(),
5220                    %source,
5221                    "reading the session row failed; refusing the spawn rather than minting over it",
5222                );
5223                Err(WorkspaceError::SessionStoreUnreadable {
5224                    org: slot.org().to_owned(),
5225                    project: slot.project().to_owned(),
5226                    label: slot.label().to_owned(),
5227                })
5228            }
5229        }
5230    }
5231
5232    /// Record `id` as the session for `slot`, minting a fresh one when
5233    /// the caller has none to hand in. A spawn leaves the seat open with
5234    /// no turn in flight; the boot wave read its pre-spawn state already,
5235    /// and the turn its note starts flips it back.
5236    fn record_spawn_id(&self, slot: &SessionSlot, id: Option<String>, reopen: bool) -> String {
5237        let id = id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5238        self.record_session_id(slot.org(), slot.project(), slot.label(), &id);
5239        self.write_seat_state(slot, SPAWNED_SEAT_STATE, reopen);
5240        id
5241    }
5242
5243    /// Move `slot`'s row to `state`. A write that cannot land is warned and
5244    /// not retried: the row is what the next boot derives from, and a
5245    /// missing write reads as the state it held before. A closed row is
5246    /// left closed; only the spawn path reopens it.
5247    pub(crate) fn record_seat_state(
5248        &self,
5249        slot: &SessionSlot,
5250        state: crate::store::sessions::SeatState,
5251    ) {
5252        self.write_seat_state(slot, state, false);
5253    }
5254
5255    /// The one write behind [`Self::record_seat_state`] and the spawn's
5256    /// reopen. `reopen` is the opening door's word, not a turn's: a spawn
5257    /// the user or the boot wave asked for may open a closed seat, while a
5258    /// settle or a swap landing late must not.
5259    fn write_seat_state(
5260        &self,
5261        slot: &SessionSlot,
5262        state: crate::store::sessions::SeatState,
5263        reopen: bool,
5264    ) {
5265        let db = self.db.lock();
5266        let Some(db) = db.as_ref() else { return };
5267        if let Err(error) = crate::store::sessions::set_seat_state(
5268            db,
5269            slot.org(),
5270            slot.project(),
5271            slot.label(),
5272            state,
5273            reopen,
5274        ) {
5275            tracing::warn!(
5276                target: "forge_workspace::sessions",
5277                event_name = "seat_state_write_failed",
5278                slot = %slot.display(),
5279                state = ?state,
5280                %error,
5281                "recording the seat's state failed; the next boot reads the state it held before",
5282            );
5283        }
5284    }
5285
5286    /// Put `slot`'s row back to the state it held before a refused spawn.
5287    /// Not a transition: the value is written verbatim, `None` included.
5288    fn restore_seat_state(
5289        &self,
5290        slot: &SessionSlot,
5291        state: Option<crate::store::sessions::SeatState>,
5292        expected: crate::store::sessions::SeatState,
5293    ) {
5294        let db = self.db.lock();
5295        let Some(db) = db.as_ref() else { return };
5296        if let Err(error) = crate::store::sessions::restore_seat_state(
5297            db,
5298            slot.org(),
5299            slot.project(),
5300            slot.label(),
5301            state,
5302            expected,
5303        ) {
5304            tracing::warn!(
5305                target: "forge_workspace::sessions",
5306                event_name = "seat_state_restore_failed",
5307                slot = %slot.display(),
5308                state = ?state,
5309                %error,
5310                "restoring the seat's state after a refused spawn failed; the next boot reads what the spawn wrote",
5311            );
5312        }
5313    }
5314
5315    /// The state `slot`'s row holds, or `None` when the row is absent, was
5316    /// written before the field existed, or could not be read.
5317    pub(crate) fn stored_seat_state(
5318        &self,
5319        slot: &SessionSlot,
5320    ) -> Option<crate::store::sessions::SeatState> {
5321        let db = self.db.lock();
5322        let db = db.as_ref()?;
5323        match crate::store::sessions::seat_state(db, slot.org(), slot.project(), slot.label()) {
5324            Ok(state) => state,
5325            Err(error) => {
5326                tracing::warn!(
5327                    target: "forge_workspace::sessions",
5328                    event_name = "seat_state_read_failed",
5329                    slot = %slot.display(),
5330                    %error,
5331                    "reading the seat's state failed; the boot opens nothing for this seat and \
5332                     a resumed worker carries no kick",
5333                );
5334                None
5335            }
5336        }
5337    }
5338
5339    /// Undo a spawn's recorded id after a bind refuses: the store keeps the
5340    /// id it held before, or none at all, so a boot never resumes onto a
5341    /// transcript that never existed.
5342    fn forget_spawn_id(&self, slot: &SessionSlot, previous: Option<&str>) {
5343        if let Some(previous) = previous {
5344            self.record_session_id(slot.org(), slot.project(), slot.label(), previous);
5345            return;
5346        }
5347        let db = self.db.lock();
5348        let Some(db) = db.as_ref() else { return };
5349        if let Err(error) =
5350            crate::store::sessions::clear_session_id(db, slot.org(), slot.project(), slot.label())
5351        {
5352            tracing::warn!(
5353                target: "forge_workspace::sessions",
5354                org = slot.org(),
5355                project = slot.project(),
5356                label = slot.label(),
5357                %error,
5358                "clearing a refused spawn's session id failed",
5359            );
5360        }
5361    }
5362
5363    /// Record the id the CLI adopted for a live session, so the store
5364    /// stops disagreeing with what is running. An in-session `/resume`, a
5365    /// `/clear`, a login or a logout can move a session's id without
5366    /// forge choosing it, and a boot resolves a session from this row.
5367    ///
5368    /// `slot` is the one the task is registered under, so the row is
5369    /// named rather than derived: a session's role is what its spawn
5370    /// stated and nothing re-derives it from the live-worker registry.
5371    pub(crate) fn note_running_session_id(&self, slot: &SessionSlot, session_id: &str) {
5372        self.note_worker_session_id(slot, session_id);
5373        let stored = match self.stored_session_id(slot.org(), slot.project(), slot.label()) {
5374            Ok(stored) => stored,
5375            Err(error) => {
5376                tracing::error!(
5377                    target: "forge_workspace::sessions",
5378                    org = slot.org(),
5379                    project = slot.project(),
5380                    label = slot.label(),
5381                    %error,
5382                    "reading the session row failed; not recording the adopted id over it",
5383                );
5384                return;
5385            }
5386        };
5387        if stored.as_deref() == Some(session_id) {
5388            return;
5389        }
5390        tracing::info!(
5391            target: "forge_workspace::sessions",
5392            org = slot.org(),
5393            project = slot.project(),
5394            label = slot.label(),
5395            session_id,
5396            "recording the id the CLI adopted, which is not the one the store held",
5397        );
5398        self.record_session_id(slot.org(), slot.project(), slot.label(), session_id);
5399    }
5400
5401    /// Mirror the id a live worker adopted onto its registry entry, so
5402    /// the status echo and `agents__list` name the occupant that is
5403    /// running rather than the one it was spawned under.
5404    fn note_worker_session_id(&self, slot: &SessionSlot, session_id: &str) {
5405        if let Some(entry) = self.pool.lock().get_mut(slot) {
5406            session_id.clone_into(&mut entry.session_id);
5407        }
5408        for entry in self.live_workers.lock().values_mut().flatten() {
5409            if entry.slot == *slot {
5410                entry.session_id = Some(forge_primitives::SessionId::new(session_id));
5411            }
5412        }
5413    }
5414
5415    /// The id the session at `slot` runs under, when this process holds
5416    /// it live. `None` for a slot with no pooled session. This is the
5417    /// slot's occupant rather than a snapshot of when a caller last
5418    /// heard about it, so a read that has to name the id now - a
5419    /// transcript path, a CLI argument - asks here.
5420    pub fn running_session_id_for(&self, slot: &SessionSlot) -> Option<String> {
5421        self.pool.lock().get(slot).map(|entry| entry.session_id.clone())
5422    }
5423
5424    /// The id a transcript read for `slot` is looked up under: the live
5425    /// occupant's when this process holds one, and the store's own row -
5426    /// the session a wake resumes - otherwise.
5427    ///
5428    /// **A sleeping seat still has a session.** The store records the id under
5429    /// the slot before the child starts and the CLI writes its transcript
5430    /// under it, so a sleeping seat's history is readable without a spawn; a
5431    /// read that named only pooled sessions drew one empty. `None` for a
5432    /// slot with no session at all: nothing live, and no stored id.
5433    pub fn transcript_session_id_for(&self, slot: &SessionSlot) -> Option<String> {
5434        if let Some(live) = self.running_session_id_for(slot) {
5435            return Some(live);
5436        }
5437        match self.stored_session_id(slot.org(), slot.project(), slot.label()) {
5438            Ok(id) => id,
5439            Err(source) => {
5440                tracing::warn!(
5441                    target: "forge_workspace::sessions",
5442                    org = slot.org(),
5443                    project = slot.project(),
5444                    label = slot.label(),
5445                    %source,
5446                    "reading the session row failed; the seat reads as one with no history",
5447                );
5448                None
5449            }
5450        }
5451    }
5452
5453    /// Whether an agent is currently pooled for `slot`.
5454    pub(crate) fn session_is_pooled(&self, slot: &SessionSlot) -> bool {
5455        self.pool.lock().contains_key(slot)
5456    }
5457
5458    /// Whether the slot's pooled handle still has a session task consuming
5459    /// its commands. The teardown signal is the task's own record of itself:
5460    /// unanswered while it runs, answered as it drops. A pooled handle with
5461    /// no such task is one no spawn may hand back (#2683).
5462    fn pooled_handle_has_task(&self, slot: &SessionSlot) -> bool {
5463        self.session_teardown(slot).is_some()
5464    }
5465
5466    /// When the transcript of the session at `slot` was last written,
5467    /// read from the catalog row naming the id it currently runs under.
5468    /// `None` for a slot with no live session, or one whose transcript
5469    /// is not on disk yet.
5470    pub fn session_last_activity(&self, slot: &SessionSlot) -> Option<std::time::SystemTime> {
5471        let id = self.running_session_id_for(slot)?;
5472        self.catalog
5473            .lock()
5474            .values()
5475            .flatten()
5476            .find(|info| info.session_id == id)
5477            .map(|info| UNIX_EPOCH + Duration::from_millis(info.last_modified))
5478    }
5479
5480    /// The sessions the seat at `slot` can jump back to with `/resume`,
5481    /// newest first, and never the one it is already on.
5482    ///
5483    /// **The set is what `claude --resume` can find from the directory the
5484    /// seat's own resume runs in** ([`Self::cwd_for_session`], which is what
5485    /// `--resume` is handed): a lead's project root, a worker's worktree, or
5486    /// the project root for a worker with none. A lead gets its project's own
5487    /// sessions and never its workers' - `Workers::Hidden`, which is what
5488    /// keeps a lead's turns out of a transcript tagged for a worker - while a
5489    /// worker gets only the transcripts carrying its own label.
5490    ///
5491    /// **A lead is answered from the boot catalog's group for its project**,
5492    /// which the boot scan already read from disk and holds in memory, with
5493    /// each row kept only if its transcript is still there: the catalog also
5494    /// mirrors every Connected, including a session that never wrote a turn,
5495    /// and `--resume` cannot find that one. A worker's runs are not in the
5496    /// catalog, so its own directory is scanned - its worktree for a git
5497    /// worker, the project root otherwise. A seat whose project forge has not
5498    /// loaded has no directory to resume from, and answers empty.
5499    pub async fn resumable_sessions(&self, slot: &SessionSlot) -> Vec<SDKSessionInfo> {
5500        let Some(cwd) = self.cwd_for_session(slot) else {
5501            return Vec::new();
5502        };
5503        let workers = if slot.is_lead() {
5504            forge_agent::userdata::catalog::scan::Workers::Hidden
5505        } else {
5506            forge_agent::userdata::catalog::scan::Workers::Only(slot.label().to_owned())
5507        };
5508        // A worker's runs are not in the catalog at all - the boot scan hides
5509        // them and nothing mirrors them - so only a lead takes this source.
5510        let from_catalog = if slot.is_lead() { self.catalog_sessions_for(&cwd) } else { None };
5511        let mut entries = match from_catalog {
5512            Some(mut held) => {
5513                let dir = self.transcript_dir_for(&cwd);
5514                held.retain(|info| dir.join(format!("{}.jsonl", info.session_id)).is_file());
5515                held
5516            }
5517            None => self.scan_sessions(&cwd, workers).await,
5518        };
5519        let occupant = self.transcript_session_id_for(slot);
5520        entries.retain(|info| Some(&info.session_id) != occupant.as_ref());
5521        // A row the catalog mirrored at Connected states a placeholder title
5522        // until the next scan reads the transcript. The transcript is there
5523        // now and the row is a real jump, so read the one file: the occupant
5524        // has already gone, so this is a session the seat left, not the one it
5525        // is writing.
5526        let dir = self.transcript_dir_for(&cwd);
5527        for info in &mut entries {
5528            if info.file_size.is_none() {
5529                let path = dir.join(format!("{}.jsonl", info.session_id));
5530                if let Some(read) =
5531                    forge_agent::userdata::catalog::scan::read_session_info_at(&path)
5532                {
5533                    *info = read;
5534                }
5535            }
5536        }
5537        entries.sort_by_key(|info| std::cmp::Reverse(info.last_modified));
5538        entries.truncate(Self::RESUME_CANDIDATES);
5539        entries
5540    }
5541
5542    /// The newest sessions a `/resume` picker offers: a reader picks from the
5543    /// top, and the record is re-encoded on every seat open and reread, so the
5544    /// answer cannot grow with the project's history.
5545    const RESUME_CANDIDATES: usize = 50;
5546
5547    /// The boot catalog's group for the project at `cwd`, or `None` while the
5548    /// boot scan has not landed: a group missing because nothing has scanned
5549    /// is not a project with no sessions.
5550    fn catalog_sessions_for(&self, cwd: &str) -> Option<Vec<SDKSessionInfo>> {
5551        if !self.catalog_ready() {
5552            return None;
5553        }
5554        let key = ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(
5555            Some(cwd),
5556        ));
5557        let catalog = self.catalog.lock();
5558        Some(catalog.get(&key).cloned().unwrap_or_default())
5559    }
5560
5561    /// The directory claude writes `cwd`'s transcripts in, under this
5562    /// workspace's config dir.
5563    fn transcript_dir_for(&self, cwd: &str) -> std::path::PathBuf {
5564        forge_sdk::projects_dir_for(&self.session_scan_root())
5565            .join(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(cwd)))
5566    }
5567
5568    /// Where this workspace's `projects/` tree resolves, with the boot scan's
5569    /// own fallbacks.
5570    fn session_scan_root(&self) -> std::path::PathBuf {
5571        catalog_scan_root(&self.config_dir).unwrap_or_else(|| {
5572            std::fs::canonicalize(&self.config_dir).unwrap_or_else(|_| self.config_dir.clone())
5573        })
5574    }
5575
5576    /// One directory scanned for the sessions a seat may resume - a worker's
5577    /// own runs, and a lead's before the boot catalog has landed - through the
5578    /// store's tag cache like the boot scan: an unchanged transcript is not
5579    /// re-read end to end for a list a reader opens for a moment.
5580    ///
5581    /// The cap bounds the reads as well as the answer: the scan reads the
5582    /// newest transcripts first and stops once the rows it serves are in
5583    /// hand, filling from the next batch when one comes back short - and a
5584    /// transcript a worker's cached tag rules out is not read at all
5585    /// (#2567), so a non-git worker sharing the project root no longer pays
5586    /// the project's whole scan. A lead takes the catalog above for a list
5587    /// that costs no read at all.
5588    async fn scan_sessions(
5589        &self,
5590        cwd: &str,
5591        workers: forge_agent::userdata::catalog::scan::Workers,
5592    ) -> Vec<SDKSessionInfo> {
5593        // Loaded but NOT persisted: the boot scan owns this table, and a redb
5594        // commit on the open path is a cost nothing here needs.
5595        let tag_cache = std::sync::Arc::new(load_session_tag_cache(self.db.lock().as_ref()));
5596        forge_agent::userdata::catalog::scan::list_sessions(
5597            &self.session_scan_root(),
5598            Some(cwd),
5599            Some(Self::RESUME_CANDIDATES),
5600            0,
5601            workers,
5602            Some(&tag_cache),
5603        )
5604        .await
5605    }
5606
5607    /// A fresh id for a session starting now, recorded against `slot`'s
5608    /// row.
5609    pub(crate) fn fresh_session_id_for(&self, slot: &SessionSlot) -> String {
5610        // A swap is not an opening door: a `/new` a dying task drains after
5611        // the close must not reopen the row.
5612        self.record_spawn_id(slot, None, false)
5613    }
5614
5615    /// The session id the store holds for `(org, project, label)`.
5616    ///
5617    /// `Ok(None)` means no id: the row is absent, or it carries none.
5618    /// That is a session which has never run, and a caller may mint for
5619    /// it. `Err` means the row could not be read, which is a different
5620    /// answer and must never be treated as absence: a caller that minted
5621    /// there would fork the session and write the new id over the row it
5622    /// failed to read.
5623    fn stored_session_id(
5624        &self,
5625        org: &str,
5626        project: &str,
5627        label: &str,
5628    ) -> Result<Option<String>, anyhow::Error> {
5629        let db = self.db.lock();
5630        let Some(db) = db.as_ref() else {
5631            // No store at all, so there is no row to read and none a mint
5632            // could overwrite.
5633            return Ok(None);
5634        };
5635        Ok(crate::store::sessions::get(db, org, project, label)?.and_then(|row| row.session_id))
5636    }
5637
5638    /// Record `id` as the session for `(org, project, label)`, keeping the
5639    /// fields a worker's row already carries. A write that cannot land is
5640    /// warned and not retried: the session still runs under `id`, and the
5641    /// next boot reads whatever the row held.
5642    pub(crate) fn record_session_id(&self, org: &str, project: &str, label: &str, id: &str) {
5643        let db = self.db.lock();
5644        let Some(db) = db.as_ref() else { return };
5645        let existing = crate::store::sessions::get(db, org, project, label).ok().flatten();
5646        let row = crate::store::sessions::SessionRecord {
5647            org: org.to_owned(),
5648            project: project.to_owned(),
5649            label: label.to_owned(),
5650            session_id: Some(id.to_owned()),
5651            charter: existing.as_ref().and_then(|row| row.charter.clone()),
5652            kick: existing.as_ref().and_then(|row| row.kick.clone()),
5653            resume_kick: existing.as_ref().and_then(|row| row.resume_kick.clone()),
5654            interactive: existing.as_ref().and_then(|row| row.interactive),
5655            seat_state: existing.as_ref().and_then(|row| row.seat_state),
5656            is_git_repo: existing.as_ref().and_then(|row| row.is_git_repo),
5657            mcp_families: existing.as_ref().and_then(|row| row.mcp_families.clone()),
5658            cwd: existing.and_then(|row| row.cwd),
5659        };
5660        if let Err(error) = crate::store::sessions::put(db, &row) {
5661            tracing::warn!(
5662                target: "forge_workspace::sessions",
5663                org, project, label, session_id = %id, %error,
5664                "recording the session id failed; it will not survive a restart",
5665            );
5666        }
5667    }
5668
5669    /// Locate a `ProjectView`-like (`LoadedProject`) by `name` from
5670    /// `forge.toml`. Returns `None` when no project carries that name.
5671    /// Used by the spawn handlers to resolve the project's path / cwd
5672    /// before emitting `SessionUpdate::Spawning`.
5673    pub(crate) fn find_project_view_by_name(&self, name: &str) -> Option<LoadedProject> {
5674        #[cfg(any(test, feature = "testing"))]
5675        if let Some(found) =
5676            self.test_extra_projects.lock().iter().find(|p| p.name == name).cloned()
5677        {
5678            return Some(found);
5679        }
5680        self.config_arc().projects.iter().find(|p| p.name == name).cloned()
5681    }
5682
5683    /// The seat a project-scoped section update routes on: the project's
5684    /// lead, which exists for every declared project. `None` for a name no
5685    /// project carries, the shape a project that left forge.toml leaves.
5686    pub(crate) fn lead_slot_for_project(&self, project_name: &str) -> Option<SessionSlot> {
5687        self.find_project_view_by_name(project_name)
5688            .map(|project| SessionSlot::lead(project.org, project.name))
5689    }
5690
5691    /// Internal accessor for the SessionUpdate fan-out sender. Used
5692    /// by `spawn.rs` to emit `Spawning` / `ConnectionFailed` /
5693    /// `FatalError` from the App-level handlers.
5694    pub(crate) fn update_tx(&self) -> &UpdateFanout {
5695        &self.update_tx
5696    }
5697
5698    /// Tell every view the account pool moved.
5699    ///
5700    /// Called from the writes that move it - a boot probe settling, the usage
5701    /// poller landing or failing, and the listener binding - because none of
5702    /// them belongs to a seat. The frame carries the pool's drawn state, built
5703    /// by [`Self::account_pool_view`], so a subscriber applies a usage-only
5704    /// move without a home re-read; a move in what a project row binds on
5705    /// still asks for the snapshot that refreshes it.
5706    pub(crate) fn announce_accounts_changed(&self) {
5707        let _ = self
5708            .update_tx
5709            .send(SessionUpdate::AccountsChanged { accounts: self.account_pool_view() });
5710    }
5711
5712    /// The cwd a project lead's slot runs in: its project's path.
5713    /// `claude --resume` indexes by the project key derived from the
5714    /// subprocess's working directory, so every explicit-resume code
5715    /// path must spawn the subprocess in the session's original cwd or
5716    /// the resume hits "No conversation found with session ID ..." even
5717    /// when the `.jsonl` exists.
5718    ///
5719    /// Read from the slot rather than from the catalog: a catalog row is
5720    /// keyed by the id the CLI adopted, which `/new` replaces, so the
5721    /// lookup answered nothing for the session that had just started and
5722    /// resume was handed an empty working directory.
5723    ///
5724    /// A WORKER answers nothing here, deliberately. Its cwd is its
5725    /// worktree, which only the live-worker registry can compose; the
5726    /// project root is a plausible-looking wrong answer that would shadow
5727    /// the registry arm in [`Self::cwd_for_session`] and send a git
5728    /// worker's resume to the wrong directory.
5729    fn session_cwd_for(&self, slot: &SessionSlot) -> Option<String> {
5730        if !slot.is_lead() {
5731            return None;
5732        }
5733        Some(self.project_for_slot(slot)?.path.to_string_lossy().into_owned())
5734    }
5735
5736    /// Insert (or update) a session entry under the project that
5737    /// owns `cwd`. Called after every `Connected` migration so newly
5738    /// spawned sessions surface in the
5739    /// Projects-pane drilldown immediately, without forcing a full
5740    /// disk re-scan. The entry is placed at the head of the
5741    /// project's session list to match the most-recent-first ordering
5742    /// the scan produces.
5743    pub fn record_connected_session(&self, cwd: &str, session_id: &str, summary: Option<String>) {
5744        let storage_key =
5745            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(cwd));
5746        let key = ProjectKey::new(storage_key.clone());
5747        let now_ms = SystemTime::now()
5748            .duration_since(UNIX_EPOCH)
5749            .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
5750        let entry = SDKSessionInfo {
5751            session_id: session_id.to_owned(),
5752            summary: summary.unwrap_or_else(|| "new session".to_owned()),
5753            last_modified: now_ms,
5754            file_size: None,
5755            custom_title: None,
5756            first_prompt: None,
5757            git_branch: None,
5758            cwd: Some(cwd.to_owned()),
5759            storage_key,
5760            tag: None,
5761            created_at: None,
5762        };
5763        let mut catalog = self.catalog.lock();
5764        let entries = catalog.entry(key).or_default();
5765        entries.retain(|e| e.session_id != session_id);
5766        entries.insert(0, entry);
5767    }
5768
5769    /// Subscribe without answering the workspace's prompts: a logger, a
5770    /// mirror, anything that reads the stream but renders no prompt.
5771    ///
5772    /// Every caller gets its own stream, so a second view attaches beside
5773    /// the first rather than being refused, and the workspace drops the
5774    /// matching subscription once its receiver goes. A stream carries what
5775    /// is emitted after this call; a subscriber reads what it missed from
5776    /// the subject's snapshot instead.
5777    ///
5778    /// The distinction from [`Self::subscribe_answerer`] is load-bearing
5779    /// rather than descriptive. A permission, question or Slack-draft
5780    /// request is parked on a reply, and the paths that raise one resolve it
5781    /// `Cancelled` instead when no subscriber can answer, so that a request
5782    /// nobody will reply to fails the turn rather than hanging it. A
5783    /// subscriber that declares itself an observer is not counted as an
5784    /// answer.
5785    ///
5786    /// **A browser hand-off fails closed differently**: nothing parks a
5787    /// decision for it to cancel, so its registration hands the blocked call
5788    /// a dead receiver instead and the tool answers the reason - no attached
5789    /// client can show the hand-off - rather than holding a session on a
5790    /// prompt no view can draw.
5791    ///
5792    /// Every frame arrives as a [`SeatUpdate`], carrying the number its
5793    /// seat minted for it.
5794    pub fn subscribe_mirror(&self) -> mpsc::UnboundedReceiver<SeatUpdate> {
5795        self.update_tx.subscribe(SubscriberRole::Observing)
5796    }
5797
5798    /// Subscribe as an answerer: the same stream, for a client that renders
5799    /// prompts and can answer them.
5800    ///
5801    /// The answering half of [`Self::subscribe_mirror`]. The role decides one
5802    /// thing: whether the core may park a prompt on this subscriber's reply.
5803    pub fn subscribe_answerer(&self) -> mpsc::UnboundedReceiver<SeatUpdate> {
5804        self.update_tx.subscribe(SubscriberRole::Answering)
5805    }
5806
5807    /// Clone the workspace's [`SessionUpdate`] sender. Internal to this
5808    /// crate: a view's own async work belongs on a channel of its own,
5809    /// so that the subscription methods above are the whole of what the core
5810    /// owes a frontend.
5811    pub(crate) fn update_sender(&self) -> UpdateFanout {
5812        self.update_tx.clone()
5813    }
5814
5815    /// The number `key`'s next frame will be minted under: what a client
5816    /// reading the seat expects next, beside the occupant that names the
5817    /// epoch it belongs to.
5818    pub fn seat_next_seq(&self, key: &SessionSlot) -> u64 {
5819        self.update_tx.next_seq(key)
5820    }
5821
5822    /// The frames `key`'s stream emitted under the numbers in `from..=to`,
5823    /// oldest first: the answer to a client's range ask while the seat's own
5824    /// window holds them.
5825    pub fn seat_frames_between(&self, key: &SessionSlot, from: u64, to: u64) -> Vec<SeatUpdate> {
5826        self.update_tx.frames_between(key, from, to)
5827    }
5828
5829    /// The oldest number `key`'s window still holds, `None` for a seat that
5830    /// has emitted nothing: what tells a range answered from memory from one
5831    /// that has to be read off the transcript.
5832    pub fn seat_oldest_kept_seq(&self, key: &SessionSlot) -> Option<u64> {
5833        self.update_tx.oldest_kept(key)
5834    }
5835
5836    /// How many of the numbers in `from..=to` on `key`'s stream no window
5837    /// holds: the frames that close an epoch are numbered but kept out of the
5838    /// ring, so a reader measuring whether a copy covers a stretch leaves them
5839    /// out of what it must account for.
5840    pub fn seat_unringed_between(&self, key: &SessionSlot, from: u64, to: u64) -> u64 {
5841        self.update_tx.unringed_between(key, from, to)
5842    }
5843
5844    /// Whether `key`'s latest epoch-opening seal carried rows, which no ring
5845    /// holds: what tells a copy filled from the window that it cannot reach
5846    /// the rows the epoch began with.
5847    pub fn seat_epoch_opened_with_rows(&self, key: &SessionSlot) -> bool {
5848        self.update_tx.epoch_opened_with_rows(key)
5849    }
5850
5851    /// Borrow the workspace-side [`DomainSession`] for `key`.
5852    ///
5853    /// Returns the [`Arc`]-cloned mutex protecting the domain bucket.
5854    /// Callers `.lock()` to read or mutate. `None` when no
5855    /// `SessionTask` is registered for `key` (e.g., the session was
5856    /// closed or hasn't been spawned yet).
5857    ///
5858    /// Callers should hold the lock for the shortest scope possible;
5859    /// concurrent reducers and the per-session `SessionTask` share
5860    /// this mutex.
5861    pub fn domain_session_for(&self, key: &SessionSlot) -> Option<Arc<Mutex<DomainSession>>> {
5862        self.domain_handles.lock().get(key).cloned()
5863    }
5864
5865    /// The slash commands the CLI last advertised for the session at
5866    /// `slot`. Empty for a slot whose session has not connected yet, or
5867    /// whose CLI advertised none.
5868    pub fn available_commands_for(&self, slot: &SessionSlot) -> Vec<AvailableCommand> {
5869        self.domain_session_for(slot)
5870            .map(|domain| domain.lock().available_commands.clone())
5871            .unwrap_or_default()
5872    }
5873
5874    /// The subagents the CLI last advertised for the session at `slot`.
5875    /// Empty in the same two cases as [`Self::available_commands_for`].
5876    pub fn available_agents_for(&self, slot: &SessionSlot) -> Vec<AvailableAgent> {
5877        self.domain_session_for(slot)
5878            .map(|domain| domain.lock().available_agents.clone())
5879            .unwrap_or_default()
5880    }
5881
5882    /// The sub-agent instances the session at `slot` has folded, as the list
5883    /// the session task pushes when it moves.
5884    ///
5885    /// **A record reads this rather than folding the conversation itself.**
5886    /// The task folds a card per frame and announces the list moving, so a
5887    /// second fold over the held conversation would be a second answer to a
5888    /// question one fold already answers - and one that disagrees with the
5889    /// frames as soon as the conversation a record can see is shorter than the
5890    /// one the session has run.
5891    pub fn subagent_cards_for(
5892        &self,
5893        slot: &SessionSlot,
5894    ) -> Vec<forge_primitives::runtime::SubagentCard> {
5895        self.domain_session_for(slot)
5896            .map(|domain| domain.lock().cards_snapshot.clone())
5897            .unwrap_or_default()
5898    }
5899
5900    /// Whether the session at `key` currently has a live agent
5901    /// handle stamped onto its [`DomainSession`]. Encapsulates the
5902    /// presence check so callers don't need to peek at
5903    /// `DomainSession.conn` directly - the field layout is a
5904    /// workspace internal.
5905    pub fn has_agent_for(&self, key: &SessionSlot) -> bool {
5906        self.domain_session_for(key).is_some_and(|d| d.lock().conn.is_some())
5907    }
5908
5909    /// Apply a `/dictate` override edit to the session's `DomainSession`
5910    /// and echo the full set back. An unknown session is refused the same
5911    /// way the per-session commands are: there is nothing to edit.
5912    ///
5913    /// # Errors
5914    ///
5915    /// Returns [`DispatchError::UnknownSession`] when no `DomainSession`
5916    /// is registered for the key.
5917    fn apply_dictate_override(
5918        self: &Arc<Self>,
5919        key: &SessionSlot,
5920        update: crate::dictate::DictateOverrideUpdate,
5921    ) -> Result<(), DispatchError> {
5922        let Some(domain) = self.domain_session_for(key) else {
5923            return Err(DispatchError::UnknownSession(key.clone()));
5924        };
5925        match update {
5926            crate::dictate::DictateOverrideUpdate::Styling(v) => {
5927                domain.lock().dictate_overrides.styling = Some(v);
5928            }
5929            crate::dictate::DictateOverrideUpdate::Structure(v) => {
5930                domain.lock().dictate_overrides.structure = Some(v);
5931            }
5932            crate::dictate::DictateOverrideUpdate::Context(v) => {
5933                domain.lock().dictate_overrides.context = Some(v);
5934            }
5935            crate::dictate::DictateOverrideUpdate::Reset => {
5936                domain.lock().dictate_overrides = crate::dictate::DictateOverrides::default();
5937            }
5938        }
5939        let overrides = domain.lock().dictate_overrides;
5940        let _ = self
5941            .update_sender()
5942            .send(SessionUpdate::DictateOverrides { key: key.clone(), overrides });
5943        Ok(())
5944    }
5945
5946    /// Dispatch a workspace-originated plain prompt (cron fire, peer,
5947    /// gotify or slack delivery, kick, notices).
5948    ///
5949    /// **A delivery drawn in a view needs an update here, paired with an arm
5950    /// in `forge-server`'s `delivery_turn`.** This path emits no frame of its
5951    /// own - the forge for one is `delivery_turn`, downstream, and it ends in
5952    /// `_ => return None`. So a prompt sent from here whose typed update has
5953    /// no arm there draws nothing in any view, silently: a prompt with no
5954    /// envelope of its own takes [`Self::dispatch_forged_prompt`] instead,
5955    /// which forges the frame here.
5956    pub fn dispatch_workspace_prompt(
5957        self: &Arc<Self>,
5958        key: &SessionSlot,
5959        text: String,
5960    ) -> Result<(), DispatchError> {
5961        self.dispatch_workspace_prompt_from(key, text, PromptSource::Forge)
5962    }
5963
5964    /// [`Self::dispatch_workspace_prompt`], with the row's source label named
5965    /// by the caller: a delivery knows what it is (a cron fire, a Gotify
5966    /// notification, a peer comm) and the queued row has to say so.
5967    pub fn dispatch_workspace_prompt_from(
5968        self: &Arc<Self>,
5969        key: &SessionSlot,
5970        text: String,
5971        source: PromptSource,
5972    ) -> Result<(), DispatchError> {
5973        self.dispatch_workspace_prompt_under(
5974            key,
5975            text,
5976            source,
5977            match source {
5978                PromptSource::Forge => forge_sdk::request_id::next_forged_prompt_id(),
5979                _ => forge_sdk::request_id::next_prompt_id(),
5980            },
5981        )
5982    }
5983
5984    /// [`Self::dispatch_workspace_prompt_from`] with the id supplied by the
5985    /// caller, for a site that already minted one: a delivery that draws its
5986    /// own envelope row passes the same id to both, so the row a view holds
5987    /// while the prompt queues and the prompt's lifecycle frames are one thing
5988    /// by id.
5989    pub fn dispatch_workspace_prompt_under(
5990        self: &Arc<Self>,
5991        key: &SessionSlot,
5992        text: String,
5993        source: PromptSource,
5994        uuid: String,
5995    ) -> Result<(), DispatchError> {
5996        // A prompt arriving while the seat swaps its occupant waits for the
5997        // successor: the session on its way out must not start a turn the new
5998        // one would never carry. Every machinery delivery - peer, cron,
5999        // Gotify, Slack - comes through here, so one gate holds them all.
6000        if self.hold_prompt(
6001            key,
6002            crate::handoff::HeldPrompt {
6003                text: text.clone(),
6004                attachments: Vec::new(),
6005                source,
6006                uuid: uuid.clone(),
6007                sender: None,
6008            },
6009        ) {
6010            return Ok(());
6011        }
6012        // `route` rather than `dispatch`: the delivery's frame is the
6013        // envelope one its own update forges, not a bare user turn.
6014        self.route(Command::PromptUnder {
6015            key: key.clone(),
6016            text,
6017            attachments: Vec::new(),
6018            uuid,
6019            source,
6020            dispatch_id: None,
6021        })
6022    }
6023
6024    /// Fire the continuation for a seat whose turn just failed: one prompt
6025    /// of forge's own, the moment the failure lands (#2146).
6026    ///
6027    /// Every non-cancelled failed turn is this one's to answer, whatever its
6028    /// class - a transient server error included, which nobody's retry
6029    /// ladder throws back at the CLI once the turn has died. The decision is
6030    /// taken under the lock; the dispatch is not.
6031    pub(crate) fn fire_auto_continue(self: &Arc<Self>, key: &SessionSlot) {
6032        let Some(domain) = self.domain_session_for(key) else {
6033            return;
6034        };
6035        let (reason, uuid) = {
6036            let mut guard = domain.lock();
6037            // Spent either way: the decision for this failure is made here,
6038            // so nothing can send the same nudge twice.
6039            let Some(pending) = guard.auto_continue.take() else {
6040                return;
6041            };
6042            guard.auto_continue_spent = true;
6043            // The prompt is keyed to the occupant the failure belonged to -
6044            // the same tick's fold cannot have moved it - so a replacement
6045            // drained ahead of it is not handed words about a history it
6046            // does not have; the check is where the queued prompt runs.
6047            let uuid = forge_sdk::request_id::next_forged_prompt_id();
6048            guard.auto_continue_queued = Some((uuid.clone(), guard.session_id.clone()));
6049            (pending.reason, uuid)
6050        };
6051        let text = auto_continue_prompt(&reason);
6052        if let Err(error) = self.dispatch_forged_prompt_under(key, text, uuid) {
6053            tracing::warn!(
6054                target: "forge_workspace::workspace",
6055                event_name = "auto_continue_dispatch_failed",
6056                slot = %key.display(),
6057                %error,
6058                "the continuation prompt for a failed turn could not be dispatched",
6059            );
6060            return;
6061        }
6062        tracing::info!(
6063            target: "forge_workspace::workspace",
6064            event_name = "auto_continued_failed_turn",
6065            slot = %key.display(),
6066            "continued a failed turn",
6067        );
6068    }
6069
6070    /// Dispatch a workspace-originated prompt whose words no view has drawn:
6071    /// a worker kick, an auto-continue. [`Self::dispatch_workspace_prompt`]
6072    /// exactly, plus the frame - nothing else carries these words, where a
6073    /// delivery's own envelope update is what draws a delivery, and routing
6074    /// one of those through here would draw its words twice.
6075    pub fn dispatch_forged_prompt(
6076        self: &Arc<Self>,
6077        key: &SessionSlot,
6078        text: String,
6079    ) -> Result<(), DispatchError> {
6080        self.dispatch_forged_prompt_under(key, text, forge_sdk::request_id::next_forged_prompt_id())
6081    }
6082
6083    /// [`Self::dispatch_forged_prompt`] with the id supplied by the caller,
6084    /// for a forged prompt an association has to outlive the dispatch: the
6085    /// auto-continue records the id it sent under, so the prompt can be
6086    /// matched where it runs. The frame and the prompt carry one id either
6087    /// way - the row a view draws and the turn the model receives are one
6088    /// thing by id.
6089    pub(crate) fn dispatch_forged_prompt_under(
6090        self: &Arc<Self>,
6091        key: &SessionSlot,
6092        text: String,
6093        uuid: String,
6094    ) -> Result<(), DispatchError> {
6095        self.dispatch_forged_prompt_held(key, text, uuid, true)
6096    }
6097
6098    /// The brief a handoff's successor runs as its first turn.
6099    ///
6100    /// **Its own row, forged like the machinery's other prompts.** The
6101    /// successor's transcript opens with this prompt and the client draws it
6102    /// as the handoff procedure; the CLI never echoes a stdin prompt, so this
6103    /// frame is the page's only carrier of it.
6104    pub(crate) fn dispatch_handoff_brief(
6105        self: &Arc<Self>,
6106        key: &SessionSlot,
6107        text: String,
6108        uuid: String,
6109    ) -> Result<(), DispatchError> {
6110        self.dispatch_forged_prompt_held(key, text, uuid, true)
6111    }
6112
6113    /// The handoff's own judgment prompt.
6114    ///
6115    /// **The one prompt a swap's gate lets through.** The gate opens when
6116    /// the handoff begins, and this is the prompt the handoff is built on:
6117    /// held like any other, it would wait for a successor that is waiting
6118    /// for it.
6119    pub(crate) fn dispatch_handoff_prompt(
6120        self: &Arc<Self>,
6121        key: &SessionSlot,
6122        text: String,
6123        uuid: String,
6124    ) -> Result<(), DispatchError> {
6125        self.dispatch_forged_prompt_held(key, text, uuid, false)
6126    }
6127
6128    fn dispatch_forged_prompt_held(
6129        self: &Arc<Self>,
6130        key: &SessionSlot,
6131        text: String,
6132        uuid: String,
6133        holdable: bool,
6134    ) -> Result<(), DispatchError> {
6135        // Stamped at the forge: the CLI never echoes a prompt, so this row
6136        // opens every turn the machinery starts - and a page attaching
6137        // mid-turn anchors its running clock on the first row that carries a
6138        // stamp.
6139        let mut frame = Message::display_only_user(text.clone(), uuid.clone());
6140        frame.stamp_if_absent(&crate::install::rfc3339_now());
6141        // One source for both the dispatch and the frame the dispatch forges:
6142        // the row a view labels and the frame a reader's follow reads arm
6143        // alike only while they are one fact (#2198).
6144        let source = crate::protocol::PromptSource::Forge;
6145        let result = if holdable {
6146            self.dispatch_workspace_prompt_under(key, text, source, uuid)
6147        } else {
6148            self.route(Command::PromptUnder {
6149                key: key.clone(),
6150                text,
6151                attachments: Vec::new(),
6152                uuid,
6153                source,
6154                dispatch_id: None,
6155            })
6156        };
6157        if result.is_ok() {
6158            let _ = self.update_sender().send(SessionUpdate::ChatAppended {
6159                key: key.clone(),
6160                msg: frame,
6161                source: Some(source),
6162            });
6163        }
6164        result
6165    }
6166
6167    /// Tell a row's live owner what the user just did to it, one line.
6168    ///
6169    /// **Forged, not plain.** The board's edits carry no envelope update of
6170    /// their own, so a prompt routed the plain way queues invisibly in a
6171    /// view - the forged frame is what draws the line the moment it lands.
6172    /// Silent for an ownerless row and a sleeping seat: the board is where
6173    /// that edit shows, and a prompt is a real turn.
6174    fn tell_row_owner(self: &Arc<Self>, task: &forge_primitives::tasks::Task, text: String) {
6175        if let Some(owner) = &task.owner
6176            && self.seat_is_live(owner)
6177        {
6178            let _ = self.dispatch_forged_prompt(owner, text);
6179        }
6180    }
6181
6182    /// What a board notice calls the lane a move landed in: the board's own
6183    /// names, so `Pending` reads `backlog` as its lane does.
6184    fn lane_words(to: forge_primitives::tasks::TaskStatus) -> &'static str {
6185        use forge_primitives::tasks::TaskStatus;
6186        match to {
6187            TaskStatus::Pending => "backlog",
6188            TaskStatus::InProgress => "in progress",
6189            TaskStatus::Waiting => "waiting",
6190            TaskStatus::Completed => "completed",
6191            TaskStatus::Failed => "failed",
6192            TaskStatus::Canceled => "canceled",
6193        }
6194    }
6195
6196    /// Route a [`Command`]. Per-session commands (`cmd.key() ==
6197    /// Some(key)`) fan out to the matching `SessionTask`. App-level
6198    /// commands (`cmd.key() == None` - `SpawnProject`) route to the
6199    /// workspace's own handler.
6200    ///
6201    /// Test fallback: when no `SessionTask` is registered for `key`
6202    /// but a `DomainSession` carries a stub `AgentHandle` (e.g., the
6203    /// `Workspace::testing_stub` path), the command runs
6204    /// synchronously against that handle. This keeps `#[test]`-flavor
6205    /// unit tests (no tokio runtime) able to observe the
6206    /// `forge_primitives::AgentCommand` emitted on the stub's channel
6207    /// without spinning up an async actor.
6208    ///
6209    /// # Errors
6210    ///
6211    /// Returns [`DispatchError::UnknownSession`] when no `SessionTask`
6212    /// is registered for the requested key (e.g., the session was just
6213    /// closed), with one exception: a `ResumeSession` starts the seat on the
6214    /// id it names, which is the recovery a failed start leaves. Returns
6215    /// [`DispatchError::SessionClosed`] when the task's command receiver has
6216    /// been dropped.
6217    pub fn dispatch(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6218        self.dispatch_answered(cmd)
6219    }
6220
6221    /// Dispatch one command a view sent over the socket.
6222    ///
6223    /// The same dispatch as [`Self::dispatch`]: the frame a prompt draws is
6224    /// the session task's, emitted when the CLI starts it, so no entry needs
6225    /// to say which door the words came through.
6226    ///
6227    /// # Errors
6228    ///
6229    /// As [`Self::dispatch`].
6230    pub fn dispatch_from_view(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6231        self.dispatch_answered(cmd)
6232    }
6233
6234    /// Route a command, answering the prompts forge handles itself.
6235    ///
6236    /// **A prompt draws its turn in the session task, never here** (#2816):
6237    /// the queue carries its words from dispatch and the page takes the row
6238    /// when the CLI starts it. Only the answer a forge command, a misuse or an
6239    /// unrunnable slash name owes is emitted from this path, because the task
6240    /// never sees those prompts.
6241    ///
6242    /// NOT from `dispatch_workspace_prompt`, which is the delivery path: a
6243    /// delivery already draws as an envelope turn of its own.
6244    fn dispatch_answered(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6245        // The id is minted HERE, before the prose is taken, so the prompt and
6246        // its lifecycle frames carry one thing by id: a plain `Prompt` becomes
6247        // the `PromptUnder` the rest of the tree already treats identically,
6248        // keeping the mint on one path instead of two.
6249        let cmd = match cmd {
6250            Command::Prompt { key, text, attachments } => Command::PromptUnder {
6251                key,
6252                text,
6253                attachments,
6254                uuid: forge_sdk::request_id::next_prompt_id(),
6255                source: crate::protocol::PromptSource::You,
6256                dispatch_id: None,
6257            },
6258            Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id, .. } => {
6259                Command::PromptUnder { key, text, attachments, uuid, source, dispatch_id }
6260            }
6261            other => other,
6262        };
6263        let key = match &cmd {
6264            Command::PromptUnder { key, .. } => Some(key.clone()),
6265            _ => None,
6266        };
6267        // A composer's text that names a forge command is FORGE'S, not the
6268        // CLI's. Decided here rather than in a view, because this is where
6269        // both entries dispatch: the UI's own entry and the socket's reach
6270        // the same commands only by taking the same path, and some of these
6271        // names the CLI answers differently or not at all - `/new` is its own
6272        // `/clear`, which rotates a conversation forge never records.
6273        //
6274        // A forge command's answer comes back with the outcome rather than
6275        // being emitted where it is decided, so it lands after the command has
6276        // run and never before a refusal that wrote nothing.
6277        let (outcome, answer, answered_here) = match &cmd {
6278            Command::PromptUnder { key, text, .. } => match crate::prompt::forge_invocation(text) {
6279                Some(crate::prompt::Invocation::Command(prompt)) => {
6280                    let (outcome, answer) = self.run_forge_prompt(key, &prompt);
6281                    (outcome, answer, true)
6282                }
6283                Some(crate::prompt::Invocation::Misuse(usage)) => {
6284                    (Ok(()), Some(forge_misuse(usage)), true)
6285                }
6286                None => {
6287                    if let Some(refusal) = self.unrunnable_slash_name(key, text) {
6288                        (Ok(()), Some(forge_misuse(&refusal)), true)
6289                    } else {
6290                        (self.route(cmd), None, false)
6291                    }
6292                }
6293            },
6294            _ => (self.route(cmd), None, false),
6295        };
6296        // A refused prompt never reached a model, so the refusal is written to
6297        // the asking socket alone. The task draws the prompt's row once it has
6298        // it, so a route refusal never draws one.
6299        if answered_here
6300            && outcome.is_ok()
6301            && let Some(key) = key
6302            && let Some((severity, answer)) = answer
6303        {
6304            self.notice(&key, severity, &answer);
6305        }
6306        outcome
6307    }
6308
6309    /// Run one of forge's own commands against `key`'s seat.
6310    ///
6311    /// The launch settings are built here rather than taken from the caller:
6312    /// a view that supplied its own would spawn a session with what its own
6313    /// snapshot happened to hold, and a client has no snapshot to supply.
6314    ///
6315    /// The answer is returned rather than emitted, so the dispatcher can land
6316    /// it after the command has run.
6317    fn run_forge_prompt(
6318        self: &Arc<Self>,
6319        key: &SessionSlot,
6320        prompt: &crate::prompt::ForgePrompt,
6321    ) -> (Result<(), DispatchError>, Option<ForgeAnswer>) {
6322        use crate::prompt::ForgePrompt;
6323        // A mode the CLI has no name for is answered rather than sent: the
6324        // command carries the enum, so an unparsed one cannot be dispatched
6325        // at all.
6326        let command = match prompt {
6327            ForgePrompt::NewSession => {
6328                let (cwd, launch_settings) = self.spawn_inputs(key);
6329                // **A replacement is not a sweep**: the slot stays and the
6330                // previous occupant's tabs close before the new one starts.
6331                self.replace_browser_seat(key);
6332                Command::NewSession { key: key.clone(), cwd, launch_settings, session_id: None }
6333            }
6334            // The brief is asked of the session: the same process the
6335            // automatic trigger runs, with the trigger being the person.
6336            ForgePrompt::Handoff => Command::Handoff { key: key.clone(), brief: None },
6337            ForgePrompt::ResumeSession { session_id } => {
6338                let (cwd, launch_settings) = self.spawn_inputs(key);
6339                self.replace_browser_seat(key);
6340                Command::ResumeSession {
6341                    key: key.clone(),
6342                    session_id: session_id.clone(),
6343                    cwd,
6344                    launch_settings,
6345                }
6346            }
6347            ForgePrompt::ShowMode => return self.show_mode(key),
6348            ForgePrompt::SetMode { mode } => {
6349                let Some(mode) = forge_primitives::permission::PermissionMode::from_wire(mode)
6350                else {
6351                    return (Ok(()), Some(forge_misuse(&format!("Unknown mode: {mode}"))));
6352                };
6353                Command::SetMode { key: key.clone(), mode }
6354            }
6355            ForgePrompt::SetModel { model } => {
6356                Command::SetModel { key: key.clone(), model: model.clone() }
6357            }
6358            ForgePrompt::ShowEffort => return self.show_effort(key),
6359            ForgePrompt::SetEffort { level } => return self.set_effort(key, level),
6360        };
6361        (self.route(command), None)
6362    }
6363
6364    /// **A bare `/mode` answers what the session is on**, not what the
6365    /// command's usage is (#1626). The hook's observation outranks the
6366    /// launch's own setting - the precedence the inspector draws - because
6367    /// the CLI can change mode without re-emitting status. A session that
6368    /// has reported neither is answered with the usage line it answered
6369    /// before.
6370    fn show_mode(&self, key: &SessionSlot) -> (Result<(), DispatchError>, Option<ForgeAnswer>) {
6371        let mode = self.domain_session_for(key).and_then(|domain| {
6372            let domain = domain.lock();
6373            domain.observed_permission_mode.or(domain.configured_permission_mode)
6374        });
6375        (
6376            Ok(()),
6377            Some(match mode {
6378                Some(mode) => (NoticeSeverity::Info, format!("Mode: {}", mode.as_wire())),
6379                None => forge_misuse("Usage: /mode <id>"),
6380            }),
6381        )
6382    }
6383
6384    /// **A bare `/effort` answers the level in force**, on `show_mode`'s
6385    /// terms: the observed level, else the one the launch asked for (#1626).
6386    fn show_effort(&self, key: &SessionSlot) -> (Result<(), DispatchError>, Option<ForgeAnswer>) {
6387        let level = self.domain_session_for(key).map(|domain| {
6388            let domain = domain.lock();
6389            domain.observed_effort.unwrap_or(domain.configured_effort)
6390        });
6391        (
6392            Ok(()),
6393            Some(match level {
6394                Some(level) => (NoticeSeverity::Info, format!("Effort: {}", level.as_stored())),
6395                None => forge_misuse("Usage: /effort <level>"),
6396            }),
6397        )
6398    }
6399
6400    /// Tell the fleet a `/new` or `/resume` kept the slot and swapped the
6401    /// occupant, so the previous occupant's tabs close. **Best-effort**, like
6402    /// the spawn's own warm: a workspace with no fleet has no tabs to replace.
6403    pub(crate) fn replace_browser_seat(&self, seat: &SessionSlot) {
6404        let (Some(host), Ok(runtime)) =
6405            (self.browser_host(), tokio::runtime::Handle::try_current())
6406        else {
6407            return;
6408        };
6409        let seat = seat.clone();
6410        runtime.spawn(async move { host.replace_seat(&seat).await });
6411    }
6412
6413    /// **A session ended**, told to the fleet as a fact rather than left to
6414    /// the next sweep: a sweep rebuilds its set from configuration, so a
6415    /// closed lead is still in it. The tombstone is synchronous, so it lands
6416    /// before the release work; the tab close is the fleet's own async work.
6417    fn release_browser_seat(&self, seat: &SessionSlot) {
6418        let Some(host) = self.browser_host() else { return };
6419        host.session_ended(seat);
6420        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
6421            // **The tombstone landed; the tab close did not.** A close
6422            // called off a runtime thread leaves the seat's tabs to the next
6423            // sweep, and says so rather than returning silently.
6424            tracing::warn!(
6425                target: "forge_workspace::workspace",
6426                seat = %seat.display(),
6427                "a seat ended but its browser tab replacement could not be spawned \
6428                 (event_name browser_seat_end_without_replacement)"
6429            );
6430            return;
6431        };
6432        let seat = seat.clone();
6433        runtime.spawn(async move { host.replace_seat(&seat).await });
6434    }
6435
6436    /// The cwd and settings a re-spawn on `key` carries.
6437    fn spawn_inputs(&self, key: &SessionSlot) -> (String, SessionLaunchSettings) {
6438        let cwd = self.cwd_for_session(key).unwrap_or_default();
6439        let launch_settings = self.launch_settings_for(key, &cwd);
6440        (cwd, launch_settings)
6441    }
6442
6443    /// Start a seat on an id when no task is running for it: the `/resume
6444    /// <old-id>` recovery a failed handoff names.
6445    ///
6446    /// A lead goes through the project wake, so a failed start surfaces the
6447    /// same `ConnectionFailed` and the same fallback a wake has. The id named
6448    /// is recorded first because that path resumes the slot's stored id; it
6449    /// names a transcript that already exists, so a failed start leaves a real
6450    /// id rather than an unrun one. A worker goes through the spawn its boot
6451    /// re-spawn uses, with the row's own charter, tree and family selection.
6452    fn resume_without_task(
6453        self: &Arc<Self>,
6454        key: &SessionSlot,
6455        session_id: &str,
6456        launch_settings: SessionLaunchSettings,
6457    ) -> Result<(), DispatchError> {
6458        if key.is_lead() {
6459            self.note_running_session_id(key, session_id);
6460            // The failure is surfaced by the frame the handler emits; this
6461            // recovery path has no caller to answer with it.
6462            let _ = crate::spawn::handle_spawn_project(self, key.project(), launch_settings);
6463            return Ok(());
6464        }
6465        let Some(view) = self
6466            .list_projects()
6467            .into_iter()
6468            .find(|view| view.org == key.org() && view.name == key.project())
6469        else {
6470            return Err(DispatchError::UnknownSession(key.clone()));
6471        };
6472        let row = match self.stored_worker_row(&view.key, key.label()) {
6473            Ok(Some(row)) => row,
6474            Ok(None) => return Err(DispatchError::UnknownSession(key.clone())),
6475            Err(error) => {
6476                tracing::warn!(
6477                    target: "forge_workspace::workspace",
6478                    event_name = "resume_worker_row_unreadable",
6479                    slot = %key.display(),
6480                    %error,
6481                    "the worker's row could not be read; a task-less resume needs it for the charter and tree",
6482                );
6483                return Err(DispatchError::UnknownSession(key.clone()));
6484            }
6485        };
6486        // **The failure this resume replaces goes first.** A failed spawn
6487        // leaves its entry for a worker with no worktree; it is not live, so
6488        // the insert below would append a second entry for the slot and the
6489        // by-slot reads would move the tombstone instead of the new entry.
6490        self.clear_failed_worker(&view.key, key.label());
6491        let (return_to, mut reply) = tokio::sync::oneshot::channel();
6492        let outcome = self.dispatch(Command::SpawnWorker {
6493            project_key: view.key,
6494            label: key.label().to_owned(),
6495            charter: row.charter.unwrap_or_default(),
6496            spawned_by: SessionSlot::lead(key.org(), key.project()),
6497            resume_existing: Some(session_id.to_owned()),
6498            // **An explicit `/resume` is the reader's own act**, so it
6499            // delivers the re-orient kick whatever the row says: unlike a
6500            // boot re-spawn or a heal, the person is asking this seat to
6501            // carry on.
6502            kick: row.resume_kick.or_else(|| Some(DYNAMIC_WORKER_RESTART_NOTE.to_owned())),
6503            resume_kick: None,
6504            interactive: row.interactive.unwrap_or(false),
6505            mcp_families: row.mcp_families,
6506            cwd: row.cwd,
6507            // **A user-driven restore is the row restore a boot re-spawn
6508            // does.** The row already holds the worker's cap slot and tree,
6509            // so the restore semantics fit: no cap check, and a failure kept
6510            // as the visible tombstone. The refusal the handler does answer
6511            // is surfaced below rather than dropped.
6512            from_boot_respawn: true,
6513            return_to: Some(return_to),
6514        });
6515        // The handler answers before it returns, so a restore that was
6516        // refused says why instead of echoing as accepted.
6517        if let Ok(Err(reason)) = reply.try_recv() {
6518            self.notice(key, NoticeSeverity::Error, &format!("the resume was refused: {reason}"));
6519        }
6520        outcome
6521    }
6522
6523    /// Fill in the launch settings a caller did not build.
6524    ///
6525    /// A caller with its own snapshot of the documents builds them and
6526    /// hands them over; a client has no config to read, so a spawn it asks
6527    /// for arrives with none, and a spawn that ran on those alone would carry
6528    /// no language, no model and the CLI's own defaults for permissions,
6529    /// effort and output style - a session launched differently from one
6530    /// started with the documents in hand.
6531    ///
6532    /// Read from the workspace's own config dir, which is what a session with
6533    /// no account-scoped dir of its own runs under. A caller bound to an
6534    /// account builds its own from that account, so the two can differ where
6535    /// an account names a dir of its own - and where they do, the caller that
6536    /// supplied settings keeps them.
6537    fn fill_launch_settings(&self, launch_settings: &mut SessionLaunchSettings, cwd: Option<&str>) {
6538        if launch_settings.settings.is_some() {
6539            return;
6540        }
6541        let empty = || serde_json::Value::Object(serde_json::Map::new());
6542        let documents_cwd = cwd.filter(|cwd| !cwd.is_empty()).map(std::path::Path::new);
6543        let documents =
6544            forge_agent::userdata::settings::settings_documents(self.config_dir(), documents_cwd);
6545        let user = documents.user.unwrap_or_else(empty);
6546        let local = documents.project_local.unwrap_or_else(empty);
6547        let preferences = self.user_preferences().unwrap_or_else(empty);
6548        let built = crate::launch_settings::session_launch_settings(
6549            &crate::launch_settings::LaunchSettingsDocuments {
6550                user: &user,
6551                local: &local,
6552                preferences: &preferences,
6553            },
6554        );
6555        launch_settings.language = built.language;
6556        launch_settings.settings = built.settings;
6557        launch_settings.agent_progress_summaries = built.agent_progress_summaries;
6558    }
6559
6560    /// `/effort <level>`: write the level the next launch reads.
6561    ///
6562    /// A settings write rather than a session command - the CLI carries no
6563    /// control request for effort - so it lands in the same document the
6564    /// launch builder reads and takes effect when the session next starts.
6565    fn set_effort(
6566        self: &Arc<Self>,
6567        key: &SessionSlot,
6568        level: &str,
6569    ) -> (Result<(), DispatchError>, Option<ForgeAnswer>) {
6570        let Some(level) = forge_primitives::EffortLevel::from_stored(level) else {
6571            return (Ok(()), Some(forge_misuse(&format!("Unknown effort level: {level}"))));
6572        };
6573        // The seat's own config dir where there is one, and the one forge runs
6574        // under otherwise: effort is a user-level setting, so it does not
6575        // depend on a session being live.
6576        let config_dir =
6577            self.config_dir_for(key).unwrap_or_else(|| self.config_dir().to_path_buf());
6578        let path = config_dir.join("settings.json");
6579        let held = forge_agent::userdata::settings::settings_documents(&config_dir, None)
6580            .user
6581            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
6582        // A settings file holding something other than an object is one this
6583        // cannot add a key to. Writing the key alone is better than reporting
6584        // a success over a document nothing was added to.
6585        let mut document =
6586            if held.is_object() { held } else { serde_json::Value::Object(serde_json::Map::new()) };
6587        document["effortLevel"] = serde_json::Value::String(level.as_stored().to_owned());
6588        match forge_agent::userdata::settings::save_document(&path, &document) {
6589            Ok(()) => (
6590                Ok(()),
6591                Some((
6592                    NoticeSeverity::Info,
6593                    format!("Effort: {} (takes effect next session)", level.label()),
6594                )),
6595            ),
6596            Err(err) => (Ok(()), Some(forge_misuse(&format!("Failed to save effort: {err}")))),
6597        }
6598    }
6599
6600    /// The line to answer a slash name this session cannot run, or `None`
6601    /// when the text is the CLI's to read.
6602    ///
6603    /// What a session can run is what its CLI advertised, or one of the names
6604    /// the CLI resolves without advertising ([`crate::prompt::FORWARDED`]).
6605    /// A session that has advertised nothing refuses nothing: an empty
6606    /// catalogue is not knowing, and refusing on ignorance would drop names
6607    /// the CLI has.
6608    fn unrunnable_slash_name(&self, key: &SessionSlot, text: &str) -> Option<String> {
6609        let name = text.split_whitespace().next()?;
6610        if !name.starts_with('/') {
6611            return None;
6612        }
6613        let advertised = self.available_commands_for(key);
6614        if advertised.is_empty()
6615            || advertised
6616                .iter()
6617                .any(|command| forge_agent::translate::commands::slash_name(&command.name) == name)
6618            || crate::prompt::is_forwarded_name(name)
6619        {
6620            return None;
6621        }
6622        Some(format!("{name} is not yet supported"))
6623    }
6624
6625    /// Emit one line the core has for a view about `key`.
6626    pub(crate) fn notice(&self, key: &SessionSlot, severity: NoticeSeverity, text: &str) {
6627        let _ = self.update_sender().send(SessionUpdate::Notice {
6628            key: key.clone(),
6629            severity,
6630            text: text.to_owned(),
6631        });
6632    }
6633
6634    /// The settings a launch on `key` carries, read from the same documents
6635    /// the CLI reads.
6636    pub(crate) fn launch_settings_for(
6637        &self,
6638        key: &SessionSlot,
6639        cwd: &str,
6640    ) -> SessionLaunchSettings {
6641        let empty = || serde_json::Value::Object(serde_json::Map::new());
6642        // An empty cwd is no cwd: joining `.claude/settings.local.json` onto
6643        // one would read it against the process working directory.
6644        let documents_cwd = (!cwd.is_empty()).then(|| std::path::Path::new(cwd));
6645        let documents = self.settings_documents(key, documents_cwd);
6646        let user =
6647            documents.as_ref().and_then(|documents| documents.user.clone()).unwrap_or_else(empty);
6648        let local = documents
6649            .as_ref()
6650            .and_then(|documents| documents.project_local.clone())
6651            .unwrap_or_else(empty);
6652        let preferences = self.user_preferences().unwrap_or_else(empty);
6653        crate::launch_settings::session_launch_settings(
6654            &crate::launch_settings::LaunchSettingsDocuments {
6655                user: &user,
6656                local: &local,
6657                preferences: &preferences,
6658            },
6659        )
6660    }
6661
6662    /// Route a command that carries no frame of its own.
6663    pub(crate) fn route(self: &Arc<Self>, mut cmd: Command) -> Result<(), DispatchError> {
6664        // A spawn a caller could not complete is completed here, ahead of
6665        // routing: a client has no config to read, so what it sends carries no
6666        // settings, and a spawn that ran on those alone would carry no
6667        // language and the CLI's own defaults for permissions, effort and
6668        // output style. Ahead of the intercept below because a command is
6669        // finished before it is dispatched, and what a test reads there is
6670        // what the handler will receive.
6671        if let Command::SpawnProject { project_name, launch_settings } = &mut cmd {
6672            let cwd = self
6673                .find_project_view_by_name(project_name)
6674                .map(|project| project.path.to_string_lossy().into_owned())
6675                .unwrap_or_default();
6676            self.fill_launch_settings(launch_settings, Some(&cwd));
6677        }
6678        // Test intercept (when armed): capture EVERY Command - both
6679        // app-level and per-session - before any routing. Tests use
6680        // this to assert what would have been dispatched without
6681        // spinning up real subprocesses or stub SessionTasks. Always
6682        // a no-op in production builds without the testing feature.
6683        #[cfg(any(test, feature = "testing"))]
6684        {
6685            let mut intercept = self.command_intercept.lock();
6686            if let Some(buffer) = intercept.as_mut() {
6687                buffer.push(cmd);
6688                return Ok(());
6689            }
6690        }
6691        // A prompt already answered, or one that asked something else, is a
6692        // dock that is gone: the reader's click did nothing. The session task
6693        // itself just logs that, and by the time it sees the answer the caller
6694        // is long gone - so it is refused here, the last layer that can still
6695        // tell one.
6696        match &cmd {
6697            Command::RespondPermission { key, tool_id, .. } => {
6698                let waiting = self
6699                    .domain_session_for(key)
6700                    .is_some_and(|domain| domain.lock().awaits_permission(tool_id));
6701                if !waiting {
6702                    return Err(DispatchError::NoPromptWaiting {
6703                        key: key.clone(),
6704                        tool_id: tool_id.clone(),
6705                    });
6706                }
6707            }
6708            Command::RespondQuestion { key, tool_id, .. } => {
6709                let waiting = self
6710                    .domain_session_for(key)
6711                    .is_some_and(|domain| domain.lock().awaits_question(tool_id));
6712                if !waiting {
6713                    return Err(DispatchError::NoPromptWaiting {
6714                        key: key.clone(),
6715                        tool_id: tool_id.clone(),
6716                    });
6717                }
6718            }
6719            Command::RespondSlackPost { key, id, .. } => {
6720                let waiting = self.slack_draft_waiting(*id, key);
6721                if !waiting {
6722                    return Err(DispatchError::NoDraftWaiting { key: key.clone(), id: *id });
6723                }
6724            }
6725            Command::RespondBrowserHandOff { key, id, .. } => {
6726                let waiting = self.browser_handoff_waiting(*id, key);
6727                if !waiting {
6728                    return Err(DispatchError::NoBrowserHandOffWaiting {
6729                        key: key.clone(),
6730                        id: *id,
6731                    });
6732                }
6733            }
6734            Command::RespondTerminalHandOff { key, id, .. } => {
6735                let waiting = self.terminal_handoff_waiting(*id, key);
6736                if !waiting {
6737                    return Err(DispatchError::NoTerminalHandOffWaiting {
6738                        key: key.clone(),
6739                        id: *id,
6740                    });
6741                }
6742            }
6743            Command::AnswerDocumentHandOff { key, id, .. } => {
6744                let waiting = self.document_handoff_waiting(*id, key);
6745                if !waiting {
6746                    return Err(DispatchError::NoDocumentHandOffWaiting {
6747                        key: key.clone(),
6748                        id: *id,
6749                    });
6750                }
6751            }
6752            Command::RespondSettingsWrite { key, id, .. } => {
6753                let waiting = self.settings_write_waiting(*id, key);
6754                if !waiting {
6755                    return Err(DispatchError::NoSettingsWriteWaiting {
6756                        key: key.clone(),
6757                        id: *id,
6758                    });
6759                }
6760            }
6761            Command::RespondVaultAsk { key, id, .. } => {
6762                let waiting = self.vault_ask_waiting(*id, key);
6763                if !waiting {
6764                    return Err(DispatchError::NoVaultAskWaiting { key: key.clone(), id: *id });
6765                }
6766            }
6767            _ => {}
6768        }
6769        if let Some(key) = cmd.key() {
6770            // The /dictate override edits are workspace state on the
6771            // DomainSession, never agent traffic: apply inline and
6772            // echo, ahead of the SessionTask routing below.
6773            match cmd {
6774                Command::SetDictateOverride { key, update } => {
6775                    return self.apply_dictate_override(&key, update);
6776                }
6777                Command::ResetDictateOverrides { key } => {
6778                    return self.apply_dictate_override(
6779                        &key,
6780                        crate::dictate::DictateOverrideUpdate::Reset,
6781                    );
6782                }
6783                // The seat's voice state: workspace state, never agent
6784                // traffic, so it does not ride the session task.
6785                Command::VoiceMode { key, on, explicit, initiator } => {
6786                    let client = initiator.unwrap_or(0);
6787                    let voice = self.voice_service();
6788                    if voice.enabled() {
6789                        voice.set_listening(&key, client, on, explicit);
6790                    }
6791                    let listeners = u32::try_from(voice.listeners(&key)).unwrap_or(u32::MAX);
6792                    let _ =
6793                        self.update_sender().send(SessionUpdate::VoiceModeState { key, listeners });
6794                    return Ok(());
6795                }
6796                // The refusals a page acts on are answered here, and the
6797                // render itself rides the seat's queue rather than holding
6798                // this dispatch for a model load.
6799                Command::VoicePreview { key, voice } => {
6800                    let service = self.voice_service();
6801                    if !service.enabled() {
6802                        return Err(crate::DispatchError::VoiceOff {
6803                            reason: "[tts] enabled is false".to_owned(),
6804                        });
6805                    }
6806                    if service.listeners(&key) == 0 {
6807                        return Err(crate::DispatchError::VoiceOff {
6808                            reason: "that seat's speaker switch is off".to_owned(),
6809                        });
6810                    }
6811                    let Some(files) = self.speaking_files() else {
6812                        return Err(crate::DispatchError::VoiceOff {
6813                            reason: "the speaking model is not installed".to_owned(),
6814                        });
6815                    };
6816                    let installed = files.installed_voices().map_err(|error| {
6817                        crate::DispatchError::VoiceOff { reason: error.to_string() }
6818                    })?;
6819                    forge_tts::select_voice(Some(&voice), &installed).map_err(|error| {
6820                        crate::DispatchError::VoiceOff { reason: error.to_string() }
6821                    })?;
6822                    // A failure the client is still drawing is cleared here, and
6823                    // the read pushed: an accepted press is the promise the old
6824                    // error no longer stands, whatever this render does.
6825                    if self.voice_preview_error.lock().is_ok_and(|mut slot| slot.take().is_some()) {
6826                        self.push_models();
6827                    }
6828                    let this = Arc::clone(self);
6829                    tokio::spawn(async move {
6830                        if let Err(error) = service.preview(&key, voice).await {
6831                            tracing::warn!(
6832                                event_name = "voice_preview_failed",
6833                                slot = %key.display(),
6834                                %error,
6835                                "the voice preview did not render"
6836                            );
6837                            if let Ok(mut slot) = this.voice_preview_error.lock() {
6838                                *slot = Some(error.to_string());
6839                            }
6840                            this.push_models();
6841                        }
6842                    });
6843                    return Ok(());
6844                }
6845                _ => {}
6846            }
6847            let key = key.clone();
6848            // **A hand-off parked by the outgoing occupant cannot outlive
6849            // it.** `/new` and `/resume` replace the session that is waiting,
6850            // and a park left behind would blind the new occupant's first
6851            // terminal call with an ask nobody owns.
6852            if matches!(&cmd, Command::NewSession { .. } | Command::ResumeSession { .. }) {
6853                self.abandon_terminal_hand_offs(&key);
6854            }
6855            // /new and /resume re-spawn on the already-pooled handle, where
6856            // get_agent_handle_at_key's stamp never runs.
6857            let resuming = matches!(&cmd, Command::ResumeSession { .. });
6858            if let Command::NewSession { launch_settings, .. }
6859            | Command::ResumeSession { launch_settings, .. } = &mut cmd
6860            {
6861                let pooled = self
6862                    .pool
6863                    .lock()
6864                    .get(&key)
6865                    .map(|p| (p.permission_mode, p.registration.clone(), p.account.clone()));
6866                match pooled {
6867                    // A resume on a seat whose task has gone has no pooled
6868                    // handle by design; the wake's own spawn stamps it. The
6869                    // warning is for a live seat caught between teardown and
6870                    // release.
6871                    None if resuming => tracing::debug!(
6872                        target: "forge_workspace::workspace",
6873                        event_name = "resume_without_pool_entry",
6874                        slot = %key.display(),
6875                        "a task-less resume has no pooled handle to stamp; the wake's spawn stamps itself",
6876                    ),
6877                    None => tracing::warn!(
6878                        target: "forge_workspace::workspace",
6879                        slot = %key.display(),
6880                        "permission_mode stamp skipped: respawn routed but no pool entry \
6881                         (release_session teardown window)",
6882                    ),
6883                    Some((mode, registration, account)) => {
6884                        // The respawned CLI runs the project's model too:
6885                        // this path bypasses the spawn-time stamp.
6886                        let config = self.config_arc();
6887                        let project = registration.as_ref().and_then(|registration| {
6888                            config
6889                                .projects
6890                                .iter()
6891                                .find(|project| project.name == registration.project)
6892                        });
6893                        apply_project_model(project, launch_settings);
6894                        if let Some(mode) = mode {
6895                            spawn::stamp_permission_mode(launch_settings, mode);
6896                        } else {
6897                            tracing::debug!(
6898                                target: "forge_workspace::workspace",
6899                                slot = %key.display(),
6900                                account = %account.0,
6901                                "respawn keeps the launcher default: no project resolved for \
6902                                 this session, so there is no permission mode to stamp",
6903                            );
6904                        }
6905                    }
6906                }
6907            }
6908            let senders = self.command_senders.lock();
6909            if let Some(sender) = senders.get(&key) {
6910                // Stamp turn_pending only on the routed path (set + route
6911                // together) so the in-flight guards can't race a Prompt
6912                // whose wire-lagged `Running` echo hasn't landed yet. The
6913                // committed turn also spends the previous turn's failure
6914                // mark: the newest turn is this one now.
6915                let mut committed = false;
6916                if matches!(cmd, Command::Prompt { .. } | Command::PromptUnder { .. })
6917                    && let Some(domain) = self.domain_session_for(&key)
6918                {
6919                    let mut guard = domain.lock();
6920                    // A prompt committed with no turn in flight starts a new
6921                    // one, and a cancel stamp the last turn left armed expires
6922                    // here: nothing would spend it, and left standing it would
6923                    // exempt this turn's genuine failure. A prompt that only
6924                    // queues behind a busy turn keeps the stamp - that turn
6925                    // is still the one it was armed for, and its error Result
6926                    // still has to read as the reader's own cancel.
6927                    //
6928                    // The nudge is the running turn's for the same reason:
6929                    // a queued prompt is not the turn that will fail next.
6930                    //
6931                    // The failure mark is the run's too. A committed prompt
6932                    // spends it because the newest turn is then this one; a
6933                    // prompt that only queues has committed nothing, and
6934                    // clearing the mark there leaves a failure nobody has
6935                    // seen drawing no rail mark.
6936                    if !guard.turn_in_flight() {
6937                        guard.pending_cancel = false;
6938                        guard.auto_continue = None;
6939                        guard.failed_turn_at = None;
6940                    }
6941                    committed = guard.commit_turn();
6942                }
6943                // **The turn starts here, before the CLI hears of it.** A
6944                // boot that finds the row mid-turn hands the seat the
6945                // restart note, so the write is the spawn's turn marker and
6946                // not the CLI's echo: a crash between this send and the
6947                // Result leaves the interrupted turn on the row.
6948                if committed {
6949                    self.record_seat_state(&key, crate::store::sessions::SeatState::InTurn);
6950                }
6951                // A prompt the reader's own hands sent moves the seat on: a
6952                // death trace still standing is taken with it, whether the
6953                // prompt commits a turn or queues behind one - it is the
6954                // reader looking, not the turn. A cron's, a peer's or
6955                // forge's own prompt is not, so none of those spends it.
6956                if let Command::PromptUnder { source: crate::protocol::PromptSource::You, .. } =
6957                    &cmd
6958                {
6959                    self.spend_death(&key);
6960                }
6961                // Arm the cancel stamp on the routed path, so the turn's
6962                // own failed `Result` can tell a reader's interrupt from a
6963                // genuine error. An idle Cancel arms nothing.
6964                if matches!(cmd, Command::Cancel { .. })
6965                    && let Some(domain) = self.domain_session_for(&key)
6966                {
6967                    let mut guard = domain.lock();
6968                    if guard.turn_in_flight() {
6969                        guard.pending_cancel = true;
6970                    }
6971                }
6972                // **A send that never reached the task spends the turn it
6973                // just committed.** No Result is coming for a prompt the task
6974                // never saw, so leaving `turn_pending` set draws a turn in
6975                // flight on the header that nothing can close.
6976                if let Err(unsent) = sender.send(cmd) {
6977                    let _ = unsent;
6978                    if committed {
6979                        if let Some(domain) = self.domain_session_for(&key) {
6980                            domain.lock().uncommit_turn();
6981                        }
6982                        // No turn is running for a prompt the task never
6983                        // saw, so the row must not say one is.
6984                        self.record_seat_state(&key, crate::store::sessions::SeatState::Idle);
6985                    }
6986                    return Err(DispatchError::SessionClosed(key));
6987                }
6988                return Ok(());
6989            }
6990            drop(senders);
6991            // **`/resume` on a seat whose task has exited is the recovery
6992            // the handoff promises.** The task leaves with its child, so the
6993            // command lands here with no sender; start the seat on the id it
6994            // names rather than answering `UnknownSession`. A failed start
6995            // still surfaces as `ConnectionFailed`, and the project wake
6996            // remains the fallback beside it.
6997            // A test that wired a stub handle expects the synchronous fallback
6998            // below to take the command; only a genuinely ownerless seat goes
6999            // through the wake.
7000            let stub_fallback_can_take_it =
7001                cfg!(any(test, feature = "testing")) && self.agent_handle_for(&key).is_some();
7002            if !stub_fallback_can_take_it
7003                && let Command::ResumeSession { key, session_id, launch_settings, .. } = &cmd
7004            {
7005                return self.resume_without_task(key, session_id, launch_settings.clone());
7006            }
7007            // In production this branch returns `UnknownSession`: a
7008            // per-session `Command` arrives before a `SessionTask`
7009            // exists, which is the contract violation the error
7010            // signals. Under `cfg(any(test, feature = "testing"))`,
7011            // tests that wire a stub `AgentHandle` directly onto a
7012            // `DomainSession` (without a running tokio runtime to host
7013            // a real `SessionTask`) get a synchronous fallback so the
7014            // command still reaches the stub. Gating this here means
7015            // the fallback is structurally unreachable in production -
7016            // a future refactor can't open the race window silently.
7017            #[cfg(any(test, feature = "testing"))]
7018            {
7019                let Some(handle) = self.agent_handle_for(&key) else {
7020                    return Err(DispatchError::UnknownSession(key));
7021                };
7022                let sid = self.domain_handles.lock().get(&key).and_then(|d| {
7023                    d.lock().session_id.as_ref().map(std::string::ToString::to_string)
7024                });
7025                let fresh = match &cmd {
7026                    Command::NewSession { session_id: Some(id), .. } => Some(id.clone()),
7027                    Command::NewSession { session_id: None, .. } => {
7028                        Some(self.fresh_session_id_for(&key))
7029                    }
7030                    _ => None,
7031                };
7032                // A respawn moves the id the child runs under, so its
7033                // gateway binding moves with it. The routed path stamps
7034                // in the `SessionTask`, where `/new`'s id is minted;
7035                // this fallback holds the same id here, so it stamps the
7036                // same way rather than leaving the child on the previous
7037                // occupant's segment.
7038                let respawn_id = match &cmd {
7039                    Command::NewSession { .. } => fresh.clone(),
7040                    Command::ResumeSession { session_id, .. } => Some(session_id.clone()),
7041                    _ => None,
7042                };
7043                if let Some(respawn_id) = respawn_id.as_deref()
7044                    && let Command::NewSession { launch_settings, .. }
7045                    | Command::ResumeSession { launch_settings, .. } = &mut cmd
7046                {
7047                    self.stamp_respawn_overrides(&key, respawn_id, launch_settings);
7048                }
7049                crate::session_task::execute_command_via_handle(
7050                    &handle,
7051                    &key,
7052                    sid.as_deref(),
7053                    fresh,
7054                    cmd,
7055                )
7056                .map_err(|_| DispatchError::SessionClosed(key))
7057            }
7058            #[cfg(not(any(test, feature = "testing")))]
7059            {
7060                let _ = cmd;
7061                Err(DispatchError::UnknownSession(key))
7062            }
7063        } else {
7064            // App-level commands. The `spawn::*` handlers are sync -
7065            // they emit one event, kick off `get_agent_handle_at_key`
7066            // (which internally tokio::spawns the agent), and return.
7067            // Run them inline under the span; no detach needed.
7068            match cmd {
7069                Command::SpawnProject { project_name, launch_settings } => {
7070                    let span = tracing::info_span!(
7071                        "spawn_project",
7072                        project = %project_name,
7073                    );
7074                    let _enter = span.enter();
7075                    // A command has no caller to answer: the failure reaches
7076                    // the views through the frame the handler emits.
7077                    let _ = spawn::handle_spawn_project(self, &project_name, launch_settings);
7078                }
7079                Command::DeliverPeerPrompt { caller, target_project, wrapped, respond } => {
7080                    let span = tracing::info_span!(
7081                        "deliver_peer_prompt",
7082                        target = %target_project,
7083                        message_id = %wrapped.id,
7084                    );
7085                    let _enter = span.enter();
7086                    let outcome =
7087                        spawn::handle_deliver_peer_prompt(self, &caller, target_project, wrapped);
7088                    // The answer is the handler's own, handed back to
7089                    // whoever awaited the dispatch: a caller that got no
7090                    // answer must not read one into the silence.
7091                    if let Some(respond) = respond {
7092                        let _ = respond.send(outcome);
7093                    }
7094                }
7095                Command::SpawnWorker {
7096                    project_key,
7097                    label,
7098                    charter,
7099                    spawned_by,
7100                    resume_existing,
7101                    kick,
7102                    resume_kick,
7103                    interactive,
7104                    mcp_families,
7105                    cwd,
7106                    from_boot_respawn,
7107                    return_to,
7108                } => {
7109                    let span = tracing::info_span!(
7110                        "spawn_worker",
7111                        project = %project_key.as_str(),
7112                        label = %label,
7113                        resume = resume_existing.is_some(),
7114                        interactive,
7115                    );
7116                    let _enter = span.enter();
7117                    spawn::handle_spawn_worker(
7118                        self,
7119                        project_key,
7120                        spawn::WorkerSpawnArgs {
7121                            label,
7122                            charter,
7123                            kick,
7124                            resume_kick,
7125                            interactive,
7126                            mcp_families,
7127                            cwd,
7128                        },
7129                        spawned_by,
7130                        resume_existing.as_deref(),
7131                        from_boot_respawn,
7132                        return_to.unwrap_or_else(crate::protocol::unanswerable),
7133                    );
7134                }
7135                Command::CloseWorker { project_key, label } => {
7136                    let span = tracing::info_span!(
7137                        "close_worker",
7138                        project = %project_key.as_str(),
7139                        label = %label,
7140                    );
7141                    let _enter = span.enter();
7142                    spawn::handle_close_worker(self, &project_key, &label);
7143                }
7144                Command::DespawnWorker { project_key, label, force, respond } => {
7145                    let span = tracing::info_span!(
7146                        "despawn_worker",
7147                        project = %project_key.as_str(),
7148                        label = %label,
7149                        force,
7150                    );
7151                    let _enter = span.enter();
7152                    spawn::handle_despawn_worker(
7153                        self,
7154                        &project_key,
7155                        &label,
7156                        force,
7157                        respond.unwrap_or_else(crate::protocol::unanswerable),
7158                    );
7159                }
7160                Command::DeliverWorkerPrompt {
7161                    caller,
7162                    project_key,
7163                    target_label,
7164                    wrapped,
7165                    respond,
7166                } => {
7167                    let span = tracing::info_span!(
7168                        "deliver_worker_prompt",
7169                        project = %project_key.as_str(),
7170                        label = %target_label,
7171                        message_id = %wrapped.id,
7172                    );
7173                    let _enter = span.enter();
7174                    spawn::handle_deliver_worker_prompt(
7175                        self,
7176                        &caller,
7177                        &project_key,
7178                        &target_label,
7179                        wrapped,
7180                        respond,
7181                    );
7182                }
7183                Command::DeliverWorkerPromptToLead {
7184                    caller,
7185                    target_lead_key,
7186                    wrapped,
7187                    respond,
7188                } => {
7189                    let span = tracing::info_span!(
7190                        "deliver_worker_prompt_to_lead",
7191                        slot = %target_lead_key.display(),
7192                        message_id = %wrapped.id,
7193                    );
7194                    let _enter = span.enter();
7195                    spawn::handle_deliver_worker_prompt_to_lead(
7196                        self,
7197                        &caller,
7198                        &target_lead_key,
7199                        wrapped,
7200                        respond,
7201                    );
7202                }
7203                Command::DeliverGotifyMessage { project, team_role, notification } => {
7204                    let span = tracing::info_span!(
7205                        "deliver_gotify_message",
7206                        project = %project,
7207                        app = %notification.app,
7208                        priority = notification.priority,
7209                    );
7210                    let _enter = span.enter();
7211                    spawn::deliver_gotify_message(
7212                        self,
7213                        &project,
7214                        team_role.as_deref(),
7215                        notification,
7216                    );
7217                }
7218                Command::RespondSlackPost { key, id, answer } => {
7219                    // A send-back with no words is not an answer: it would
7220                    // drop the reader's card and hand the agent nothing. The
7221                    // draft stays waiting so the field can be sent again.
7222                    if let forge_primitives::slack::SlackDraftAnswer::SendBack { note } = &answer
7223                        && note.trim().is_empty()
7224                    {
7225                        return Err(DispatchError::EmptySlackFeedback { key: key.clone(), id });
7226                    }
7227                    // The guard above already refused a draft this one is not
7228                    // waiting for, so a `false` here is a resolve that landed
7229                    // between the two: the same refusal, not a silent drop.
7230                    let answered = self.resolve_slack_draft(
7231                        id,
7232                        &key,
7233                        crate::slack::DraftResolution::Answered(answer),
7234                    );
7235                    if !answered {
7236                        return Err(DispatchError::NoDraftWaiting { key: key.clone(), id });
7237                    }
7238                }
7239                Command::RespondBrowserHandOff { key, id, answer } => {
7240                    // Same shape as the Slack arm above: the guard already
7241                    // refused one that is not waiting, so a `false` here is a
7242                    // resolve that landed between the two.
7243                    // **The answer names its own hand**: the ending, the door
7244                    // that sent it and the connection it came over, so a
7245                    // hand-off never resolves with nothing attributable
7246                    // behind it (2026-10-07, 2026-10-26).
7247                    tracing::debug!(
7248                        event_name = "browser_hand_off_answered",
7249                        key = ?key,
7250                        id = %id,
7251                        ending = ?answer.ending,
7252                        source = ?answer.source,
7253                        client = answer.client,
7254                        "a client answered a parked browser hand-off"
7255                    );
7256                    let answered = self.resolve_browser_hand_off(id, &key, answer);
7257                    if !answered {
7258                        return Err(DispatchError::NoBrowserHandOffWaiting {
7259                            key: key.clone(),
7260                            id,
7261                        });
7262                    }
7263                }
7264                Command::RespondTerminalHandOff { key, id, answer } => {
7265                    // Same shape as the browser arm above: the guard already
7266                    // refused one that is not waiting, and the answer names
7267                    // its own hand.
7268                    tracing::debug!(
7269                        event_name = "terminal_hand_off_answered",
7270                        key = ?key,
7271                        id = %id,
7272                        ending = ?answer.ending,
7273                        source = ?answer.source,
7274                        client = answer.client,
7275                        "a client answered a parked terminal hand-off"
7276                    );
7277                    let answered = self.resolve_terminal_hand_off(id, &key, answer);
7278                    if !answered {
7279                        return Err(DispatchError::NoTerminalHandOffWaiting {
7280                            key: key.clone(),
7281                            id,
7282                        });
7283                    }
7284                }
7285                Command::AnswerDocumentHandOff { key, id, answer } => {
7286                    // Same shape as the browser arm above: the guard already
7287                    // refused one that is not waiting, so a `false` here is a
7288                    // resolve that landed between the two.
7289                    tracing::debug!(
7290                        event_name = "document_hand_off_answered",
7291                        key = ?key,
7292                        id = %id,
7293                        ?answer,
7294                        "a client answered a parked document hand-off"
7295                    );
7296                    let answered = self.resolve_document_hand_off(id, &key, answer);
7297                    if !answered {
7298                        return Err(DispatchError::NoDocumentHandOffWaiting {
7299                            key: key.clone(),
7300                            id,
7301                        });
7302                    }
7303                }
7304                Command::RespondSettingsWrite { key, id, approved } => {
7305                    // Same shape as the two arms above: the guard already
7306                    // refused one that is not waiting, so a `false` here is a
7307                    // resolve that landed between the two.
7308                    let answered = self.resolve_settings_write(id, &key, approved);
7309                    if !answered {
7310                        return Err(DispatchError::NoSettingsWriteWaiting { key: key.clone(), id });
7311                    }
7312                }
7313                Command::RespondVaultAsk { key, id, answer } => {
7314                    let answered = self.resolve_vault_ask(id, &key, answer);
7315                    if !answered {
7316                        return Err(DispatchError::NoVaultAskWaiting { key: key.clone(), id });
7317                    }
7318                }
7319                Command::DictateCatalogueCheck => {
7320                    return self.check_dictate_catalogue();
7321                }
7322                Command::PluginInstall { plugin_id, scope, project } => {
7323                    return self.start_plugin_install(plugin_id, scope, project.as_deref());
7324                }
7325                Command::PluginUpdate { plugin_id, scope } => {
7326                    return self.start_plugin_update(plugin_id, scope);
7327                }
7328                Command::PluginUpdateAll => {
7329                    return self.start_plugin_update_all();
7330                }
7331                Command::PluginCheckUpdates => {
7332                    return self.start_plugin_check();
7333                }
7334                Command::MarketplaceAdd { source, dispatch_id } => {
7335                    return self.start_marketplace_add(source, dispatch_id);
7336                }
7337                Command::MarketplaceRepair { name } => {
7338                    return self.start_marketplace_repair(name);
7339                }
7340                Command::DictateInstall { variant } => {
7341                    return self.start_install(variant);
7342                }
7343                Command::DictateActivate { role, file } => {
7344                    return self.start_activate(role, file);
7345                }
7346                Command::DictateDeactivate { role } => {
7347                    return self.start_deactivate(role);
7348                }
7349                Command::DictateBench { target, tier } => {
7350                    return self.start_bench(target, tier);
7351                }
7352                Command::DictateBenchStop => {
7353                    return self.stop_bench();
7354                }
7355                Command::VoiceBenchStart => {
7356                    let outcome = self.start_speaking_bench();
7357                    self.push_models();
7358                    return outcome;
7359                }
7360                Command::VoiceBenchStop => {
7361                    let outcome = self.stop_speaking_bench();
7362                    self.push_models();
7363                    return outcome;
7364                }
7365                Command::DictateReadAloudStart { initiator } => {
7366                    let outcome = self.start_read_aloud(initiator);
7367                    self.push_models();
7368                    return outcome;
7369                }
7370                Command::DictateReadAloudStop { keep, initiator } => {
7371                    let outcome = self.finish_read_aloud(keep, initiator);
7372                    self.push_models();
7373                    return outcome;
7374                }
7375                Command::DictateReadAloudDelete { id } => {
7376                    let outcome = self.delete_read_aloud(&id);
7377                    self.push_models();
7378                    return outcome;
7379                }
7380                Command::DictateUninstall { file } => {
7381                    let outcome = self.uninstall_model(&file);
7382                    self.push_models();
7383                    return outcome;
7384                }
7385                Command::DictateBenchDelete { target, tier, corpus } => {
7386                    let outcome = self.delete_bench_result(&target, tier, &corpus);
7387                    self.push_models();
7388                    return outcome;
7389                }
7390                Command::DictateStream { key, options, surface, initiator } => {
7391                    let updates = self.update_sender();
7392                    // Registered HERE, inline, rather than on a spawned
7393                    // task: the connection this arrived on is ordered, so
7394                    // the audio frame a client sends next must find the
7395                    // take. Nothing on this path opens a device, so there
7396                    // is nothing to take off the runtime thread. The test
7397                    // `a_stream_take_registers_synchronously_and_keeps_its_frames`
7398                    // fails the moment this moves onto one.
7399                    match crate::dictate::register_stream_take(
7400                        self,
7401                        &key,
7402                        options,
7403                        surface.clone(),
7404                        initiator,
7405                    ) {
7406                        Ok(take) => {
7407                            let _ = updates.send(SessionUpdate::DictateStarted {
7408                                key: key.clone(),
7409                                floor_db: take.floor_db,
7410                                generation: take.generation,
7411                                initiator,
7412                            });
7413                            tokio::spawn(crate::dictate::run_stream_take(
7414                                Arc::clone(self),
7415                                key,
7416                                take,
7417                                updates,
7418                            ));
7419                        }
7420                        Err(message) => {
7421                            // The start refused, so any park the press's
7422                            // release left behind answers a take that will
7423                            // never exist.
7424                            self.dictate_runtime.lock().clear_stop_pending(&key, initiator);
7425                            let _ = updates.send(SessionUpdate::DictateEnded {
7426                                key,
7427                                outcome: crate::protocol::DictateOutcome::Refused { message },
7428                                generation: 0,
7429                                surface,
7430                                initiator,
7431                            });
7432                        }
7433                    }
7434                }
7435                Command::DictateStop { key, submit, initiator } => {
7436                    let ws = Arc::clone(self);
7437                    tokio::spawn(async move {
7438                        crate::dictate::handle_dictate_stop(&ws, &key, submit, initiator).await;
7439                    });
7440                }
7441                // User-action store writes routed through the command
7442                // bus. Synchronous inline handlers - the writes are
7443                // local redb operations, and the view has already
7444                // applied its optimistic state.
7445                Command::SetReviewThreadStatus { project, branch, thread_id, status } => {
7446                    let span = tracing::info_span!(
7447                        "set_review_thread_status",
7448                        project = %project,
7449                        branch = %branch,
7450                        thread_id = %thread_id,
7451                        status = ?status,
7452                    );
7453                    let _enter = span.enter();
7454                    self.set_review_thread_status(&project, &branch, &thread_id, status);
7455                }
7456                Command::CloseSession { session_key } => {
7457                    let span = tracing::info_span!(
7458                        "close_session",
7459                        slot = %session_key.display(),
7460                    );
7461                    let _enter = span.enter();
7462                    self.release_session_with_cascade(&session_key);
7463                }
7464                Command::UpsertReviewThread {
7465                    project,
7466                    branch,
7467                    thread,
7468                    origin: _,
7469                    sent_words: _,
7470                    respond,
7471                } => {
7472                    let span = tracing::info_span!(
7473                        "upsert_review_thread",
7474                        project = %project,
7475                        branch = %branch,
7476                        thread_id = %thread.id,
7477                    );
7478                    let _enter = span.enter();
7479                    if let Some(respond) = respond {
7480                        let _ = respond.send(self.upsert_review_thread(&project, &branch, thread));
7481                    }
7482                }
7483                Command::DeleteReviewThread { project, branch, thread_id, respond } => {
7484                    let span = tracing::info_span!(
7485                        "delete_review_thread",
7486                        project = %project,
7487                        branch = %branch,
7488                        thread_id = %thread_id,
7489                    );
7490                    let _enter = span.enter();
7491                    if let Some(respond) = respond {
7492                        let _ =
7493                            respond.send(self.delete_review_thread(&project, &branch, &thread_id));
7494                    }
7495                }
7496                Command::SubmitReview { project, branch, summary, thread_ids, origin, respond } => {
7497                    let span = tracing::info_span!(
7498                        "submit_review",
7499                        project = %project,
7500                        branch = %branch,
7501                        threads = thread_ids.len(),
7502                    );
7503                    let _enter = span.enter();
7504                    if let Some(respond) = respond {
7505                        let _ = respond.send(self.submit_review(
7506                            &project,
7507                            &branch,
7508                            summary,
7509                            &thread_ids,
7510                            origin,
7511                        ));
7512                    }
7513                }
7514                // The board's edits are the user's own moves: stamped
7515                // `By::User` in the history, and no session routes them -
7516                // the board is the user's surface, and each carries its
7517                // project.
7518                Command::TaskVerdict { project, id, approve, words } => {
7519                    let id = TaskId::from(id.as_str());
7520                    let moved = if approve {
7521                        self.approve_task(&project, &id)
7522                    } else {
7523                        self.send_back_task(&project, &id, words.as_deref().unwrap_or(""))
7524                    };
7525                    match moved {
7526                        Ok(Some(task)) => {
7527                            // A send-back is news its owner wants, with the
7528                            // user's words in it; an approval ends the row
7529                            // and has nothing to resume. The notice goes to a
7530                            // live owner and nowhere else - the
7531                            // owner-and-liveness check, without the chase's
7532                            // fallback to the lead.
7533                            if !approve {
7534                                let back = words.as_deref().unwrap_or("");
7535                                self.tell_row_owner(
7536                                    &task,
7537                                    if back.is_empty() {
7538                                        format!(
7539                                            "task board: \"{}\" came back from the user's look \
7540                                             with no words - resume it.",
7541                                            task.subject,
7542                                        )
7543                                    } else {
7544                                        format!(
7545                                            "task board: \"{}\" came back from the user's look: \
7546                                             \"{back}\" - resume it.",
7547                                            task.subject,
7548                                        )
7549                                    },
7550                                );
7551                            }
7552                        }
7553                        Ok(None) => {
7554                            tracing::debug!(
7555                                target: "forge_workspace",
7556                                project = %project,
7557                                id = %id.as_str(),
7558                                "a user verdict named a row that is not there",
7559                            );
7560                            self.board_edit_refused(
7561                                "a verdict",
7562                                "the row it named is no longer there",
7563                            );
7564                        }
7565                        Err(refused) => {
7566                            tracing::debug!(
7567                                target: "forge_workspace",
7568                                project = %project,
7569                                id = %id.as_str(),
7570                                refusal = %refused,
7571                                "a user verdict was refused",
7572                            );
7573                            self.board_edit_refused("a verdict", &refused.to_string());
7574                        }
7575                    }
7576                }
7577                Command::TaskAnswer { project, id, words } => {
7578                    let id = TaskId::from(id.as_str());
7579                    match self.answer_task(&project, &id, &words) {
7580                        Ok(Some(task)) => {
7581                            // The words ride the notice: the owner should read
7582                            // what the user said where it is told to resume,
7583                            // not go looking for it.
7584                            self.tell_row_owner(
7585                                &task,
7586                                if words.is_empty() {
7587                                    format!(
7588                                        "task board: the user answered \"{}\" with no words - \
7589                                         resume it.",
7590                                        task.subject,
7591                                    )
7592                                } else {
7593                                    format!(
7594                                        "task board: the user answered \"{}\": \"{}\" - resume it.",
7595                                        task.subject, words,
7596                                    )
7597                                },
7598                            );
7599                        }
7600                        Ok(None) => {
7601                            tracing::debug!(
7602                                target: "forge_workspace",
7603                                project = %project,
7604                                id = %id.as_str(),
7605                                "a user answer named a row that is not there",
7606                            );
7607                            self.board_edit_refused(
7608                                "an answer",
7609                                "the row it named is no longer there",
7610                            );
7611                        }
7612                        Err(refused) => {
7613                            tracing::debug!(
7614                                target: "forge_workspace",
7615                                project = %project,
7616                                id = %id.as_str(),
7617                                refusal = %refused,
7618                                "a user answer was refused",
7619                            );
7620                            self.board_edit_refused("an answer", &refused.to_string());
7621                        }
7622                    }
7623                }
7624                Command::TaskRank { project, id, to } => {
7625                    match self.rank_task(&project, &TaskId::from(id.as_str()), to) {
7626                        // A drag that lands the row back where it started is
7627                        // not an edit: the queue is unchanged, and a notice
7628                        // would be a turn spent on nothing.
7629                        Ok(Some((task, true))) => {
7630                            self.tell_row_owner(
7631                                &task,
7632                                format!(
7633                                    "task board: the user re-ordered \"{}\" in its queue.",
7634                                    task.subject,
7635                                ),
7636                            );
7637                        }
7638                        Ok(Some((_, false))) => {}
7639                        Ok(None) => {
7640                            tracing::debug!(
7641                                target: "forge_workspace::tasks",
7642                                project = %project,
7643                                id = %id.as_str(),
7644                                "a board re-order named a row that is not there",
7645                            );
7646                            self.board_edit_refused(
7647                                "a re-order",
7648                                "the row it named is no longer there",
7649                            );
7650                        }
7651                        Err(refused) => {
7652                            tracing::debug!(
7653                                target: "forge_workspace::tasks",
7654                                project = %project,
7655                                id = %id.as_str(),
7656                                refusal = %refused,
7657                                "a board re-order was refused",
7658                            );
7659                            self.board_edit_refused("a re-order", &refused.to_string());
7660                        }
7661                    }
7662                }
7663                Command::TaskMove { project, id, to, place } => {
7664                    match self.user_move_task(&project, &TaskId::from(id.as_str()), to) {
7665                        // The landed status, not the asked one: a drag into
7666                        // `completed` on a verify=user row lands in waiting,
7667                        // and the notice must name the lane the board shows.
7668                        Ok(Some(task)) => {
7669                            // A drag carries where it landed: the row keeps
7670                            // the place the reader chose. A place that does
7671                            // not apply is LOGGED, not said on the board's
7672                            // line - the move itself landed, and a refusal
7673                            // would report the whole edit as failed.
7674                            if let Some(place) = place {
7675                                match self.rank_task(&project, &TaskId::from(id.as_str()), place) {
7676                                    Ok(Some(_)) => {}
7677                                    Ok(None) => {
7678                                        tracing::debug!(
7679                                            target: "forge_workspace::tasks",
7680                                            project = %project,
7681                                            id = %id.as_str(),
7682                                            "a drop's place named a row that is not there; the move landed without it",
7683                                        );
7684                                    }
7685                                    Err(refused) => {
7686                                        tracing::debug!(
7687                                            target: "forge_workspace::tasks",
7688                                            project = %project,
7689                                            id = %id.as_str(),
7690                                            refusal = %refused,
7691                                            "a drop's place was refused; the move landed without it",
7692                                        );
7693                                    }
7694                                }
7695                            }
7696                            self.tell_row_owner(
7697                                &task,
7698                                format!(
7699                                    "task board: the user moved \"{}\" to {}.",
7700                                    task.subject,
7701                                    Self::lane_words(task.status),
7702                                ),
7703                            );
7704                        }
7705                        Ok(None) => {
7706                            tracing::debug!(
7707                                target: "forge_workspace::tasks",
7708                                project = %project,
7709                                id = %id.as_str(),
7710                                "a board move named a row that is not there",
7711                            );
7712                            self.board_edit_refused(
7713                                "a move",
7714                                "the row it named is no longer there",
7715                            );
7716                        }
7717                        Err(refused) => {
7718                            tracing::debug!(
7719                                target: "forge_workspace::tasks",
7720                                project = %project,
7721                                id = %id.as_str(),
7722                                refusal = %refused,
7723                                "a board move was refused",
7724                            );
7725                            self.board_edit_refused("a move", &refused.to_string());
7726                        }
7727                    }
7728                }
7729                Command::TaskReopen { project, id } => {
7730                    let id = TaskId::from(id.as_str());
7731                    match self.reopen_task(&project, &id) {
7732                        Ok(Some(task)) => {
7733                            self.tell_row_owner(
7734                                &task,
7735                                format!(
7736                                    "task board: the user reopened \"{}\" - the look is owed \
7737                                     again; when it completes it parks on the user for the \
7738                                     new look.",
7739                                    task.subject,
7740                                ),
7741                            );
7742                        }
7743                        Ok(None) => self
7744                            .board_edit_refused("a reopen", "the row it named is no longer there"),
7745                        Err(refused) => {
7746                            tracing::debug!(
7747                                target: "forge_workspace::tasks",
7748                                project = %project,
7749                                id = %id.as_str(),
7750                                refusal = %refused,
7751                                "a reopen was refused",
7752                            );
7753                            self.board_edit_refused("a reopen", &refused.to_string());
7754                        }
7755                    }
7756                }
7757                Command::TaskReserve { project, id, reserved } => {
7758                    let id = TaskId::from(id.as_str());
7759                    match self.reserve_task(&project, &id, reserved) {
7760                        Ok(Some(_)) => {}
7761                        Ok(None) => self.board_edit_refused(
7762                            "a reservation",
7763                            "the row it named is no longer there",
7764                        ),
7765                        Err(refused) => {
7766                            self.board_edit_refused("a reservation", &refused.to_string());
7767                        }
7768                    }
7769                }
7770                Command::TaskAssign { project, id, owner } => {
7771                    // A project this forge does not carry has no seat to name
7772                    // and no row to hold: an assignment to it is refused
7773                    // rather than written against an invented empty-org slot.
7774                    let config = self.config_arc();
7775                    if let Some(row) = config.projects.iter().find(|p| p.name == project) {
7776                        let owner_slot =
7777                            owner.map(|label| SessionSlot::new(&row.org, &project, label.clone()));
7778                        match self.assign_task(&project, &TaskId::from(id.as_str()), owner_slot) {
7779                            // Both ends of a hand-over hear it, and neither
7780                            // does when the owner did not actually change.
7781                            Ok(Some((task, prior))) if prior != task.owner => {
7782                                if let Some(old) = prior
7783                                    && self.seat_is_live(&old)
7784                                {
7785                                    let _ = self.dispatch_forged_prompt(
7786                                        &old,
7787                                        format!(
7788                                            "task board: the user took \"{}\" from you.",
7789                                            task.subject,
7790                                        ),
7791                                    );
7792                                }
7793                                self.tell_row_owner(
7794                                    &task,
7795                                    format!(
7796                                        "task board: the user gave \"{}\" to you.",
7797                                        task.subject,
7798                                    ),
7799                                );
7800                            }
7801                            Ok(Some(_)) => {}
7802                            Ok(None) => {
7803                                tracing::debug!(
7804                                    target: "forge_workspace::tasks",
7805                                    project = %project,
7806                                    id = %id.as_str(),
7807                                    "a board assignment named a row that is not there",
7808                                );
7809                                self.board_edit_refused(
7810                                    "an assignment",
7811                                    "the row it named is no longer there",
7812                                );
7813                            }
7814                            Err(refused) => {
7815                                tracing::debug!(
7816                                    target: "forge_workspace::tasks",
7817                                    project = %project,
7818                                    id = %id.as_str(),
7819                                    refusal = %refused,
7820                                    "a board assignment was refused",
7821                                );
7822                                self.board_edit_refused("an assignment", &refused.to_string());
7823                            }
7824                        }
7825                    } else {
7826                        self.board_edit_refused("an assignment", "no project carries that name");
7827                    }
7828                }
7829                Command::TaskCreate { project, subject, parent } => {
7830                    let now = std::time::SystemTime::now();
7831                    let task = forge_primitives::tasks::Task {
7832                        id: TaskId::from(uuid::Uuid::new_v4().to_string()),
7833                        project_name: project.clone(),
7834                        subject,
7835                        active_form: None,
7836                        detail: None,
7837                        status: forge_primitives::tasks::TaskStatus::Pending,
7838                        owner: None,
7839                        parent: parent.map(|id| TaskId::from(id.as_str())),
7840                        waiting_on: None,
7841                        estimate: None,
7842                        rank: None,
7843                        verify: None,
7844                        attested: None,
7845                        reserved: None,
7846                        links: Vec::new(),
7847                        attempt: 0,
7848                        archived_at: None,
7849                        created_at: now,
7850                        updated_at: now,
7851                    };
7852                    self.push_task(task);
7853                }
7854                Command::BiometricEnrol { enrolment, device, purpose, blob, respond } => {
7855                    // The write's own answer rides the reply: `false` is what
7856                    // a rotation rewrite turns into a lock (decision 15), and
7857                    // a refusal is a dispatch error so the two cannot be
7858                    // mistaken for each other.
7859                    self.enrol_biometric(enrolment, &device, &purpose, &blob)
7860                        .map_err(|reason| DispatchError::BiometricRefused { reason })?;
7861                    if let Some(respond) = respond {
7862                        let _ = respond.send(true);
7863                    }
7864                }
7865                Command::BiometricRewrite { enrolment, purpose, blob, respond } => {
7866                    // **Update-only**: an unknown id or purpose answers
7867                    // `false`, which the rotation turns into a lock rather
7868                    // than letting a revoked device re-register itself.
7869                    let replaced = self
7870                        .rewrite_biometric(enrolment, &purpose, &blob)
7871                        .map_err(|reason| DispatchError::BiometricRefused { reason })?;
7872                    if let Some(respond) = respond {
7873                        let _ = respond.send(replaced);
7874                    }
7875                }
7876                Command::BiometricRelease { enrolment, purpose, respond } => {
7877                    // The wait the transport answered: a store failure is a
7878                    // dispatch refusal, so it surfaces as a refusal rather
7879                    // than as `null` - the answer that means "not enrolled"
7880                    // and sends the client to its password.
7881                    let answer = self
7882                        .release_biometric(enrolment, &purpose)
7883                        .map_err(|reason| DispatchError::BiometricRefused { reason })?;
7884                    if let Some(respond) = respond {
7885                        let _ = respond.send(answer);
7886                    }
7887                }
7888                Command::BiometricRevoke { enrolment, purpose } => {
7889                    self.revoke_biometric(enrolment, purpose.as_deref())
7890                        .map_err(|reason| DispatchError::BiometricRefused { reason })?;
7891                }
7892                // The shared vault's lock is workspace state, never agent
7893                // traffic; the press is answered by the re-stamped cards and
7894                // the settings card's next read.
7895                Command::VaultLock => {
7896                    self.lock_vault();
7897                }
7898                Command::VaultEnrolmentMaterial { respond } => {
7899                    // The material rides the reply alone: a locked session
7900                    // answers `null`, which is the state a device cannot
7901                    // enrol from.
7902                    let material = self.vault_enrolment_material();
7903                    if let Some(respond) = respond {
7904                        let _ = respond.send(material);
7905                    }
7906                }
7907                other => {
7908                    tracing::warn!(
7909                        target: "forge_workspace",
7910                        command = ?other,
7911                        "unexpected App-level command (no key but not a spawn variant); ignored",
7912                    );
7913                }
7914            }
7915            Ok(())
7916        }
7917    }
7918
7919    /// Say on the service line why a board edit did not land: a refusal a
7920    /// reader cannot see is indistinguishable from a press that did
7921    /// nothing.
7922    fn board_edit_refused(&self, what: &str, why: &str) {
7923        let _ = self.update_tx.send(SessionUpdate::ServiceStatus {
7924            severity: forge_primitives::cloud::service_status::ServiceSeverity::Warning,
7925            message: format!("The board refused {what}: {why}"),
7926        });
7927    }
7928
7929    /// Re-spawn this project's persisted workers on lead reconnect,
7930    /// resuming by label off the id the store holds for that label.
7931    /// Charter and kick come from the DB row.
7932    ///
7933    /// Since this holds the worker set, it decides the kick directly:
7934    /// a resume of a worker whose row says `in_turn` takes the row's own
7935    /// `resume_kick` and falls back to the forge restart note, telling it
7936    /// to continue rather than restart; a resume of a parked worker gets
7937    /// nothing, and a fresh re-spawn (never prompted, so no store row)
7938    /// re-delivers the stored kick. `maybe_kick_worker_on_connected` then
7939    /// just delivers whatever this put on the `WorkerEntry`.
7940    pub(crate) fn dispatch_worker_respawns(
7941        self: &Arc<Self>,
7942        lead: &SessionSlot,
7943        project_key: &crate::target::ProjectKey,
7944        dynamic: &[crate::store::sessions::SessionRecord],
7945        force_new: bool,
7946    ) {
7947        // `--new`: the lead came up fresh, so its workers do too - every
7948        // row spawns fresh rather than resuming its stored id.
7949        let project = if force_new { None } else { self.project_for_key(project_key) };
7950        for worker in dynamic {
7951            let resume_existing = match project
7952                .as_ref()
7953                .map(|p| self.stored_session_id(&p.org, &p.name, &worker.label))
7954            {
7955                None | Some(Ok(None)) => None,
7956                Some(Ok(Some(id))) => Some(id),
7957                Some(Err(error)) => {
7958                    // A row that cannot be read is not a row without an
7959                    // id: spawning fresh here would mint over it and fork
7960                    // the worker's session.
7961                    tracing::error!(
7962                        target: "forge_workspace::workers",
7963                        project = %project_key.as_str(),
7964                        label = %worker.label,
7965                        %error,
7966                        "reading the worker's session row failed; skipping this worker rather than spawning fresh over it",
7967                    );
7968                    continue;
7969                }
7970            };
7971            // A resume starts the subprocess in the worker's worktree,
7972            // and a subprocess cannot enter a directory that is not
7973            // there: the spawn fails on every boot and the row never
7974            // clears. A FRESH re-spawn runs in its tree and takes
7975            // `--worktree`, so it is the resume this skip is for: a
7976            // fresh one that cannot get a worktree is refused by the
7977            // spawn itself, which leaves its row failed with the reason.
7978            //
7979            // The tree composed here is the ROW's own: a worker the
7980            // spawn pointed at another directory reads back to that
7981            // directory, so the wave and the spawn ask about the same
7982            // one.
7983            let root = worker.cwd.as_deref().map_or_else(
7984                || project.as_ref().map(|p| p.path.clone()),
7985                |cwd| Some(std::path::PathBuf::from(cwd)),
7986            );
7987            if let Some(root) = root.as_deref()
7988                && !crate::mcp::workers::types::worker_row_can_start(
7989                    root,
7990                    &worker.label,
7991                    worker.is_git_repo,
7992                    resume_existing.is_some(),
7993                )
7994            {
7995                let wanted = crate::mcp::workers::types::worker_tag_dir(
7996                    root,
7997                    &worker.label,
7998                    matches!(worker.is_git_repo, Some(true)),
7999                );
8000                tracing::warn!(
8001                    target: "forge_workspace::workers",
8002                    event_name = "boot_respawn_skipped_missing_worktree",
8003                    project = %project_key.as_str(),
8004                    label = %worker.label,
8005                    directory = %wanted.display(),
8006                    "the worker's directory is gone, so this boot re-spawn has nowhere to \
8007                     start; the row is kept and stays unoffered until it is back",
8008                );
8009                continue;
8010            }
8011            // **Only a worker that was mid-turn resumes with a kick.** A
8012            // worker that was idle when forge stopped is resumed parked;
8013            // its kick stays on the row for a future fresh spawn, and a
8014            // crash does not put an idle seat back to work. The state is
8015            // read from the row before this spawn overwrites it.
8016            let was_mid_turn =
8017                matches!(worker.seat_state, Some(crate::store::sessions::SeatState::InTurn));
8018            let kick = if resume_existing.is_some() {
8019                if was_mid_turn {
8020                    worker
8021                        .resume_kick
8022                        .clone()
8023                        .or_else(|| Some(DYNAMIC_WORKER_RESTART_NOTE.to_owned()))
8024                } else {
8025                    None
8026                }
8027            } else {
8028                worker.kick.clone()
8029            };
8030            let (tx, _rx) = tokio::sync::oneshot::channel();
8031            let cmd = crate::protocol::Command::SpawnWorker {
8032                project_key: project_key.clone(),
8033                label: worker.label.clone(),
8034                charter: worker.charter.clone().unwrap_or_default(),
8035                spawned_by: lead.clone(),
8036                resume_existing,
8037                kick,
8038                // The row this re-spawn is replaying already holds it.
8039                resume_kick: None,
8040                // And its family selection rides the same row; the spawn
8041                // reads it back rather than restating it here.
8042                mcp_families: worker.mcp_families.clone(),
8043                interactive: worker.interactive.unwrap_or(false),
8044                // The row's own tree, which is what this re-spawn
8045                // restores: the handler records it back verbatim.
8046                cwd: worker.cwd.clone(),
8047                from_boot_respawn: true,
8048                return_to: Some(tx),
8049            };
8050            if let Err(err) = self.dispatch(cmd) {
8051                tracing::error!(
8052                    target: "forge_workspace::workers",
8053                    project = %project_key.as_str(),
8054                    label = %worker.label,
8055                    error = ?err,
8056                    "dispatch_worker_respawns: dispatch failed for label"
8057                );
8058            }
8059        }
8060    }
8061
8062    /// Lead Connected-hook entry point. Synchronously claims a
8063    /// per-project in-flight guard, then dispatches one
8064    /// `Command::SpawnWorker` per persisted row, resuming by the id the
8065    /// store holds for that row's label. The guard is released after
8066    /// the dispatches go out so a fast double-Connected can't slip a
8067    /// second wave through.
8068    ///
8069    /// No-op when the per-project guard is already claimed (another
8070    /// wave is in flight). The first-pass `live_workers.is_empty()`
8071    /// gate in `session_task::maybe_respawn_workers_on_connected` catches
8072    /// the post-dispatch case; this guard covers the in-flight window.
8073    pub(crate) fn respawn_workers_for_lead(
8074        self: &Arc<Self>,
8075        lead: &SessionSlot,
8076        project_key: crate::target::ProjectKey,
8077        force_new: bool,
8078    ) {
8079        let dynamic = self.worker_rows_for_project(&project_key);
8080        if dynamic.is_empty() {
8081            return;
8082        }
8083        if !self.try_claim_respawn(&project_key) {
8084            tracing::debug!(
8085                target: "forge_workspace::workers",
8086                project = %project_key.as_str(),
8087                "worker-spawn already in flight; skipping duplicate Connected fire",
8088            );
8089            return;
8090        }
8091        // Dispatching a worker probes its project path for git-repo-ness
8092        // inline, so this runs off the event loop rather than inside
8093        // `translate_event`. Outside a runtime (the sync `#[test]`
8094        // fixtures in `connected_hook_tests`) there is none to hand it
8095        // to, so dispatch inline.
8096        let Ok(handle) = tokio::runtime::Handle::try_current() else {
8097            tracing::debug!(
8098                target: "forge_workspace::workers",
8099                project = %project_key.as_str(),
8100                "no tokio runtime in scope; dispatching worker-spawns inline (test path)",
8101            );
8102            self.dispatch_worker_respawns(lead, &project_key, &dynamic, force_new);
8103            self.release_respawn(&project_key);
8104            return;
8105        };
8106        let workspace = Arc::clone(self);
8107        let lead = lead.clone();
8108        handle.spawn(async move {
8109            tracing::info!(
8110                target: "forge_workspace::workers",
8111                project = %project_key.as_str(),
8112                lead_slot = %lead.display(),
8113                worker_count = dynamic.len(),
8114                "dispatching SpawnWorker per persisted row",
8115            );
8116            workspace.dispatch_worker_respawns(&lead, &project_key, &dynamic, force_new);
8117            workspace.release_respawn(&project_key);
8118        });
8119    }
8120
8121    /// Claim the per-project respawn in-flight guard. Returns true
8122    /// if the guard was acquired (entry was absent), false if another
8123    /// wave was already in flight.
8124    fn try_claim_respawn(&self, project_key: &crate::target::ProjectKey) -> bool {
8125        self.respawn_in_flight.lock().insert(project_key.clone())
8126    }
8127
8128    /// Whether this fire pass is the FIRST to find `id` unwakeable,
8129    /// recording it as seen if so. True is the pass that warns; false is
8130    /// every later pass while the condition holds.
8131    pub(crate) fn cron_unwakeable_is_new(&self, id: &forge_primitives::CronId) -> bool {
8132        self.unwakeable_crons.lock().insert(id.clone())
8133    }
8134
8135    /// Close a fire pass: `ids` is what it found unwakeable, and it
8136    /// replaces the previous pass's set wholesale. That replacement is
8137    /// the only clearing this state needs - an entry cannot survive a
8138    /// pass that did not find its cron unwakeable, whether the cron fired,
8139    /// advanced, was deleted, or simply was not due.
8140    pub(crate) fn cron_unwakeable_commit(
8141        &self,
8142        ids: std::collections::HashSet<forge_primitives::CronId>,
8143    ) {
8144        *self.unwakeable_crons.lock() = ids;
8145    }
8146
8147    /// When the previous fire pass last warned about this cron's cooling,
8148    /// or `None` when that pass did not find it cooling.
8149    pub(crate) fn cron_cooling_warned_at(
8150        &self,
8151        id: &forge_primitives::CronId,
8152    ) -> Option<SystemTime> {
8153        self.cooling_crons.lock().get(id).copied()
8154    }
8155
8156    /// Close a fire pass with the crons it still found cooling and the
8157    /// time each last warned, replacing the previous pass's map wholesale
8158    /// for the same reason [`Self::cron_unwakeable_commit`] does.
8159    pub(crate) fn cron_cooling_commit(
8160        &self,
8161        warned_at: std::collections::HashMap<forge_primitives::CronId, SystemTime>,
8162    ) {
8163        *self.cooling_crons.lock() = warned_at;
8164    }
8165
8166    /// The session a worker label resumes onto.
8167    ///
8168    /// The row the label is registered under is authoritative while it is
8169    /// there: a worker that has one opens as it always has. The
8170    /// transcripts are the recovery path for a row that is gone - a
8171    /// despawn deleted it, or this install never had it - and there the
8172    /// newest transcript in `run_dir`'s directory carrying the label's
8173    /// worker tag wins.
8174    ///
8175    /// `Ok(ResumeTarget::None)` is a label with nothing in either, and
8176    /// `Err` is a store, directory or transcript that is there but could
8177    /// not be read. Only the first is a fallback: a caller that started a
8178    /// fresh session on the second would be answering a question the
8179    /// lookup never answered.
8180    pub(crate) fn resolve_worker_resume_session(
8181        &self,
8182        org: &str,
8183        project: &str,
8184        run_dir: &std::path::Path,
8185        label: &str,
8186    ) -> Result<ResumeTarget, anyhow::Error> {
8187        if let Some(session_id) = self.stored_session_id(org, project, label)? {
8188            return Ok(ResumeTarget::Found { session_id, restamp: false });
8189        }
8190        // The tag scan is what the store's cache is for: without it every
8191        // candidate transcript is read end to end, and the directory a
8192        // worker without a worktree runs in holds every session that ever
8193        // ran in its project. A cache that cannot be read or written
8194        // costs a re-scan, never an answer.
8195        let cache = {
8196            let db = self.db.lock();
8197            load_session_tag_cache(db.as_ref())
8198        };
8199        let owner = format!("{org}/{project}");
8200        let found = forge_agent::userdata::transcripts::newest_worker_session(
8201            &self.config_dir,
8202            run_dir,
8203            label,
8204            &owner,
8205            Some(&cache),
8206        );
8207        {
8208            let db = self.db.lock();
8209            persist_session_tag_cache(db.as_ref(), &cache);
8210        }
8211        Ok(match found? {
8212            Some((session_id, restamp)) => {
8213                if restamp {
8214                    // A tag row that predates the project field is adopted
8215                    // only from a directory no OTHER declared project
8216                    // reads; the check counts the sanitised key the lookup
8217                    // opens, so a key collision counts even when the paths
8218                    // differ.
8219                    let composing = self.projects_composing(run_dir, label);
8220                    if let Some((_, other)) =
8221                        composing.iter().find(|(owner, name)| owner != org || name != project)
8222                    {
8223                        anyhow::bail!(
8224                            "the transcript for '{label}' at '{}' predates project tags and is \
8225                             in a directory project '{other}' composes, so it cannot be known to \
8226                             be this project's",
8227                            run_dir.display(),
8228                        );
8229                    }
8230                }
8231                ResumeTarget::Found { session_id, restamp }
8232            }
8233            None => ResumeTarget::None,
8234        })
8235    }
8236
8237    /// Stamp an adopted pre-field transcript with the adopting project,
8238    /// so a second project can never adopt it.
8239    pub(crate) fn stamp_adopted_worker_transcript(
8240        &self,
8241        project_key: &crate::ProjectKey,
8242        label: &str,
8243        session_id: &str,
8244        run_dir: &std::path::Path,
8245    ) -> anyhow::Result<()> {
8246        let Some((org, name)) = self.project_identity_for_key(project_key) else {
8247            anyhow::bail!("no configured project for {}", project_key.as_str());
8248        };
8249        let tag = forge_primitives::worker_tag(label);
8250        forge_agent::userdata::catalog::mutations::tag_session(
8251            &self.config_dir,
8252            session_id,
8253            Some(&tag),
8254            &format!("{org}/{name}"),
8255            Some(&run_dir.to_string_lossy()),
8256        )
8257        .map_err(anyhow::Error::from)
8258    }
8259
8260    /// The declared projects whose transcript lookup for `label` opens
8261    /// `run_dir`: the directory their worker runs in, or the sanitised
8262    /// key that directory is stored under.
8263    fn projects_composing(&self, run_dir: &std::path::Path, label: &str) -> Vec<(String, String)> {
8264        // **One spelling per path, resolved the same way on both sides.** A
8265        // worker directory that is gone cannot be canonicalised at all, so
8266        // `resolve_existing_prefix` resolves the part that exists and keeps
8267        // the rest; the key is then built from that same spelling rather than
8268        // resolved a second time, or a directory removed between the two
8269        // resolutions would compare under a different key than its path.
8270        let composed = resolve_existing_prefix(run_dir);
8271        let key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
8272            &composed.to_string_lossy(),
8273        ));
8274        let key_of = |dir: &std::path::Path| {
8275            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
8276                &dir.to_string_lossy(),
8277            ))
8278        };
8279        self.list_projects()
8280            .into_iter()
8281            .filter(|view| {
8282                let is_git = self
8283                    .recorded_worker_is_git_repo(&view.key, label)
8284                    .ok()
8285                    .flatten()
8286                    .unwrap_or_else(|| forge_agent::env::worktree::is_git_repo(&view.path));
8287                let root = resolve_existing_prefix(&view.path);
8288                let dir = crate::mcp::workers::types::worker_tag_dir(&root, label, is_git);
8289                let dir = resolve_existing_prefix(&dir);
8290                dir == composed || key_of(&dir) == key
8291            })
8292            .map(|view| (view.org, view.name))
8293            .collect()
8294    }
8295
8296    /// Release the per-project respawn in-flight guard. Paired with
8297    /// `try_claim_respawn`; called once the dispatches have gone out, on
8298    /// each path that can issue them.
8299    fn release_respawn(&self, project_key: &crate::target::ProjectKey) {
8300        self.respawn_in_flight.lock().remove(project_key);
8301    }
8302
8303    /// Set the `session_id` field on the workspace's `DomainSession`
8304    /// for `key`. No-op when no domain handle is registered for
8305    /// `key`. Stamps the claude-issued UUID once the first
8306    /// `Connected` event fires - the workspace consults this when
8307    /// routing `AgentHandle` calls that take a session_id.
8308    pub fn set_session_id_in_domain(
8309        &self,
8310        key: &SessionSlot,
8311        value: Option<forge_primitives::SessionId>,
8312    ) {
8313        let Some(domain) = self.domain_handles.lock().get(key).cloned() else {
8314            return;
8315        };
8316        domain.lock().session_id = value;
8317    }
8318
8319    /// Graceful shutdown of every pooled Agent. Drains the pool, then
8320    /// drops each `Arc<AgentHandle>`.
8321    ///
8322    /// The subprocess kill is asynchronous through each `SessionTask`:
8323    /// dropping the command senders closes every task's command channel,
8324    /// each task's run loop exits, and its exit path awaits
8325    /// `AgentHandle::disconnect`, which takes the bridge's client slot
8326    /// and runs the SDK's graceful shutdown (signal reader task, drain,
8327    /// close the child). `Client` has no `Drop` of its own, so without
8328    /// that disconnect the child would survive the pool drain.
8329    /// A well-behaved caller releases its handle reference before calling
8330    /// shutdown, so Workspace is the sole owner of every pool entry.
8331    /// Callers that hold cloned handles across shutdown keep the
8332    /// AgentHandle's task alive until they release them.
8333    ///
8334    /// **The return waits for those exit paths**, because the runtime a
8335    /// caller is about to drop does not poll them: each child sits in its
8336    /// own process group and only its own close signals that group, so a
8337    /// shutdown that returned early would leave the tree running. No
8338    /// session may start once it begins - the gate goes up before the first
8339    /// drain and the mint reads it under the same lock - and the drain is
8340    /// repeated until nothing is registered. The wait is bounded by the
8341    /// same `TEARDOWN_WAIT` the despawn's removal holds to.
8342    ///
8343    /// Returns the slots that had not answered; empty is a clean stop. The
8344    /// caller turns a non-empty answer into a non-zero exit, so a
8345    /// supervisor can tell a clean stop from one that left a tree.
8346    pub async fn shutdown(&self) -> Vec<SessionSlot> {
8347        self.shutdown_within(crate::spawn::TEARDOWN_WAIT).await
8348    }
8349
8350    /// [`Self::shutdown`] with the budget injected, so a case can drive the
8351    /// expiry without sitting out the production wait. Returns the slots
8352    /// that had not answered by the deadline; empty is a clean stop.
8353    pub(crate) async fn shutdown_within(&self, wait: std::time::Duration) -> Vec<SessionSlot> {
8354        // Up before anything is torn down: from here on a spawn refuses,
8355        // and a mint cannot land behind the drain below.
8356        self.shutting_down.store(true, std::sync::atomic::Ordering::Release);
8357        // A pump left to the task's drop reports a stream end at every
8358        // clean stop; signalling it reaches its own shutdown branch.
8359        self.stop_gotify_subsystem();
8360        // Release any live dictation before the pools go: a recording
8361        // task outliving its session's teardown would otherwise hold
8362        // the microphone for a composer nobody can reach.
8363        crate::dictate::teardown_all(self);
8364        // Drop command senders first so every SessionTask sees its
8365        // command channel close and exits cleanly; each task's exit
8366        // path then disconnects its subprocess (see the doc above).
8367        let _ = self.command_senders.lock().drain().collect::<Vec<_>>();
8368        let _ = self.domain_handles.lock().drain().collect::<Vec<_>>();
8369        drop(self.pool.lock().drain().collect::<Vec<_>>());
8370
8371        let deadline = tokio::time::Instant::now() + wait;
8372        loop {
8373            // The senders are taken with the drain but not dropped: each
8374            // subscription below reads the answer off the channel.
8375            let batch: Vec<(SessionSlot, tokio::sync::watch::Sender<bool>)> =
8376                self.session_teardowns.lock().drain().collect();
8377            if batch.is_empty() {
8378                return Vec::new();
8379            }
8380            let mut receivers: Vec<(SessionSlot, tokio::sync::watch::Receiver<bool>)> =
8381                batch.iter().map(|(slot, tx)| (slot.clone(), tx.subscribe())).collect();
8382            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
8383            if remaining.is_zero() {
8384                let unready = unready_slots(&receivers);
8385                warn_shutdown_expired(&unready, wait);
8386                return unready;
8387            }
8388            // One budget for the batch: the tasks run concurrently, so a
8389            // later one is usually already done by its turn.
8390            let _ = tokio::time::timeout(remaining, async {
8391                for (_, rx) in &mut receivers {
8392                    wait_for_teardown(rx).await;
8393                }
8394            })
8395            .await;
8396            // Read the answers off the values, never off the order they
8397            // were polled in: a session that fired at any point counts.
8398            let unready = unready_slots(&receivers);
8399            if !unready.is_empty() {
8400                warn_shutdown_expired(&unready, wait);
8401                return unready;
8402            }
8403            // Nothing is registered now, but a spawn that passed the gate
8404            // just before it could still have minted: drain again.
8405        }
8406    }
8407
8408    /// Release a single session's pool entry: drops the workspace's
8409    /// `Arc<AgentHandle>` for that key so the underlying `claude`
8410    /// subprocess exits once the consumer (a view's bucket) also
8411    /// drops its reference.
8412    ///
8413    /// Cascade-aware lead release. Use this when closing a project's
8414    /// lead session from a view: the lead-row `×` click, the launchpad's
8415    /// per-row close on a failed lead bucket, etc.
8416    ///
8417    /// When `session_key` is a project's lead - it appears in the
8418    /// project's catalog AND is NOT in `live_workers[project]` - every
8419    /// live worker under that project is released first via the
8420    /// non-cascading `release_session` primitive. Workers' JSONLs
8421    /// persist on disk; only the in-memory live state + the running
8422    /// claude subprocesses are torn down.
8423    ///
8424    /// The "in-catalog AND not-in-live_workers" rule is the
8425    /// discriminator (rather than `sessions.first()`): the catalog
8426    /// also indexes worker sessions once their Connected fires, so
8427    /// `sessions[0]` is not a reliable lead marker after a worker
8428    /// reaches Running. `live_workers` is the authoritative
8429    /// "this session is a child agent" registry.
8430    pub fn release_session_with_cascade(self: &Arc<Self>, session_key: &SessionSlot) {
8431        // **The close door is the only thing that closes a seat.** A bare
8432        // boot skips what it closed; opening the project again resumes its
8433        // stored session and writes `idle` like any spawn. A worker's row
8434        // is never written here: the cascade takes its live entries, and
8435        // its row is the despawn's or its next re-spawn's business.
8436        if session_key.is_lead() {
8437            self.record_seat_state(session_key, crate::store::sessions::SeatState::Closed);
8438        }
8439        // The cascade is the lead's, and a lead's slot is the one whose
8440        // label says so: a worker's slot carries its own label, so a
8441        // closed worker cannot be misidentified as its lead however far
8442        // the registry has moved.
8443        let cascade_project = session_key.is_lead().then(|| {
8444            self.list_projects()
8445                .into_iter()
8446                .find(|view| view.org == session_key.org() && view.name == session_key.project())
8447        });
8448        let cascade_project = cascade_project.flatten();
8449        // **Every record this close dismisses is cleared before the first
8450        // frame that makes a view re-read.** The lead's announcement below is
8451        // one; each live worker's removal is another. A stranded worker - a
8452        // record with no live entry left to carry it - is the project's too,
8453        // so every persisted label is cleared here rather than only what
8454        // `live_workers` still holds.
8455        if let Some(view) = cascade_project.as_ref() {
8456            for row in self.worker_rows_for_project(&view.key) {
8457                let slot = SessionSlot::worker(&row.org, &row.project, &row.label);
8458                self.clear_spawn_failure(&slot);
8459                self.spend_death(&slot);
8460            }
8461        }
8462        // Announced before anything is torn down, so the seat's row starts
8463        // saying where it is going at the gesture rather than after the
8464        // workers this cascade takes with it (#1930). A viewer's mark is a
8465        // set, so a second announcement would be spent.
8466        self.announce_release(session_key);
8467        if let Some(view) = cascade_project.as_ref() {
8468            for entry in self.drain_live_workers(&view.key) {
8469                // An entry whose label has no persisted row is only here.
8470                self.clear_spawn_failure(&entry.slot);
8471                self.spend_death(&entry.slot);
8472                // The cascade is the lead's close taking its workers with
8473                // it: their deaths are the stop's, not crashes to heal.
8474                self.healing().note_stopped(&entry.slot);
8475                let worktree =
8476                    crate::protocol::WorktreeDisposition::untouched(entry.is_git_repo_at_spawn);
8477                let _ = self.update_tx.send(SessionUpdate::WorkerStatusChanged {
8478                    project_key: view.key.clone(),
8479                    action: crate::protocol::WorkerStatusAction::Removed,
8480                    status: entry.to_status(),
8481                    worktree,
8482                });
8483                // The cascade takes a worker's browser tabs with it too;
8484                // `release_session` is the one door that tells the fleet.
8485                self.release_session(&entry.slot);
8486            }
8487        }
8488        self.release_session(session_key);
8489    }
8490
8491    /// Announce that a seat's release has begun.
8492    ///
8493    /// One place for the close doors to say it, so a viewer draws the seat as
8494    /// going to sleep from the gesture instead of keeping the state it last
8495    /// read. Each door calls it where the close is certain and the teardown
8496    /// has not started: a lead before its cascade, a worker before its own
8497    /// kill - where a despawn's removal frame only follows the worktree
8498    /// cleanup, minutes later (#1930).
8499    ///
8500    /// The seat's failure record and any standing death go with the
8501    /// announcement, before the frame that makes a view re-read: a close is
8502    /// the reader acting on the row, and a trace that outlived the seat kept
8503    /// it in the needs-you group with nothing left to act on.
8504    pub(crate) fn announce_release(self: &Arc<Self>, key: &SessionSlot) {
8505        self.clear_spawn_failure(key);
8506        self.spend_death(key);
8507        // A stop owns the seat from here: any heal attempt standing for it
8508        // - a backoff already counted down - is cancelled rather than left
8509        // to resurrect what the stop is taking down, and the subprocess
8510        // death the stop causes is not read as a crash.
8511        self.clear_heal_progress(key);
8512        self.healing().note_stopped(key);
8513        let _ = self.update_tx.send(SessionUpdate::Releasing { key: key.clone() });
8514    }
8515
8516    /// Non-cascading single-session release - the primitive. Drops
8517    /// the pool entry, command sender, and domain handle for
8518    /// `session_key`. No side effects beyond that single session.
8519    ///
8520    /// Use this for ANY session close where cascade semantics are
8521    /// undesirable or undefined:
8522    /// - `handle_close_worker` (per-row worker X click) - the close
8523    ///   must affect only the worker being closed.
8524    /// - The lead-cascade loop inside `release_session_with_cascade`
8525    ///   itself, when releasing each worker.
8526    ///
8527    /// Use `release_session_with_cascade` instead when the caller is
8528    /// the lead-row close gesture.
8529    pub(crate) fn release_session(self: &Arc<Self>, session_key: &SessionSlot) {
8530        // **A closed seat is gone now, not at the next sweep.** The fleet
8531        // hears the tombstone before the close work, so a hand-off resolved
8532        // after it does not keep the tab for a session that has ended; the
8533        // async tab close follows.
8534        self.release_browser_seat(session_key);
8535        // **The seat ended, so its terminal ends with it.** A `/new` or
8536        // `/resume` swaps the occupant and never reaches this path, which is
8537        // what leaves the PTY, its shell and its scrollback untouched.
8538        self.close_terminal(session_key);
8539        crate::dictate::teardown_for_closed_session(self, session_key);
8540        // A parked write is not a prompt the gone session can answer, and a
8541        // reused label must not draw the previous occupant's edit on a dock.
8542        self.abandon_settings_writes(session_key);
8543        // A parked vault call is the same shape: the blocked tool handler
8544        // cannot be answered by a session that is gone.
8545        self.abandon_vault_asks(session_key);
8546        // Anything parked for this session's owner was waiting on a
8547        // session that is now gone: fail the peer asks so their callers
8548        // are told, rather than letting them wait out the timeout.
8549        self.expire_parked_for_slot(
8550            session_key,
8551            crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
8552        );
8553        self.domain_handles.lock().remove(session_key);
8554        let removed = self.pool.lock().remove(session_key);
8555        drop(removed);
8556        let _ = self.command_senders.lock().remove(session_key);
8557        // The task this signal belonged to is exiting (its command channel
8558        // just closed). Its teardown entry stays: the task's drop is what
8559        // answers it, and a shutdown may still be waiting on that answer.
8560    }
8561
8562    /// Whether a session task still exists for `key`, which is what
8563    /// makes it live: its command channel is registered and routable.
8564    pub(crate) fn session_is_live(&self, key: &SessionSlot) -> bool {
8565        self.command_senders.lock().contains_key(key)
8566    }
8567
8568    /// Whether a shutdown has begun: no new session may start.
8569    pub fn is_shutting_down(&self) -> bool {
8570        self.shutting_down.load(std::sync::atomic::Ordering::Acquire)
8571    }
8572
8573    /// Mint the slot's teardown signal, or `None` when a shutdown is
8574    /// already underway and no session may start. The refusal is read under
8575    /// the same lock a shutdown drains with, so a spawn can never register
8576    /// behind the drain.
8577    pub(crate) fn mint_session_teardown(
8578        &self,
8579        key: &SessionSlot,
8580    ) -> Option<tokio::sync::watch::Sender<bool>> {
8581        let mut slots = self.session_teardowns.lock();
8582        if self.shutting_down.load(std::sync::atomic::Ordering::Acquire) {
8583            return None;
8584        }
8585        let (tx, _rx) = tokio::sync::watch::channel(false);
8586        let _ = slots.insert(key.clone(), tx.clone());
8587        Some(tx)
8588    }
8589
8590    /// Subscribe to the slot's teardown signal, if a session task is
8591    /// registered behind it and has not answered yet. `None` for a slot
8592    /// with no task - a stranded row - and for an entry a finished task
8593    /// already answered: either way the caller has nothing to wait for,
8594    /// and the despawn's mid-spawn guard reads `None` exactly that way.
8595    ///
8596    /// The entry itself stays in the map, unlike the one receiver it
8597    /// replaced: more than one waiter may need the same answer, and a
8598    /// shutdown's drain reads every entry's value rather than subscribing
8599    /// blind.
8600    pub(crate) fn session_teardown(
8601        &self,
8602        key: &SessionSlot,
8603    ) -> Option<tokio::sync::watch::Receiver<bool>> {
8604        let slots = self.session_teardowns.lock();
8605        let tx = slots.get(key)?;
8606        // An answered entry belongs to a task that is already gone: a
8607        // re-spawn's own signal is not minted yet, and handing the previous
8608        // answer back would wave a despawn's removal through over a child
8609        // that is only starting (#1767's mid-spawn window).
8610        if *tx.borrow() {
8611            return None;
8612        }
8613        Some(tx.subscribe())
8614    }
8615
8616    /// Supersession-safe release: drop `session_key`'s pool entry,
8617    /// command sender, and domain handle ONLY when the pooled agent is
8618    /// still `handle` (by `Arc` identity). A SessionTask whose session
8619    /// was re-spawned under the same key is a
8620    /// stale predecessor - when it exits and runs this cleanup, the
8621    /// pool already holds the successor's handle, so the guard no-ops
8622    /// and the live re-spawned session is left intact. Gating all three
8623    /// maps on the one identity check keeps a superseded task from
8624    /// half-cleaning its successor.
8625    pub(crate) fn release_session_if_current(
8626        &self,
8627        session_key: &SessionSlot,
8628        handle: &Arc<AgentHandle>,
8629    ) {
8630        let mut pool = self.pool.lock();
8631        if !pool.get(session_key).is_some_and(|pooled| Arc::ptr_eq(&pooled.handle, handle)) {
8632            return;
8633        }
8634        let removed = pool.remove(session_key);
8635        drop(pool);
8636        drop(removed);
8637        let _ = self.command_senders.lock().remove(session_key);
8638        let _ = self.domain_handles.lock().remove(session_key);
8639        // The teardown entry stays, as on the release path: this task's
8640        // drop answers it, and a shutdown may still be waiting. A
8641        // superseded predecessor stopped above, so the successor's entry
8642        // is untouched.
8643    }
8644
8645    // ---- Live workers (project-internal child-agent coordination) ----
8646
8647    /// Every seat that exists right now: each configured project's lead, and
8648    /// every worker the store holds - running or not, because a durable
8649    /// worker's seat exists until it is despawned.
8650    ///
8651    /// The browser sweep reads this: a tab whose owning seat is not here is
8652    /// an orphan and closes. **No profile is deleted by that sweep** - a
8653    /// profile's logins are the org's and stay until the idle clock closes
8654    /// the host - so the cost of a wrong answer is a closed tab, not a lost
8655    /// login. **Captures are serialised, the lock spanning the mint and the
8656    /// reads** ([`crate::browser::LiveSeats`]): a higher generation is a later
8657    /// seat set, which is what lets the fleet drop a stale one - including for
8658    /// a seat released without a sweep of its own, since only a capture that
8659    /// read after the release can drop its tombstone and every snapshot still
8660    /// listing the seat is older than that capture. It still holds two rules,
8661    /// because a closed tab is a page an agent was working in:
8662    ///
8663    /// - **It errs toward INCLUSION**: a seat wrongly present leaves one tab
8664    ///   to the next sweep, while a seat wrongly absent would close a live
8665    ///   session's page.
8666    /// - **It fails closed**: `None` when the store cannot be read, so no
8667    ///   caller can mistake an unread store for "no seats exist" and sweep
8668    ///   everything. Nothing sends a set it could not read.
8669    pub fn live_seats(&self) -> Option<crate::browser::LiveSeats> {
8670        use std::collections::BTreeSet;
8671
8672        // **Held across the reads**: the mint ranks a whole capture, so no
8673        // other capture may read between the two.
8674        let _capture = self.live_seats_lock.lock();
8675        let views = self.list_projects();
8676        let stored = {
8677            let guard = self.db.lock();
8678            let db = guard.as_ref()?;
8679            match crate::store::sessions::worker_row_index(db) {
8680                Ok(rows) => rows,
8681                Err(error) => {
8682                    tracing::warn!(
8683                        target: "forge_workspace::workspace",
8684                        %error,
8685                        "listing the persisted workers for the seat set failed, so no seat set \
8686                         is answered",
8687                    );
8688                    return None;
8689                }
8690            }
8691        };
8692        // Minted after the fail-closed read, so a capture that answers no set
8693        // takes no generation.
8694        let generation =
8695            self.live_seats_generation.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
8696        #[cfg(any(test, feature = "testing"))]
8697        self.pass_live_seats_gate();
8698        let mut triples: BTreeSet<(String, String, String)> = BTreeSet::new();
8699        for view in &views {
8700            triples.insert((
8701                view.org.clone(),
8702                view.name.clone(),
8703                String::from(forge_primitives::LEAD_LABEL),
8704            ));
8705        }
8706        for row in stored {
8707            if row.label != forge_primitives::LEAD_LABEL {
8708                triples.insert((row.org, row.project, row.label));
8709            }
8710        }
8711        for view in &views {
8712            for worker in self.list_live_workers(&view.key) {
8713                triples.insert((view.org.clone(), view.name.clone(), worker.label));
8714            }
8715        }
8716        Some(crate::browser::LiveSeats {
8717            generation,
8718            seats: triples
8719                .into_iter()
8720                .map(|(org, project, label)| {
8721                    forge_primitives::SessionSlot::new(org, project, label)
8722                })
8723                .collect(),
8724        })
8725    }
8726
8727    /// Snapshot the live workers for `project_key`. Returns an empty
8728    /// Vec when no workers exist (rather than `None`) so a view's tree-
8729    /// child render can branch only on `is_empty`.
8730    pub fn list_live_workers(
8731        &self,
8732        project_key: &ProjectKey,
8733    ) -> Vec<crate::mcp::workers::types::WorkerEntry> {
8734        self.live_workers.lock().get(project_key).cloned().unwrap_or_default()
8735    }
8736
8737    /// Every live worker's liveness, keyed by project.
8738    ///
8739    /// The launchpad's worker rows read this once per frame, which is
8740    /// what the shape is for: one lock answers every project row, and
8741    /// the projection leaves each worker's charter in the registry
8742    /// instead of copying it, which [`Self::list_live_workers`] does on
8743    /// every call.
8744    pub fn live_worker_states_by_project(
8745        &self,
8746    ) -> HashMap<ProjectKey, Vec<crate::mcp::workers::types::LiveWorkerState>> {
8747        self.live_workers
8748            .lock()
8749            .iter()
8750            .map(|(project, workers)| {
8751                let states = workers
8752                    .iter()
8753                    .map(|w| crate::mcp::workers::types::LiveWorkerState {
8754                        label: w.label.clone(),
8755                        status: w.status,
8756                        slot: w.slot.clone(),
8757                        diagnostic: w.diagnostic.clone(),
8758                    })
8759                    .collect();
8760                (project.clone(), states)
8761            })
8762            .collect()
8763    }
8764
8765    /// Whether `slot`'s session has live background work. A fact about the
8766    /// session rather than about a viewer, so the core holds one answer for
8767    /// every view.
8768    pub fn has_background_work(&self, slot: &SessionSlot) -> bool {
8769        self.domain_session_for(slot).is_some_and(|domain| domain.lock().background_work)
8770    }
8771
8772    /// When `slot`'s newest turn ended in failure, for the rail's mark.
8773    /// `None` for a slot whose newest turn succeeded, never ran, was
8774    /// cancelled by the reader, or has since been moved past with a new
8775    /// turn.
8776    pub fn session_failed_turn(&self, slot: &SessionSlot) -> Option<std::time::SystemTime> {
8777        self.domain_session_for(slot).and_then(|domain| domain.lock().failed_turn_at)
8778    }
8779
8780    /// What the session at `slot` is waiting on a person for, or `None`
8781    /// when it can advance on its own.
8782    ///
8783    /// **The one predicate every needs-you surface reads.** A session
8784    /// activity, a roster row, a rail mark and a notification all ask this
8785    /// rather than each keeping a list of sources: the lists drifted, and a
8786    /// terminal hand-off drew in the dock while its seat read as running
8787    /// (#2954).
8788    ///
8789    /// The order is fixed. The hand-offs lead because each parks in a
8790    /// registry of the workspace's own rather than in the session's pending
8791    /// set, so a seat with no domain can still be holding one; then the
8792    /// question outranks the permission prompt beside it, because both hold
8793    /// a turn and the question is the one a person has to read; then the
8794    /// remaining registries, the Slack draft first.
8795    ///
8796    /// **The row names the strongest ask; the dock draws its own queue.**
8797    /// [`Self::pending_asks`] fronts a draft because a card is what a person
8798    /// reads first, while this answers one kind - so a seat holding a Slack
8799    /// draft and a document hand-off names the hand-off on its row and shows
8800    /// the draft at the front of the dock.
8801    pub fn pending_interaction(&self, slot: &SessionSlot) -> Option<PendingInteractionKind> {
8802        self.pending_ask_ledger(slot).into_iter().find(|(_, held)| *held > 0).map(|(kind, _)| kind)
8803    }
8804
8805    /// How many asks the session at `slot` is holding, zero when none.
8806    /// **The same ledger [`Self::pending_interaction`] reads**, summed, so a
8807    /// view showing one of them can say how many wait behind it.
8808    pub fn pending_interaction_depth(&self, slot: &SessionSlot) -> usize {
8809        self.pending_ask_ledger(slot).iter().map(|(_, held)| held).sum()
8810    }
8811
8812    /// Every ask source, in [`Self::pending_interaction`]'s precedence
8813    /// order, with the number of asks each holds.
8814    ///
8815    /// **One list feeds the kind and the depth**, so a source cannot be
8816    /// counted without being answered or answered without being counted.
8817    /// The guard test parks one ask of every kind and reads both back.
8818    ///
8819    /// **Lock order:** each registry read takes and releases its own lock,
8820    /// and the domain guard is off before the registry reads after it, so no
8821    /// path here holds a registry lock and the domain lock together.
8822    fn pending_ask_ledger(&self, slot: &SessionSlot) -> [(PendingInteractionKind, usize); 8] {
8823        let documents = self.document_handoffs.lock().values().filter(|h| &h.seat == slot).count();
8824        let browsers =
8825            self.browser_handoffs.lock().values().filter(|(owner, _, _)| owner == slot).count();
8826        let terminals =
8827            self.terminal_handoffs.lock().values().filter(|(owner, _, _)| owner == slot).count();
8828        let (questions, permissions) = match self.domain_session_for(slot) {
8829            Some(domain) => {
8830                let guard = domain.lock();
8831                let questions = guard
8832                    .pending_interactions
8833                    .values()
8834                    .filter(|pending| {
8835                        matches!(pending, crate::protocol::PendingInteractionSlot::Question { .. })
8836                    })
8837                    .count();
8838                // The set holds questions and permissions and nothing else.
8839                (questions, guard.pending_interactions.len().saturating_sub(questions))
8840            }
8841            None => (0, 0),
8842        };
8843        let drafts =
8844            self.slack_drafts.lock().values().filter(|(owner, _, _)| owner == slot).count();
8845        let writes =
8846            self.settings_writes.lock().values().filter(|(owner, _, _)| owner == slot).count();
8847        let vaults =
8848            self.parked_vault_asks.lock().values().filter(|(owner, _, _)| owner == slot).count();
8849        [
8850            (PendingInteractionKind::DocumentHandOff, documents),
8851            (PendingInteractionKind::BrowserHandOff, browsers),
8852            (PendingInteractionKind::TerminalHandOff, terminals),
8853            (PendingInteractionKind::Question, questions),
8854            (PendingInteractionKind::Permission, permissions),
8855            (PendingInteractionKind::SlackDraft, drafts),
8856            (PendingInteractionKind::SettingsWrite, writes),
8857            (PendingInteractionKind::Vault, vaults),
8858        ]
8859    }
8860
8861    /// Note that an ask arrived for `slot`.
8862    ///
8863    /// **The number is the ask's identity on the wire.** A client announces a
8864    /// new ask by it, so a re-read of the roster is not news, an ask replacing
8865    /// another at the same depth is, and an answer that shrinks a batch is
8866    /// not.
8867    pub(crate) fn note_ask(&self, slot: &SessionSlot) {
8868        let mut held = self.pending_seq.lock();
8869        let next = held.entry(slot.clone()).or_insert(0);
8870        *next = next.saturating_add(1);
8871    }
8872
8873    /// How many asks have ever arrived for `slot`, zero when none has.
8874    pub fn pending_request_seq(&self, slot: &SessionSlot) -> u64 {
8875        self.pending_seq.lock().get(slot).copied().unwrap_or(0)
8876    }
8877
8878    /// Record a fatal error, so a view that was not attached when it fired
8879    /// can still read it.
8880    pub(crate) fn record_fatal_error(&self, error: forge_primitives::error::AppError) {
8881        *self.last_fatal_error.lock() = Some(error);
8882    }
8883
8884    /// The last fatal error, or `None` when nothing has failed fatally.
8885    ///
8886    /// A fatal error is App-level: it names the startup that could not
8887    /// happen rather than a seat, and it arrives once. Without this the
8888    /// only way to know is to have been subscribed when it went out.
8889    pub fn last_fatal_error(&self) -> Option<forge_primitives::error::AppError> {
8890        self.last_fatal_error.lock().clone()
8891    }
8892
8893    /// The statuspage's last answer, or `None`.
8894    ///
8895    /// `None` covers both "every relevant component is operational" and
8896    /// "the statuspage could not be reached", which is the fetch's own
8897    /// contract rather than something invented here - and a view that draws
8898    /// nothing for it is drawing the answer, not a gap.
8899    ///
8900    /// The core probes rather than the view because the answer is the same
8901    /// for every viewer and because it must outlive any one of them: a
8902    /// client that attaches later would otherwise never see a
8903    /// service status at all.
8904    pub fn service_status(&self) -> Option<ServiceIssue> {
8905        self.service_status.lock().clone()
8906    }
8907
8908    /// Kick off the statuspage probe. Idempotent, and off the boot path.
8909    fn start_service_status_probe(&self, prober: ServiceStatusProber) {
8910        spawn_background_service_status_probe(
8911            &self.service_status_probe_started,
8912            &self.service_status,
8913            &self.update_tx,
8914            prober,
8915        );
8916    }
8917
8918    /// What the seat at `slot` is holding - **a draft leads, then arrival
8919    /// order** - empty when it is holding nothing. Every hop keeps that
8920    /// order, so a re-read and the live folds cannot disagree about the
8921    /// front, with one exception: two drafts held at once - and two
8922    /// configuration writes held at once - come back in the registry's own
8923    /// order, because the registry keeps none to offer.
8924    ///
8925    /// **The list rather than one ask**, because a parallel batch parks
8926    /// several at once and a view that attached mid-batch has no other way to
8927    /// read the ones behind the front: the stream is a mirror with no backlog,
8928    /// and the request beside the answer's oneshot is what is left. A view
8929    /// draws the front.
8930    ///
8931    /// A draft leads because it is parked in its own registry rather than in
8932    /// the session's pending set, so a seat with no domain can still hold one.
8933    pub fn pending_asks(&self, slot: &SessionSlot) -> Vec<crate::protocol::PendingAsk> {
8934        let mut asks = Vec::new();
8935        {
8936            let parked = self.slack_drafts.lock();
8937            asks.extend(parked.values().filter(|(owner, _, _)| owner == slot).map(
8938                |(_, draft, _)| crate::protocol::PendingAsk::SlackDraft(Box::new(draft.clone())),
8939            ));
8940        }
8941        {
8942            let parked = self.browser_handoffs.lock();
8943            asks.extend(parked.values().filter(|(owner, _, _)| owner == slot).map(
8944                |(_, handoff, _)| {
8945                    crate::protocol::PendingAsk::BrowserHandOff(Box::new(handoff.clone()))
8946                },
8947            ));
8948        }
8949        {
8950            let parked = self.terminal_handoffs.lock();
8951            asks.extend(parked.values().filter(|(owner, _, _)| owner == slot).map(
8952                |(_, handoff, _)| {
8953                    crate::protocol::PendingAsk::TerminalHandOff(Box::new(handoff.clone()))
8954                },
8955            ));
8956        }
8957        {
8958            let parked = self.document_handoffs.lock();
8959            asks.extend(parked.values().filter(|held| &held.seat == slot).map(|held| {
8960                crate::protocol::PendingAsk::DocumentHandOff(Box::new(held.ask.clone()))
8961            }));
8962        }
8963        let needs_unlock = self.vault.read().as_ref().is_some_and(|session| session.is_locked());
8964        asks.extend(self.vault_asks(slot, needs_unlock));
8965        asks.extend(self.settings_write_asks(slot));
8966        if let Some(domain) = self.domain_session_for(slot) {
8967            let guard = domain.lock();
8968            asks.extend(
8969                guard
8970                    .pending_interactions
8971                    .values()
8972                    .map(crate::protocol::PendingInteractionSlot::ask),
8973            );
8974        }
8975        asks
8976    }
8977
8978    /// What `entry`'s session is doing right now. The two liveness states
8979    /// that need no session of their own answer here; everything else is
8980    /// [`Self::session_activity`].
8981    ///
8982    /// Call this with no worker lock held - it reaches for
8983    /// `domain_handles` and then the `DomainSession`.
8984    pub fn worker_activity(
8985        &self,
8986        entry: &crate::mcp::workers::types::WorkerEntry,
8987    ) -> forge_primitives::SessionLifecycleState {
8988        use forge_primitives::{SessionLifecycleState as L, WorkerLiveness};
8989
8990        // None of these has a connected session to interrogate.
8991        match entry.status {
8992            WorkerLiveness::Spawning => return L::Spawning,
8993            WorkerLiveness::Failed => return L::Failed,
8994            WorkerLiveness::Restarting => return L::Restarting,
8995            WorkerLiveness::Running => {}
8996        }
8997        self.session_activity(&entry.slot)
8998    }
8999
9000    /// Why the last spawn or connection for `slot` failed, when it has not
9001    /// come up since. `None` for a slot that is running, was never tried,
9002    /// or recovered.
9003    pub fn spawn_failure(&self, slot: &SessionSlot) -> Option<String> {
9004        self.spawn_failures.lock().get(slot).cloned()
9005    }
9006
9007    /// Record that `slot`'s spawn or connection failed, and why. Called
9008    /// before the failure releases the session, because the release is
9009    /// what takes the rest of the evidence with it.
9010    pub(crate) fn record_spawn_failure(&self, slot: &SessionSlot, message: &str) {
9011        self.spawn_failures.lock().insert(slot.clone(), message.to_owned());
9012    }
9013
9014    /// Clear `slot`'s recorded failure: a session for it came up. Called
9015    /// from the `Connected` arm, which is the path production takes - the
9016    /// spawn entries hoist a domain straight into `domain_handles` rather
9017    /// than registering one, so clearing on registration would never run.
9018    pub(crate) fn clear_spawn_failure(&self, slot: &SessionSlot) {
9019        let _ = self.spawn_failures.lock().remove(slot);
9020    }
9021
9022    /// Record that the occupant running on `slot` died, with the reason it
9023    /// left, keeping both until a reader has been shown the seat and the
9024    /// recovery has announced itself. The lifecycle is NOT held down by
9025    /// this: a recovering connect clears the failure as it always did, and
9026    /// what rides on until shown is the death - `died` on the row, with the
9027    /// reason and the mark the client draws from it.
9028    pub(crate) fn record_death(&self, slot: &SessionSlot, occupant: SessionId, reason: &str) {
9029        self.deaths.lock().insert(
9030            slot.clone(),
9031            SeatDeath { occupant, reason: reason.to_owned(), announced: false, shown: false },
9032        );
9033    }
9034
9035    /// The died occupant recorded for `slot` when the recovery has not
9036    /// announced it yet, consuming that one announcement: the notice fires
9037    /// on the first connect that answers the death and stays quiet after.
9038    ///
9039    /// **The spend does not consume it.** A reader who opens the crashed
9040    /// seat's page spends the row's trace and then wakes the seat, so the
9041    /// connect that answers their own click is the first one to see the
9042    /// death - and it is owed the sentence like any other.
9043    pub(crate) fn unannounced_death(&self, slot: &SessionSlot) -> Option<SessionId> {
9044        let mut deaths = self.deaths.lock();
9045        let death = deaths.get_mut(slot)?;
9046        if death.announced {
9047            return None;
9048        }
9049        death.announced = true;
9050        let occupant = death.occupant.clone();
9051        if death.shown {
9052            deaths.remove(slot);
9053        }
9054        Some(occupant)
9055    }
9056
9057    /// Whether a death stands unshown on `slot`: the row carries `died` with
9058    /// its reason while one does, so a crash nobody watched is still
9059    /// readable when someone looks. `pub` because the row a view reads
9060    /// carries it.
9061    pub fn death_stands(&self, slot: &SessionSlot) -> bool {
9062        self.deaths.lock().get(slot).is_some_and(|death| !death.shown)
9063    }
9064
9065    /// The reason a standing death left, for the row to draw beside its
9066    /// mark. `None` once a reader has been shown the seat.
9067    pub fn death_reason(&self, slot: &SessionSlot) -> Option<String> {
9068        self.deaths.lock().get(slot).filter(|death| !death.shown).map(|death| death.reason.clone())
9069    }
9070
9071    /// Record that `slot` is between heal attempts: which attempt is next
9072    /// and when it fires. Written by the healing supervisor and read by
9073    /// every row that draws the seat.
9074    pub fn set_heal_progress(&self, slot: &SessionSlot, attempt: u32, next_at: SystemTime) {
9075        self.heal_progress.lock().insert(slot.clone(), HealProgress { attempt, next_at });
9076    }
9077
9078    /// Forget `slot`'s heal progress: an occupant connected, or the seat
9079    /// gave up and its row now draws the failure instead.
9080    pub fn clear_heal_progress(&self, slot: &SessionSlot) {
9081        let _ = self.heal_progress.lock().remove(slot);
9082    }
9083
9084    /// Where `slot`'s heal has got to, or `None` when the seat is not
9085    /// restarting.
9086    pub fn heal_progress(&self, slot: &SessionSlot) -> Option<HealProgress> {
9087        self.heal_progress.lock().get(slot).copied()
9088    }
9089
9090    /// How many restarts a heal may make, from the current config: the row
9091    /// says `restarting (attempt 1 of 3)` from this.
9092    pub fn heal_attempts(&self) -> Option<u32> {
9093        u32::try_from(self.config_arc().sessions.heal_attempts).ok()
9094    }
9095
9096    /// The supervisor, built on first use.
9097    fn healing(self: &Arc<Self>) -> Arc<crate::healing::Healing> {
9098        self.healing.get_or_init(|| crate::healing::Healing::for_workspace(self)).clone()
9099    }
9100
9101    /// A seat that had been up died: hand the death to the supervisor. A
9102    /// resume's new occupant that never came up is a death here too - the
9103    /// attempt failed rather than a first spawn being surfaced.
9104    pub(crate) fn note_seat_death(self: &Arc<Self>, slot: &SessionSlot, diagnostic: &str) {
9105        self.healing().note_death(slot, diagnostic);
9106    }
9107
9108    /// A session came up on `slot`, which cancels any scheduled heal.
9109    pub(crate) fn note_seat_connected(self: &Arc<Self>, slot: &SessionSlot) {
9110        if let Some(healing) = self.healing.get() {
9111            healing.note_connected(slot);
9112        }
9113    }
9114
9115    /// `slot`'s worker is between heal attempts: its row draws
9116    /// `Restarting`, and the label is free for the resume.
9117    pub(crate) fn mark_worker_restarting(&self, project_key: &ProjectKey, slot: &SessionSlot) {
9118        let entry = {
9119            let mut workers = self.live_workers.lock();
9120            workers.get_mut(project_key).and_then(|entries| {
9121                entries.iter_mut().find(|entry| entry.slot == *slot).map(|entry| {
9122                    entry.status = forge_primitives::WorkerLiveness::Restarting;
9123                    entry.clone()
9124                })
9125            })
9126        };
9127        if let Some(entry) = entry {
9128            let status = self.worker_status_snapshot(&entry);
9129            let _ = self.update_tx.send(SessionUpdate::WorkerStatusChanged {
9130                project_key: project_key.clone(),
9131                action: crate::protocol::WorkerStatusAction::StatusChanged,
9132                status,
9133                worktree: crate::protocol::WorktreeDisposition::untouched(
9134                    entry.is_git_repo_at_spawn,
9135                ),
9136            });
9137        }
9138    }
9139
9140    /// A reader has been shown the death on `slot` - the seat's page was
9141    /// opened: the row's trace goes (whether or not the recovery has
9142    /// announced itself yet), and the answer says whether there was one to
9143    /// spend. `pub` because the seat being shown is the server's to see,
9144    /// and this is the spend the server makes on the view's behalf.
9145    pub fn spend_death(&self, slot: &SessionSlot) -> bool {
9146        let mut deaths = self.deaths.lock();
9147        let Some(death) = deaths.get_mut(slot) else {
9148            return false;
9149        };
9150        let spent = !death.shown;
9151        death.shown = true;
9152        if death.announced {
9153            deaths.remove(slot);
9154        }
9155        spent
9156    }
9157
9158    /// What the session at `slot` is doing right now - the axis
9159    /// `WorkerLiveness` does not answer, since it stops moving once the
9160    /// worker connects. Reachable without a `WorkerEntry`, which a project
9161    /// lead has none of.
9162    ///
9163    /// A pending interaction outranks the turn it is blocking:
9164    /// `Attention` is the state a lead has to act on, and reporting the
9165    /// blocked worker as `Running` is what let the deadlock stay
9166    /// invisible.
9167    ///
9168    /// Call this with no worker lock held - it reaches for
9169    /// `domain_handles` and then the `DomainSession`.
9170    pub fn session_activity(&self, slot: &SessionSlot) -> forge_primitives::SessionLifecycleState {
9171        use forge_primitives::SessionLifecycleState as L;
9172
9173        // A seat between heal attempts outranks the failure that started
9174        // the heal: the failure is being answered, and drawing it would
9175        // tell a reader nothing is happening when something is.
9176        if self.heal_progress(slot).is_some() {
9177            return L::Restarting;
9178        }
9179        // Both of these are terminal for the session's own progress, so
9180        // they answer before anything about turns: a session waiting to
9181        // be let in, and one whose last attempt to start is still the
9182        // last thing that happened to it.
9183        if self.spawn_failure(slot).is_some() {
9184            return L::Failed;
9185        }
9186        let Some(domain) = self.domain_session_for(slot) else {
9187            return L::Sleeping;
9188        };
9189        if domain.lock().awaiting_login {
9190            return L::AuthRequired;
9191        }
9192        let requires_action = {
9193            let guard = domain.lock();
9194            // A held interaction only matters while a turn can advance on
9195            // it, and this gate is what decides that for every ask kind too:
9196            // one parked on a slot with no turn (busytools/forge#672 - a slot
9197            // outliving its turn is incoherent state) reads `Idle` here
9198            // rather than as a person's to answer.
9199            if !guard.turn_in_flight() {
9200                return L::Idle;
9201            }
9202            // `RequiresAction` is the CLI naming its own block; a held ask
9203            // is forge naming it, through the predicate below. Either way a
9204            // human has to move first, and calling that `Running` is what
9205            // makes a blocked worker invisible. This arm is reachable only
9206            // because `turn_in_flight()` counts `RequiresAction` as
9207            // in-flight: drop it from that OR and a `RequiresAction` session
9208            // falls through the gate above to `Idle` instead.
9209            matches!(
9210                guard.runtime_state,
9211                Some(forge_primitives::RuntimeSessionState::RequiresAction)
9212            )
9213        };
9214        // **Every ask kind reads through the one predicate**, after the
9215        // domain guard comes off: each parks in its own registry, and
9216        // `pending_interaction` keeps the registry locks and the domain lock
9217        // from being held together. Knowing only some sources is what kept
9218        // the lifecycle quiet while the dock sat unanswered - the Slack
9219        // draft (#1758), the browser hand-off, the document hand-off, the
9220        // settings write and the vault call, and the terminal hand-off last
9221        // (#2954).
9222        if requires_action || self.pending_interaction(slot).is_some() {
9223            L::Attention
9224        } else {
9225            L::Running
9226        }
9227    }
9228
9229    /// `entry` projected to the wire shape with `activity` derived.
9230    /// This is the `agents__list` projection; `WorkerEntry::to_status`
9231    /// is the event-path one that leaves `activity` unset.
9232    pub fn worker_status_snapshot(
9233        &self,
9234        entry: &crate::mcp::workers::types::WorkerEntry,
9235    ) -> forge_primitives::WorkerStatus {
9236        let heal = self.heal_progress(&entry.slot);
9237        forge_primitives::WorkerStatus {
9238            activity: Some(self.worker_activity(entry)),
9239            heal_attempt: heal.map(|progress| progress.attempt),
9240            heal_next_at: heal.map(|progress| progress.next_at),
9241            ..entry.to_status()
9242        }
9243    }
9244
9245    /// The forge.toml project NAME that owns `cwd`, matched by the
9246    /// project whose expanded path is an ancestor-or-equal of `cwd`
9247    /// (component-aware, longest wins), so a worker in a
9248    /// `<project>/.claude/worktrees/<label>` worktree resolves to its
9249    /// parent project. `cwd` is `~`-expanded before the lexical match so
9250    /// a tilde form can't miss the already-expanded `ProjectView.path`.
9251    /// `None` when `cwd` is blank or under no configured project. The
9252    /// Inspector stamps this NAME onto a tab's UI bucket once (at
9253    /// Connect), then scopes SCHEDULES / GOTIFY by name rather than
9254    /// re-deriving the project every render tick from a stale cwd.
9255    pub fn project_name_for_path(&self, cwd: &str) -> Option<String> {
9256        if cwd.is_empty() {
9257            return None;
9258        }
9259        let cwd = crate::config::expand_home(cwd);
9260        self.list_projects()
9261            .into_iter()
9262            .filter(|view| cwd.starts_with(&view.path))
9263            .max_by_key(|view| view.path.as_os_str().len())
9264            .map(|view| view.name)
9265    }
9266
9267    /// The forge.toml project NAME that owns the RESOLVED `cwd`: the
9268    /// match [`Self::project_name_for_path`] makes, retried with both
9269    /// sides canonicalized when the lexical one misses. A spawn's tree is
9270    /// canonical - the CLI derives its state directory from the canonical
9271    /// path - while a declared path may reach the same directory through
9272    /// an alias (`/tmp` for `/private/tmp`, a symlinked parent), and the
9273    /// lexical rule alone would miss: a crossing-into-another-project
9274    /// check that misses is the rule the check exists for.
9275    ///
9276    /// Both sides resolve together or not at all, the way the sibling's
9277    /// lexical match keeps them in one form - a per-side canonicalize
9278    /// would put an existing root on one side of the comparison and an
9279    /// uncanonicalized child on the other.
9280    pub fn project_name_for_resolved_path(&self, cwd: &str) -> Option<String> {
9281        if let Some(name) = self.project_name_for_path(cwd) {
9282            return Some(name);
9283        }
9284        let canonical = std::fs::canonicalize(crate::config::expand_home(cwd)).ok()?;
9285        self.list_projects()
9286            .into_iter()
9287            .filter_map(|view| {
9288                let declared = std::fs::canonicalize(&view.path).ok()?;
9289                canonical.starts_with(&declared).then_some((declared, view.name))
9290            })
9291            .max_by_key(|(declared, _)| declared.as_os_str().len())
9292            .map(|(_, name)| name)
9293    }
9294
9295    /// The `(org, project name)` a project key names, when forge.toml
9296    /// still declares it. The `sessions` table is keyed by the pair
9297    /// while every worker site holds the key, so a worker's row cannot be
9298    /// written or found without this translation.
9299    fn project_identity_for_key(&self, project_key: &ProjectKey) -> Option<(String, String)> {
9300        self.project_for_key(project_key).map(|project| (project.org, project.name))
9301    }
9302
9303    /// Write `label`'s worker row: the id it runs under plus the
9304    /// re-spawn arguments its spawn stated. The row is the only thing a
9305    /// boot re-spawns from, so the charter, the kick, the interactive
9306    /// flag and the gitness its worktree derives from land here rather
9307    /// than staying in memory.
9308    ///
9309    /// `Err` when the store is closed, the project is no longer
9310    /// configured, or the write failed, so the caller can warn the lead
9311    /// that this worker will not survive a restart.
9312    pub(crate) fn record_worker_row(
9313        &self,
9314        project_key: &ProjectKey,
9315        label: &str,
9316        id: &str,
9317        charter: &str,
9318        kick: Option<&str>,
9319        resume_kick: Option<&str>,
9320        interactive: bool,
9321        tree: crate::mcp::workers::types::WorkerTree,
9322        mcp_families: Option<&[String]>,
9323    ) -> anyhow::Result<()> {
9324        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9325            anyhow::bail!("no configured project for {}", project_key.as_str());
9326        };
9327        let guard = self.db.lock();
9328        let Some(db) = guard.as_ref() else {
9329            anyhow::bail!("the session store is unavailable this session");
9330        };
9331        // A boot re-spawn or a heal passes no re-orient message of its
9332        // own - it hands the row's `resume_kick` to the child as the kick
9333        // only when the row says a turn was cut off, and nothing when the
9334        // worker was parked - so a `None` here carries the stored value
9335        // forward rather than erasing the field its caller just read. Same
9336        // for `kick`: only a first spawn states one, and a resume that
9337        // wrote its own live kick here would replace the worker's original
9338        // first turn with it.
9339        let existing = crate::store::sessions::get(db, &org, &project, label).ok().flatten();
9340        crate::store::sessions::put(
9341            db,
9342            &crate::store::sessions::SessionRecord {
9343                org,
9344                project,
9345                label: label.to_owned(),
9346                session_id: Some(id.to_owned()),
9347                charter: Some(charter.to_owned()),
9348                mcp_families: match mcp_families {
9349                    Some(names) if !names.is_empty() => Some(names.to_vec()),
9350                    // Empty resolves to every family, stored as absence;
9351                    // a resume states none and carries the stored value.
9352                    Some(_) => None,
9353                    None => existing.as_ref().and_then(|row| row.mcp_families.clone()),
9354                },
9355                kick: kick
9356                    .map(str::to_owned)
9357                    .or_else(|| existing.as_ref().and_then(|row| row.kick.clone())),
9358                resume_kick: resume_kick
9359                    .map(str::to_owned)
9360                    .or_else(|| existing.and_then(|row| row.resume_kick)),
9361                interactive: Some(interactive),
9362                // A spawn and a re-spawn both leave the worker open with
9363                // no turn in flight; its kick, when one is due, is what
9364                // flips it.
9365                seat_state: Some(crate::store::sessions::SeatState::Idle),
9366                is_git_repo: Some(tree.is_git_repo),
9367                // Stored as the spawn resolved it, never carried forward
9368                // from an older row: a spawn that states no tree runs in
9369                // the project root, and only a resume adopts one.
9370                cwd: tree.cwd,
9371            },
9372        )
9373    }
9374
9375    /// The git flag the worker's row records, or `Ok(None)` when there is
9376    /// no row to read or its row predates the field. The spawn reads it in
9377    /// preference to probing the project path, so the cwd it builds is the
9378    /// one the launchpad already checked.
9379    ///
9380    /// A read failure is an `Err`, not an absent row: an absent row means
9381    /// the caller may probe, while an unreadable one means the answer is
9382    /// unknown and the caller should say so rather than quietly compose a
9383    /// different directory. Distinct from [`Self::stored_worker_row`],
9384    /// which reports both the same way because its callers act on the row
9385    /// itself rather than on a field of it.
9386    pub(crate) fn recorded_worker_is_git_repo(
9387        &self,
9388        project_key: &ProjectKey,
9389        label: &str,
9390    ) -> anyhow::Result<Option<bool>> {
9391        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9392            return Ok(None);
9393        };
9394        let guard = self.db.lock();
9395        let Some(db) = guard.as_ref() else {
9396            anyhow::bail!("the session store is unavailable this session");
9397        };
9398        Ok(crate::store::sessions::get(db, &org, &project, label)?.and_then(|row| row.is_git_repo))
9399    }
9400
9401    /// The MCP-family selection the worker's row records, or `Ok(None)`
9402    /// when there is no row or its row names none (which resolves to
9403    /// every family). Same failure contract as
9404    /// [`Self::recorded_worker_is_git_repo`]: an unreadable row is an
9405    /// `Err`, not an absence.
9406    pub(crate) fn recorded_worker_mcp_families(
9407        &self,
9408        project_key: &ProjectKey,
9409        label: &str,
9410    ) -> anyhow::Result<Option<Vec<String>>> {
9411        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9412            return Ok(None);
9413        };
9414        let guard = self.db.lock();
9415        let Some(db) = guard.as_ref() else {
9416            anyhow::bail!("the session store is unavailable this session");
9417        };
9418        Ok(crate::store::sessions::get(db, &org, &project, label)?.and_then(|row| row.mcp_families))
9419    }
9420
9421    /// Delete a worker's persisted row so it never re-spawns. The row is
9422    /// the only thing that brings one back, so a delete that cannot land
9423    /// is an error rather than a warn.
9424    ///
9425    /// `Ok` reports whether a row was there to remove. `Err` means the row
9426    /// could not be addressed or the delete failed - which is not the same
9427    /// answer as an absent row, so `spawn::handle_despawn_worker` reports
9428    /// the failure rather than NotFound when it gets one.
9429    pub(crate) fn delete_worker_row(
9430        &self,
9431        project_key: &ProjectKey,
9432        label: &str,
9433    ) -> anyhow::Result<bool> {
9434        // Every failure here is logged where it happens: the callers that
9435        // roll a spawn back have nothing to do with an `Err` and would
9436        // otherwise drop it silently.
9437        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9438            tracing::warn!(
9439                target: "forge_workspace::workspace",
9440                project = %project_key.as_str(),
9441                label = %label,
9442                "no configured project for this worker's key; its row is left where it is",
9443            );
9444            anyhow::bail!("no configured project for {}", project_key.as_str());
9445        };
9446        let guard = self.db.lock();
9447        let Some(db) = guard.as_ref() else {
9448            tracing::warn!(
9449                target: "forge_workspace::workspace",
9450                event_name = "worker_row_delete_store_unavailable",
9451                project = %project_key.as_str(),
9452                label = %label,
9453                "the session store is unavailable; the worker's row is left where it is",
9454            );
9455            anyhow::bail!("the session store is unavailable this session");
9456        };
9457        match crate::store::sessions::delete(db, &org, &project, label) {
9458            Ok(existed) => Ok(existed),
9459            Err(error) => {
9460                tracing::error!(
9461                    target: "forge_workspace::workspace",
9462                    %error,
9463                    project = %project_key.as_str(),
9464                    label = %label,
9465                    "deleting a persisted worker failed; it may re-spawn on restart",
9466                );
9467                Err(error)
9468            }
9469        }
9470    }
9471
9472    /// Every persisted worker row for `project_key`, the lead's own row
9473    /// excluded. Empty when the DB isn't open or the read fails. Backs
9474    /// the lead-reconnect re-spawn merge and the durable-identity read.
9475    pub(crate) fn worker_rows_for_project(
9476        &self,
9477        project_key: &ProjectKey,
9478    ) -> Vec<crate::store::sessions::SessionRecord> {
9479        self.worker_rows_for_project_strict(project_key).unwrap_or_else(|error| {
9480            tracing::warn!(
9481                target: "forge_workspace::workspace",
9482                %error,
9483                project = %project_key.as_str(),
9484                "listing persisted workers failed",
9485            );
9486            Vec::new()
9487        })
9488    }
9489
9490    /// The project's durable worker rows, with a store failure surfaced
9491    /// rather than swallowed: a caller deciding whether a worktree path is
9492    /// free refuses on an unknown.
9493    pub(crate) fn worker_rows_for_project_strict(
9494        &self,
9495        project_key: &ProjectKey,
9496    ) -> anyhow::Result<Vec<crate::store::sessions::SessionRecord>> {
9497        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9498            return Ok(Vec::new());
9499        };
9500        let guard = self.db.lock();
9501        let Some(db) = guard.as_ref() else {
9502            // A store that is not open is not a project without rows: a
9503            // caller deciding whether a path is free must refuse on the
9504            // unknown.
9505            anyhow::bail!(
9506                "the session store is unavailable, so project '{project}' has no readable \
9507                 worker rows"
9508            );
9509        };
9510        Ok(crate::store::sessions::list_for_project(db, &org, &project)?
9511            .into_iter()
9512            .filter(|row| row.label != forge_primitives::LEAD_LABEL)
9513            .collect())
9514    }
9515
9516    /// Every persisted worker label the launchpad still offers, keyed by
9517    /// the project key it holds. A row the boot wave would not start is
9518    /// not among them, unless a live worker holds it as Failed: that row
9519    /// is the failure's own record, and its reason has to be drawable.
9520    ///
9521    /// The launchpad's worker rows read this once per frame, which is
9522    /// what the shape is for: one read transaction answers every project
9523    /// row, over a projection that skips each row's charter. Unlike
9524    /// [`Self::list_live_workers`] it answers before the project has
9525    /// launched, which is when the launchpad renders.
9526    ///
9527    /// Empty on a read failure rather than surfacing it, deliberately
9528    /// unlike the sibling `stored_worker_row`: the caller is a render
9529    /// path, where a warn plus a bare row beats failing the frame.
9530    pub fn worker_labels_by_project(&self) -> HashMap<ProjectKey, Vec<String>> {
9531        let rows = {
9532            let guard = self.db.lock();
9533            let Some(db) = guard.as_ref() else {
9534                return HashMap::new();
9535            };
9536            crate::store::sessions::worker_row_index(db).unwrap_or_else(|error| {
9537                tracing::warn!(
9538                    target: "forge_workspace::workspace",
9539                    %error,
9540                    "listing persisted worker labels failed",
9541                );
9542                Vec::new()
9543            })
9544        };
9545        let views = self.list_projects();
9546        let mut out: HashMap<ProjectKey, Vec<String>> = HashMap::new();
9547        for row in rows {
9548            if row.label == forge_primitives::LEAD_LABEL {
9549                continue;
9550            }
9551            let Some(view) = views.iter().find(|v| v.org == row.org && v.name == row.project)
9552            else {
9553                continue;
9554            };
9555            // A live Failed row is the failure's own record: the start probe
9556            // would drop it with the reason it exists to show (#1593). Any
9557            // other row the wave would not start is not a worker the
9558            // launchpad can offer either - the same question, so the pane
9559            // and the wave answer it with one call. The tree it asks about is
9560            // the ROW's own: a worker pointed at another directory is offered
9561            // on that directory, not the project's.
9562            let failed_live = self.live_workers.lock().get(&view.key).is_some_and(|entries| {
9563                entries.iter().any(|entry| {
9564                    entry.label == row.label
9565                        && entry.status == forge_primitives::WorkerLiveness::Failed
9566                })
9567            });
9568            if failed_live
9569                || crate::mcp::workers::types::worker_row_can_start(
9570                    row.cwd.as_deref().map_or(view.path.as_path(), std::path::Path::new),
9571                    &row.label,
9572                    row.is_git_repo,
9573                    row.session_id.is_some(),
9574                )
9575            {
9576                out.entry(view.key.clone()).or_default().push(row.label);
9577            }
9578        }
9579        out
9580    }
9581
9582    /// The row `label` holds in `project_key`. Distinct from
9583    /// [`Self::worker_rows_for_project`], which swallows a read failure as
9584    /// empty: this surfaces the error (and treats a missing store as one)
9585    /// so the cron fire router can tell "conclusively absent" from "could
9586    /// not read" and leave the cron on a hiccup.
9587    pub(crate) fn stored_worker_row(
9588        &self,
9589        project_key: &ProjectKey,
9590        label: &str,
9591    ) -> anyhow::Result<Option<crate::store::sessions::SessionRecord>> {
9592        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9593            anyhow::bail!("no configured project for {}", project_key.as_str());
9594        };
9595        let guard = self.db.lock();
9596        let Some(db) = guard.as_ref() else {
9597            anyhow::bail!("the session store is unavailable this session");
9598        };
9599        crate::store::sessions::get(db, &org, &project, label)
9600    }
9601
9602    /// Merge the supplied fields onto the worker row for `(project_key,
9603    /// label)`, leaving a `None` field at its stored value. Returns
9604    /// whether a row existed; this never creates one, because a row is
9605    /// what makes a worker re-spawn on the next lead connect. Read and
9606    /// write share one store lock so a concurrent despawn cannot land
9607    /// between them and resurrect the row.
9608    pub(crate) fn update_worker_row(
9609        &self,
9610        project_key: &ProjectKey,
9611        label: &str,
9612        charter: Option<String>,
9613        kick: Option<String>,
9614        resume_kick: Option<String>,
9615        mcp_families: Option<Vec<String>>,
9616    ) -> anyhow::Result<bool> {
9617        let Some((org, project)) = self.project_identity_for_key(project_key) else {
9618            anyhow::bail!("no configured project for {}", project_key.as_str());
9619        };
9620        let guard = self.db.lock();
9621        let Some(db) = guard.as_ref() else {
9622            anyhow::bail!("the session store is unavailable this session");
9623        };
9624        crate::store::sessions::update(
9625            db,
9626            &crate::store::sessions::SessionRecord {
9627                org,
9628                project,
9629                label: label.to_owned(),
9630                session_id: None,
9631                charter,
9632                kick,
9633                resume_kick,
9634                interactive: None,
9635                // An update of the worker's charter and kick says nothing
9636                // about whether a turn is running.
9637                seat_state: None,
9638                // `update` leaves an absent field alone; the gitness is
9639                // fixed at spawn and never re-decided here.
9640                is_git_repo: None,
9641                // Absent leaves the stored selection alone; `Some(empty)`
9642                // resets it to every family.
9643                mcp_families,
9644                // And absent leaves the stored tree alone: the tree is
9645                // fixed at spawn, the way the gitness is.
9646                cwd: None,
9647            },
9648        )
9649    }
9650
9651    /// Scan the shared session-JSONL pool into a `UsageReport` for the
9652    /// `usage` subject. Query-style (a direct method, not a Command):
9653    /// reads the one real `projects` dir, refreshes the incremental
9654    /// per-file cache, and rolls the deduped summaries up into the four
9655    /// windows priced from the bundled table. Does blocking file IO, so
9656    /// callers run it off the UI thread.
9657    pub fn scan_usage(&self) -> forge_primitives::token_usage::UsageReport {
9658        use forge_agent::env::{timezone, token_usage};
9659        use time_tz::OffsetDateTimeExt;
9660        // Canonicalize so a symlinked projects tree is read once, not
9661        // once per alias.
9662        let projects_dir = forge_sdk::projects_dir_for(&self.config_dir);
9663        let projects_dir = std::fs::canonicalize(&projects_dir).unwrap_or(projects_dir);
9664        // Resolve the system timezone once so days bucket on the user's
9665        // wall clock, and derive "now" in the same zone for the windows.
9666        let tz = timezone::system_timezone();
9667        let summaries: Vec<_> = token_usage::usage_files(&projects_dir)
9668            .iter()
9669            .filter_map(|path| self.usage_summary_for(path, tz))
9670            .collect();
9671        let pricing = self.load_pricing();
9672        let now = time::OffsetDateTime::now_utc().to_timezone(tz);
9673        token_usage::roll_up(&summaries, &pricing, now)
9674    }
9675
9676    /// Cached summary for `path` when its mtime and size still match,
9677    /// otherwise re-parse (bucketing by `tz`) and refresh the cache.
9678    /// `None` when the file vanished between listing and parsing.
9679    fn usage_summary_for(
9680        &self,
9681        path: &Path,
9682        tz: &time_tz::Tz,
9683    ) -> Option<forge_agent::env::token_usage::FileUsageSummary> {
9684        let key = path.to_string_lossy();
9685        let signature =
9686            std::fs::metadata(path).ok().and_then(|m| Some((m.modified().ok()?, m.len())));
9687        if let Some(mut cached) = self.load_usage_summary(&key)
9688            && signature.is_some_and(|(mtime, size)| cached.mtime == mtime && cached.size == size)
9689        {
9690            // An inactive session's mtime never changes, so a project
9691            // label guessed while its repo was absent would otherwise
9692            // outlive the checkout coming back.
9693            cached.refresh_unresolved_project(path);
9694            return Some(cached);
9695        }
9696        let parsed = forge_agent::env::token_usage::parse_file(path, tz)?;
9697        self.store_usage_summary(&key, &parsed);
9698        Some(parsed)
9699    }
9700
9701    fn load_usage_summary(
9702        &self,
9703        path: &str,
9704    ) -> Option<forge_agent::env::token_usage::FileUsageSummary> {
9705        let guard = self.db.lock();
9706        let db = guard.as_ref()?;
9707        crate::store::token_usage::load(db, path).unwrap_or_else(|error| {
9708            tracing::warn!(
9709                target: "forge_workspace::workspace",
9710                %error,
9711                path = %path,
9712                "loading a usage summary failed",
9713            );
9714            None
9715        })
9716    }
9717
9718    fn store_usage_summary(
9719        &self,
9720        path: &str,
9721        summary: &forge_agent::env::token_usage::FileUsageSummary,
9722    ) {
9723        if let Some(db) = self.db.lock().as_ref()
9724            && let Err(error) = crate::store::token_usage::store(db, path, summary)
9725        {
9726            tracing::warn!(
9727                target: "forge_workspace::workspace",
9728                %error,
9729                path = %path,
9730                "storing a usage summary failed",
9731            );
9732        }
9733    }
9734
9735    /// Refresh the LiteLLM pricing cache when it is absent or older than
9736    /// a day, re-fetching immediately on a missed day. Fire-and-forget
9737    /// from a view: it fetches through the proxy-aware client and is a
9738    /// no-op on any network failure, so the last-good cache is kept.
9739    /// Returns whether a new price table was stored (so a caller can
9740    /// re-price without a redundant scan when nothing changed).
9741    /// The redb read and the ~1.6 MB write run on the blocking pool so
9742    /// the once-a-day fsync can't stall a UI frame.
9743    pub async fn refresh_pricing(self: &Arc<Self>) -> bool {
9744        let fresh = {
9745            let workspace = Arc::clone(self);
9746            tokio::task::spawn_blocking(move || workspace.pricing_is_fresh()).await.unwrap_or_else(
9747                |error| {
9748                    tracing::warn!(
9749                        target: "forge_workspace::workspace",
9750                        %error,
9751                        "pricing freshness-check task failed; treating the cache as stale",
9752                    );
9753                    false
9754                },
9755            )
9756        };
9757        if fresh {
9758            return false;
9759        }
9760        let Some(json) = (self.pricing_fetcher)().await else {
9761            return false;
9762        };
9763        let workspace = Arc::clone(self);
9764        tokio::task::spawn_blocking(move || workspace.store_fresh_pricing(json))
9765            .await
9766            .unwrap_or_else(|error| {
9767                tracing::warn!(
9768                    target: "forge_workspace::workspace",
9769                    %error,
9770                    "pricing store task failed; the cache was not updated",
9771                );
9772                false
9773            })
9774    }
9775
9776    /// Store freshly-fetched pricing json unless it parses to an empty
9777    /// table - a garbage 200 must not wipe a good cache. Returns whether
9778    /// it stored.
9779    fn store_fresh_pricing(&self, json: String) -> bool {
9780        if forge_agent::env::token_usage::pricing::PricingTable::from_litellm_json(&json).is_empty()
9781        {
9782            tracing::warn!(
9783                target: "forge_workspace::workspace",
9784                "fetched pricing parsed to an empty table; keeping the existing cache",
9785            );
9786            return false;
9787        }
9788        self.store_pricing(&crate::store::pricing::CachedPricing {
9789            fetched_at: std::time::SystemTime::now(),
9790            json,
9791        });
9792        true
9793    }
9794
9795    /// The cached pricing, or an empty table before the first fetch
9796    /// lands (a scan answered in the meantime reports tokens with a blank
9797    /// cost).
9798    fn load_pricing(&self) -> forge_agent::env::token_usage::pricing::PricingTable {
9799        use forge_agent::env::token_usage::pricing::PricingTable;
9800        let json = self.load_cached_pricing().map(|cached| cached.json);
9801        PricingTable::from_litellm_json(json.as_deref().unwrap_or("{}"))
9802    }
9803
9804    /// Read the cached pricing snapshot, warning (not swallowing) on a
9805    /// redb or decode error so a corrupt cache is diagnosable.
9806    fn load_cached_pricing(&self) -> Option<crate::store::pricing::CachedPricing> {
9807        let guard = self.db.lock();
9808        let db = guard.as_ref()?;
9809        crate::store::pricing::load(db).unwrap_or_else(|error| {
9810            tracing::warn!(
9811                target: "forge_workspace::workspace",
9812                %error,
9813                "loading the pricing cache failed",
9814            );
9815            None
9816        })
9817    }
9818
9819    /// Whether the cached pricing is younger than the daily refresh
9820    /// window; a missing or older cache is stale and re-fetched.
9821    fn pricing_is_fresh(&self) -> bool {
9822        const REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
9823        self.load_cached_pricing()
9824            .and_then(|cached| cached.fetched_at.elapsed().ok())
9825            .is_some_and(|age| age < REFRESH_INTERVAL)
9826    }
9827
9828    fn store_pricing(&self, entry: &crate::store::pricing::CachedPricing) {
9829        if let Some(db) = self.db.lock().as_ref()
9830            && let Err(error) = crate::store::pricing::store(db, entry)
9831        {
9832            tracing::warn!(
9833                target: "forge_workspace::workspace",
9834                %error,
9835                "storing the pricing cache failed",
9836            );
9837        }
9838    }
9839
9840    /// The canonical model forge stamped for `key`'s session: the
9841    /// project's declared `model`. `None` when the session has no
9842    /// project registration or the project declares no model.
9843    pub(crate) fn canonical_model_for_session(&self, key: &SessionSlot) -> Option<String> {
9844        let project_name = self
9845            .pool
9846            .lock()
9847            .get(key)
9848            .and_then(|entry| entry.registration.as_ref())
9849            .map(|registration| registration.project.clone())?;
9850        self.config_arc()
9851            .projects
9852            .iter()
9853            .find(|project| project.name == project_name)
9854            .and_then(|project| project.model.clone())
9855    }
9856
9857    /// The `/model` picker rows for a session: the declared models of
9858    /// the session's org's accounts, in pin order, deduped.
9859    pub(crate) fn declared_models_for_session(
9860        &self,
9861        key: &SessionSlot,
9862    ) -> Vec<forge_primitives::runtime::AvailableModel> {
9863        let org = self
9864            .pool
9865            .lock()
9866            .get(key)
9867            .and_then(|entry| entry.registration.as_ref())
9868            .map(|registration| registration.org.clone());
9869        let Some(org) = org else {
9870            return Vec::new();
9871        };
9872        // The org's account walk order, from any of its projects
9873        // (the pin is shared across the org).
9874        let mut order: Vec<String> = Vec::new();
9875        let config = self.config_arc();
9876        if let Some(project) = config.projects.iter().find(|p| p.org == org) {
9877            order.extend(project.accounts.iter().cloned());
9878            order.extend(project.fallback_accounts.iter().cloned());
9879        }
9880        let mut seen = std::collections::HashSet::new();
9881        let mut rows = Vec::new();
9882        for name in order {
9883            let Some(account) = config.accounts.iter().find(|a| a.display_name == name) else {
9884                continue;
9885            };
9886            for model in &account.models {
9887                if seen.insert(model.clone()) {
9888                    rows.push(forge_primitives::runtime::AvailableModel::new(
9889                        model.clone(),
9890                        model.clone(),
9891                    ));
9892                }
9893            }
9894        }
9895        rows
9896    }
9897
9898    /// Install a redb store into a test workspace so the durable-vs-
9899    /// ephemeral persistence path is exercisable without `Workspace::new`.
9900    #[cfg(any(test, feature = "test-helpers"))]
9901    pub fn install_db_for_test(&self, db: crate::store::Db) {
9902        *self.db.lock() = Some(db);
9903    }
9904
9905    /// Snapshot every live worker's session_key across every project.
9906    /// Used for worker-key routing that excludes worker buckets
9907    /// from project-row click routing without depending on
9908    /// `list_projects()` for enumeration.
9909    pub fn all_live_worker_session_keys(&self) -> Vec<SessionSlot> {
9910        self.live_workers
9911            .lock()
9912            .values()
9913            .flat_map(|entries| entries.iter().map(|e| e.slot.clone()))
9914            .collect()
9915    }
9916
9917    /// Insert a worker entry into `live_workers[project_key]`.
9918    /// `remove_latest_worker` resolves the single live match. The
9919    /// one-live-worker-per-label invariant is enforced by
9920    /// [`Self::insert_live_worker_if_label_absent`]; this raw push is for
9921    /// callers (tests, re-tag) that already own that guarantee.
9922    pub fn insert_live_worker(
9923        &self,
9924        project_key: &ProjectKey,
9925        entry: crate::mcp::workers::types::WorkerEntry,
9926    ) {
9927        self.live_workers.lock().entry(project_key.clone()).or_default().push(entry);
9928    }
9929
9930    /// Insert `entry` only if no live (non-`Failed`) worker already holds
9931    /// its label in `project_key`, and - when `cap` is `Some` - only if
9932    /// that project's live worker count is under it. Holds
9933    /// `live_workers.lock()` across the label-check, the cap-check AND
9934    /// the push, so two genuinely-concurrent SpawnWorker dispatches (a
9935    /// reconnect re-spawn racing a manual `agents__spawn`, say) can't
9936    /// both pass a check-then-insert window and fork two subprocesses
9937    /// onto one worktree or overshoot the cap. The label check precedes
9938    /// the cap check, so a duplicate-label spawn at the cap reports the
9939    /// collision. `cap: None` is the boot re-spawn exemption. Returns
9940    /// `Ok(())` on insert. This is the sole enforcement point for the
9941    /// at-most-one-live-worker-per-label invariant and the per-project
9942    /// worker cap.
9943    pub fn insert_live_worker_if_label_absent(
9944        &self,
9945        project_key: &ProjectKey,
9946        entry: crate::mcp::workers::types::WorkerEntry,
9947        cap: Option<usize>,
9948    ) -> Result<(), LiveWorkerRefusal> {
9949        // The cleanup marker is read while holding the live registry's lock,
9950        // in the same order `mark_despawn_cleanup_pending` writes it, so a
9951        // spawn cannot pass between "the label is free" and "the cleanup is
9952        // remembered". A despawn marks before it tears its entry down; from
9953        // then until the cleanup ends, this refuses.
9954        let mut workers = self.live_workers.lock();
9955        if self.despawn_cleanup_pending(project_key, &entry.label) {
9956            return Err(LiveWorkerRefusal::CleanupPending);
9957        }
9958        if let Some(existing) = workers.get(project_key).and_then(|entries| {
9959            crate::mcp::workers::types::live_worker_with_label(entries, &entry.label)
9960        }) {
9961            return Err(LiveWorkerRefusal::LabelLive(existing.slot.clone()));
9962        }
9963        if let Some(cap) = cap {
9964            let live = workers
9965                .get(project_key)
9966                .map_or(0, |entries| crate::mcp::workers::types::live_worker_count(entries));
9967            if live >= cap {
9968                return Err(LiveWorkerRefusal::AtCap { live, cap });
9969            }
9970        }
9971        // **The heal's tombstone goes with the spawn that supersedes it.**
9972        // The label gate admits a same-label spawn because a `Restarting`
9973        // entry is not live, while the cap counts that entry; a push beside
9974        // it would count the slot twice, and the heal's timer aborts on the
9975        // now-live label, leaving a tombstone that holds a cap slot for the
9976        // rest of the run. Cleared after the refusals above, so a refused
9977        // spawn never frees the heal's slot.
9978        // **The heal's tombstone goes with the spawn that supersedes it.**
9979        // The label gate admits a same-label spawn because a `Restarting`
9980        // entry is not live, while the cap counts that entry; a push beside
9981        // it would count the slot twice, and the heal's timer aborts on the
9982        // now-live label, leaving a tombstone that holds a cap slot for the
9983        // rest of the run. Cleared after the refusals above, so a refused
9984        // spawn never frees the heal's slot.
9985        workers.entry(project_key.clone()).or_default().retain(|existing| {
9986            existing.slot != entry.slot
9987                || !matches!(existing.status, forge_primitives::WorkerLiveness::Restarting)
9988        });
9989        workers.entry(project_key.clone()).or_default().push(entry);
9990        Ok(())
9991    }
9992
9993    /// Mark `label`'s despawn cleanup as in flight in `project_key`, and
9994    /// hand back the guard that releases it.
9995    ///
9996    /// A despawn takes this BEFORE its teardown, under the live registry's
9997    /// lock: from here until the cleanup ends the label must not read as
9998    /// free to a spawn, because the worktree that cleanup will delete is
9999    /// still on disk. `Drop` does the releasing, so a cleanup that returns
10000    /// early or panics cannot leave the label unspawnable for the run.
10001    pub(crate) fn mark_despawn_cleanup_pending(
10002        self: &Arc<Self>,
10003        project_key: &ProjectKey,
10004        label: &str,
10005    ) -> DespawnCleanupPending {
10006        {
10007            let _live = self.live_workers.lock();
10008            self.despawn_cleanups.lock().insert((project_key.clone(), label.to_owned()));
10009        }
10010        DespawnCleanupPending {
10011            workspace: Arc::clone(self),
10012            project_key: project_key.clone(),
10013            label: label.to_owned(),
10014        }
10015    }
10016
10017    /// Whether `label`'s despawn cleanup is still running in `project_key`.
10018    pub(crate) fn despawn_cleanup_pending(&self, project_key: &ProjectKey, label: &str) -> bool {
10019        self.despawn_cleanups.lock().contains(&(project_key.clone(), label.to_owned()))
10020    }
10021
10022    /// The project's cap-relevant worker count: the same number
10023    /// `insert_live_worker_if_label_absent` enforces against, read for
10024    /// `WorkerFacade::capacity`.
10025    pub fn count_live_workers(&self, project_key: &ProjectKey) -> usize {
10026        self.live_workers
10027            .lock()
10028            .get(project_key)
10029            .map_or(0, |entries| crate::mcp::workers::types::live_worker_count(entries))
10030    }
10031
10032    /// Drop any failed tombstone for `label` before a restore appends its
10033    /// own entry.
10034    ///
10035    /// A failed spawn keeps its entry for a worker that has no worktree, so
10036    /// the failure stays on screen. That entry is not live, so the insert
10037    /// would append a second entry for the same slot, and the by-slot reads
10038    /// take the first match: the tombstone would move as the new session
10039    /// connects while the new entry stayed behind as a phantom. A restore
10040    /// supersedes the failure it replaces.
10041    pub(crate) fn clear_failed_worker(&self, project_key: &ProjectKey, label: &str) {
10042        let mut workers = self.live_workers.lock();
10043        if let Some(entries) = workers.get_mut(project_key) {
10044            entries.retain(|entry| entry.label != label || entry.is_live());
10045        }
10046    }
10047
10048    /// Remove the latest-spawned worker matching `label` from
10049    /// `live_workers[project_key]`. Returns the removed entry, or
10050    /// `None` when no match exists.
10051    pub fn remove_latest_worker(
10052        &self,
10053        project_key: &ProjectKey,
10054        label: &str,
10055    ) -> Option<crate::mcp::workers::types::WorkerEntry> {
10056        let mut map = self.live_workers.lock();
10057        let entries = map.get_mut(project_key)?;
10058        let last_match_idx = entries.iter().rposition(|e| e.label == label)?;
10059        Some(entries.remove(last_match_idx))
10060    }
10061
10062    /// Remove the worker whose `session_key` exactly matches across
10063    /// any project's `live_workers`. Used by the async-spawn-failure
10064    /// path so concurrent same-label spawns don't accidentally
10065    /// roll back the wrong entry (`remove_latest_worker` would peek
10066    /// the wrong one when two workers share a label). Returns the
10067    /// matched `(project_key, entry)` pair, or `None` when no entry
10068    /// matches.
10069    pub fn remove_worker_by_session_key(
10070        &self,
10071        session_key: &SessionSlot,
10072    ) -> Option<(ProjectKey, crate::mcp::workers::types::WorkerEntry)> {
10073        let mut map = self.live_workers.lock();
10074        for (project_key, entries) in map.iter_mut() {
10075            if let Some(idx) = entries.iter().position(|e| e.slot == *session_key) {
10076                let entry = entries.remove(idx);
10077                return Some((project_key.clone(), entry));
10078            }
10079        }
10080        None
10081    }
10082
10083    /// Drain every worker entry for `project_key` and return them in
10084    /// insertion order.
10085    pub fn drain_live_workers(
10086        &self,
10087        project_key: &ProjectKey,
10088    ) -> Vec<crate::mcp::workers::types::WorkerEntry> {
10089        self.live_workers.lock().remove(project_key).unwrap_or_default()
10090    }
10091
10092    /// Locate `(project_key, label, is_git_repo_at_spawn, needs_tag,
10093    /// cwd)` for any worker matching `session_key` across every
10094    /// project's `live_workers`. Used by the Connected handler in
10095    /// `SessionTask::translate_event` to decide whether a just-
10096    /// connected session is a worker (and what tag to write).
10097    /// `is_git_repo_at_spawn` lets the tag-write path route to the
10098    /// worktree-derived JSONL via `worker_tag_dir` in
10099    /// `crate::mcp::workers::types`, and `needs_tag` says whether that
10100    /// write has ever landed - the rollback reads it to tell a worker
10101    /// that never established itself from one that is simply re-tagging.
10102    /// `cwd` is the tree the spawn resolved, which every composed
10103    /// directory reads instead of the project's path.
10104    /// `None` when the session is a lead (or not a worker at all).
10105    pub fn worker_lookup_for_session(
10106        &self,
10107        session_key: &SessionSlot,
10108    ) -> Option<(ProjectKey, String, bool, bool, Option<std::path::PathBuf>)> {
10109        let workers = self.live_workers.lock();
10110        for (project_key, entries) in workers.iter() {
10111            if let Some(entry) = entries.iter().find(|e| e.slot == *session_key) {
10112                return Some((
10113                    project_key.clone(),
10114                    entry.label.clone(),
10115                    entry.is_git_repo_at_spawn,
10116                    entry.needs_tag,
10117                    entry.cwd.clone(),
10118                ));
10119            }
10120        }
10121        None
10122    }
10123
10124    /// The tree a live worker runs in when that is NOT its project's own
10125    /// directory: the root its spawn resolved. `None` for a lead, for a
10126    /// worker in its project's directory, and for a worker this cannot
10127    /// place - so a caller that gets one has a tree to name.
10128    pub fn worker_tree_for_session(&self, session_key: &SessionSlot) -> Option<std::path::PathBuf> {
10129        let (project_key, _label, _is_git, _needs_tag, cwd) =
10130            self.worker_lookup_for_session(session_key)?;
10131        let root = self.live_worker_root(&project_key, cwd.as_deref())?;
10132        let project = self.project_root_for_key(&project_key)?;
10133        // Both sides resolve before they compare: a recorded root is
10134        // canonical, while a declared path may reach the same directory
10135        // through an alias.
10136        let same = match (std::fs::canonicalize(&root), std::fs::canonicalize(&project)) {
10137            (Ok(root), Ok(project)) => root == project,
10138            _ => root == project,
10139        };
10140        (!same).then_some(root)
10141    }
10142
10143    /// What a worker's composed worktree path collides with, when it does:
10144    /// a declared project's own checkout, or the worktree of a worker
10145    /// under another project with the same label.
10146    ///
10147    /// The base is repository-global (#2460), so two projects of one
10148    /// repository - the main checkout and a linked worktree of it, or two
10149    /// linked worktrees - compose the same `<main>/.claude/worktrees/
10150    /// <label>` and the same `worktree-<label>` branch for one label. The
10151    /// one-live-worker guard is per project, so without this the second
10152    /// spawn would run in the first worker's tree and a despawn would
10153    /// remove it; a label naming a checkout parked under `.claude/worktrees`
10154    /// would do the same to that project. A crashed worker's row still
10155    /// claims its path until the row is closed - refusing is the safe
10156    /// direction - and a durable row claims it even with no in-memory
10157    /// entry (after a restart, before that project's lead connects).
10158    /// Callers refuse rather than reuse or remove. A separate forge
10159    /// process is not visible here.
10160    pub(crate) fn worktree_collision(
10161        &self,
10162        path: &std::path::Path,
10163        own: &ProjectKey,
10164        own_label: &str,
10165        own_op: WorktreeOp,
10166    ) -> Option<String> {
10167        let composed = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
10168        if let Some(reason) = self.claim_conflict(&composed, own, own_label, own_op) {
10169            return Some(reason);
10170        }
10171        for view in self.list_projects() {
10172            let declared = std::fs::canonicalize(&view.path).unwrap_or_else(|_| view.path.clone());
10173            if declared == composed {
10174                return Some(format!(
10175                    "'{}' is the checkout of project '{}'; a worker cannot run in, or be \
10176                     removed from, a project's own tree",
10177                    path.display(),
10178                    view.name
10179                ));
10180            }
10181        }
10182        // Snapshot the other projects' workers before composing: the tag
10183        // dir resolves a git root, and no subprocess runs under the
10184        // registry lock.
10185        let others: Vec<(ProjectKey, String, bool, Option<std::path::PathBuf>)> = {
10186            let workers = self.live_workers.lock();
10187            workers
10188                .iter()
10189                .filter(|(key, _)| *key != own)
10190                .flat_map(|(key, entries)| {
10191                    // Every state counts: a crashed worker's row still
10192                    // claims its path until the row is closed.
10193                    entries.iter().map(|entry| {
10194                        (
10195                            key.clone(),
10196                            entry.label.clone(),
10197                            entry.is_git_repo_at_spawn,
10198                            entry.cwd.clone(),
10199                        )
10200                    })
10201                })
10202                .collect()
10203        };
10204        for (key, label, is_git, cwd) in others {
10205            let Some(root) = self.live_worker_root(&key, cwd.as_deref()) else {
10206                continue;
10207            };
10208            let other = crate::mcp::workers::types::worker_tag_dir(&root, &label, is_git);
10209            let other = std::fs::canonicalize(&other).unwrap_or(other);
10210            if other == composed {
10211                let project = self
10212                    .project_identity_for_key(&key)
10213                    .map_or_else(|| key.as_str().to_owned(), |(_, name)| name);
10214                return Some(format!(
10215                    "the worker '{label}' of project '{project}' already holds '{}'",
10216                    path.display()
10217                ));
10218            }
10219        }
10220        // A worker with a durable row but no in-memory entry - after a
10221        // restart, or under a project whose lead has not connected yet -
10222        // still owns its tree on disk. The rows are the same any-state
10223        // rule: an old row claims the path until it is closed.
10224        for view in self.list_projects() {
10225            if view.key == *own {
10226                continue;
10227            }
10228            // A guard that cannot read the rows refuses: an unknown is not
10229            // a free path.
10230            let rows = match self.worker_rows_for_project_strict(&view.key) {
10231                Ok(rows) => rows,
10232                Err(error) => {
10233                    tracing::warn!(
10234                        target: "forge_workspace::workspace",
10235                        event_name = "worktree_collision_rows_unreadable",
10236                        %error,
10237                        project = %view.name,
10238                        path = %path.display(),
10239                        "the project's worker rows could not be read, so the worktree path is \
10240                         refused rather than reused",
10241                    );
10242                    return Some(format!(
10243                        "could not read the worker rows of project '{}' ({error}), so '{}' \
10244                         cannot be known to be free",
10245                        view.name,
10246                        path.display()
10247                    ));
10248                }
10249            };
10250            for row in rows {
10251                if row.is_git_repo == Some(false) {
10252                    // A non-git worker's tree is its project root, never
10253                    // the composed worktree.
10254                    continue;
10255                }
10256                // A row from before the field existed still names the
10257                // path by base+label; refusing on that name is the safe
10258                // direction.
10259                let root =
10260                    row.cwd.as_deref().map_or_else(|| view.path.clone(), std::path::PathBuf::from);
10261                let other = crate::mcp::workers::types::worker_tag_dir(&root, &row.label, true);
10262                let other = std::fs::canonicalize(&other).unwrap_or(other);
10263                if other == composed {
10264                    return Some(format!(
10265                        "the worker '{}' of project '{}' already holds '{}'",
10266                        row.label,
10267                        view.name,
10268                        path.display()
10269                    ));
10270                }
10271            }
10272        }
10273        None
10274    }
10275
10276    /// Claim the composed worktree path for this (project, label) while a
10277    /// spawn's ensure/insert or a despawn's removal acts on it.
10278    ///
10279    /// Re-entrant for the same owner: a resume's pre-ensure claim and the
10280    /// core handler's claim are one spawn. Refused when another project
10281    /// or label holds it, which is the repository-wide half of the
10282    /// one-live rule - the live registry is per project, and two projects
10283    /// of one repository compose the same path.
10284    pub(crate) fn claim_worktree(
10285        &self,
10286        path: &std::path::Path,
10287        project_key: &ProjectKey,
10288        label: &str,
10289        op: WorktreeOp,
10290    ) -> Result<WorktreeClaimGuard, String> {
10291        let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
10292        {
10293            let mut claims = self.worktree_claims.lock();
10294            match claims.get_mut(&path) {
10295                Some(claim)
10296                    if claim.project_key == *project_key
10297                        && claim.label == label
10298                        && claim.op == op =>
10299                {
10300                    claim.count += 1;
10301                }
10302                Some(claim) => {
10303                    return Err(self.claim_message(claim, &path));
10304                }
10305                None => {
10306                    claims.insert(
10307                        path.clone(),
10308                        WorktreeClaim {
10309                            project_key: project_key.clone(),
10310                            label: label.to_owned(),
10311                            op,
10312                            count: 1,
10313                        },
10314                    );
10315                }
10316            }
10317        }
10318        Ok(WorktreeClaimGuard { claims: Arc::clone(&self.worktree_claims), path })
10319    }
10320
10321    /// What a composed path is claimed by, when a seat other than
10322    /// `own`/`own_label` on the SAME operation holds it. A different
10323    /// operation on the same owner and label is a conflict: a despawn must
10324    /// not enter a resume's claim.
10325    fn claim_conflict(
10326        &self,
10327        composed: &std::path::Path,
10328        own: &ProjectKey,
10329        own_label: &str,
10330        own_op: WorktreeOp,
10331    ) -> Option<String> {
10332        let claims = self.worktree_claims.lock();
10333        let claim = claims.get(composed)?;
10334        if claim.project_key == *own && claim.label == own_label && claim.op == own_op {
10335            return None;
10336        }
10337        Some(self.claim_message(claim, composed))
10338    }
10339
10340    fn claim_message(&self, claim: &WorktreeClaim, path: &std::path::Path) -> String {
10341        let project = self
10342            .project_identity_for_key(&claim.project_key)
10343            .map_or_else(|| claim.project_key.as_str().to_owned(), |(_, name)| name);
10344        match claim.op {
10345            WorktreeOp::Spawn => format!(
10346                "the worktree '{}' is in use by the worker '{}' of project '{project}'",
10347                path.display(),
10348                claim.label
10349            ),
10350            WorktreeOp::Despawn => format!(
10351                "the worker '{}' of project '{project}' is still cleaning up the worktree '{}'",
10352                claim.label,
10353                path.display()
10354            ),
10355        }
10356    }
10357
10358    /// What a composed path is held by, when it is held by anything other
10359    /// than a spawn of `own`/`own_label`: an in-flight claim of another
10360    /// owner or operation, or a live seat of ANY project (a same-project
10361    /// winner of a spawn race included). Used by a rollback deciding
10362    /// whether a tree it minted is still its own.
10363    pub(crate) fn worktree_held_elsewhere(
10364        &self,
10365        path: &std::path::Path,
10366        own: &ProjectKey,
10367        own_label: &str,
10368    ) -> Option<String> {
10369        let composed = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
10370        if let Some(reason) = self.claim_conflict(&composed, own, own_label, WorktreeOp::Spawn) {
10371            return Some(reason);
10372        }
10373        let seats: Vec<(ProjectKey, String, bool, Option<std::path::PathBuf>)> = {
10374            let workers = self.live_workers.lock();
10375            workers
10376                .iter()
10377                .flat_map(|(key, entries)| {
10378                    entries
10379                        .iter()
10380                        .filter(|entry| entry.is_live())
10381                        .map(|entry| {
10382                            (
10383                                key.clone(),
10384                                entry.label.clone(),
10385                                entry.is_git_repo_at_spawn,
10386                                entry.cwd.clone(),
10387                            )
10388                        })
10389                        .collect::<Vec<_>>()
10390                })
10391                .collect()
10392        };
10393        for (key, label, is_git, cwd) in seats {
10394            let Some(root) = self.live_worker_root(&key, cwd.as_deref()) else {
10395                continue;
10396            };
10397            let other = crate::mcp::workers::types::worker_tag_dir(&root, &label, is_git);
10398            let other = std::fs::canonicalize(&other).unwrap_or(other);
10399            if other == composed {
10400                let project = self
10401                    .project_identity_for_key(&key)
10402                    .map_or_else(|| key.as_str().to_owned(), |(_, name)| name);
10403                return Some(format!(
10404                    "the worktree '{}' is in use by the worker '{label}' of project '{project}'",
10405                    path.display()
10406                ));
10407            }
10408        }
10409        None
10410    }
10411
10412    /// The tree a live worker's directory composes against: the one its
10413    /// spawn resolved, or the project's own `forge.toml` path when the
10414    /// spawn stated none. `None` when the session is not a live worker,
10415    /// or its project is no longer loaded.
10416    fn live_worker_root(
10417        &self,
10418        project_key: &ProjectKey,
10419        cwd: Option<&std::path::Path>,
10420    ) -> Option<std::path::PathBuf> {
10421        match cwd {
10422            Some(root) => Some(root.to_path_buf()),
10423            None => self.project_root_for_key(project_key),
10424        }
10425    }
10426
10427    /// The working directory forge holds for `session_key`. Two
10428    /// sources, in order:
10429    /// 1. The sessions catalog (`session_cwd_for`). Leads only: the
10430    ///    boot scan hides worker-tagged sessions and the Connected
10431    ///    handler skips the catalog mirror for workers, so a worker
10432    ///    never has a row to read.
10433    /// 2. The worker registry (`live_workers` via
10434    ///    `worker_lookup_for_session`), composed by `worker_tag_dir`
10435    ///    against the tree the worker's spawn resolved - its own root
10436    ///    when it has one, the project's `forge.toml` path otherwise -
10437    ///    the worktree for a git worker, that root itself otherwise.
10438    ///    This is the authoritative source for every worker.
10439    ///
10440    /// `None` leaves the caller to decide what an unknown cwd means:
10441    /// `resume_cwd_for_slot` hands claude an empty cwd, while the
10442    /// review MCP reports `SessionCwdUnknown` to the caller.
10443    pub fn cwd_for_session(&self, session_key: &SessionSlot) -> Option<String> {
10444        if let Some(cwd) = self.session_cwd_for(session_key) {
10445            return Some(cwd);
10446        }
10447        let (project_key, label, is_git, _, cwd) = self.worker_lookup_for_session(session_key)?;
10448        let Some(root) = self.live_worker_root(&project_key, cwd.as_deref()) else {
10449            // Unreachable while `forge.toml` and `live_workers` agree,
10450            // so treat a firing as drift rather than a normal miss.
10451            tracing::warn!(
10452                target: "forge_workspace::workspace",
10453                event_name = "session_cwd_registry_contradiction",
10454                slot = %session_key.display(),
10455                project_key = project_key.as_str(),
10456                worker_label = %label,
10457                "worker registry resolves this session but no loaded project matches its \
10458                 project_key, so no cwd can be composed",
10459            );
10460            return None;
10461        };
10462        Some(
10463            crate::mcp::workers::types::worker_tag_dir(&root, &label, is_git)
10464                .to_string_lossy()
10465                .into_owned(),
10466        )
10467    }
10468
10469    /// The extra document roots the seat's project declares: its
10470    /// `document_roots` entries, `~` expanded at load. Empty for a project
10471    /// that declares none, and for a slot whose project is not loaded.
10472    pub fn document_roots_for_session(&self, session_key: &SessionSlot) -> Vec<PathBuf> {
10473        self.project_for_slot(session_key).map(|project| project.document_roots).unwrap_or_default()
10474    }
10475
10476    /// The cwd to pass `claude --resume` for the session at
10477    /// `session_key`: [`Self::cwd_for_session`], or an empty string
10478    /// when forge holds none (pass through and let the bridge surface
10479    /// ConnectionFailed - the session can't be resumed cleanly anyway).
10480    ///
10481    /// `claude --resume` does NOT receive a `--worktree` flag (see
10482    /// `SessionLaunchSettings::extra_args` in
10483    /// `forge-agent/src/client.rs` - lead/resume paths leave
10484    /// extra_args empty), so the subprocess cwd is the ONLY signal
10485    /// claude uses to derive the JSONL location. Handing a git
10486    /// worker just its project root makes claude look under the
10487    /// project's sanitised dir, miss the worker JSONL (which lives
10488    /// under the worktree's sanitised dir), and exit with "No
10489    /// conversation found with session ID:" (#245 Layer B).
10490    pub(crate) fn resume_cwd_for_slot(&self, session_key: &SessionSlot) -> String {
10491        self.cwd_for_session(session_key).unwrap_or_else(|| {
10492            tracing::warn!(
10493                target: "forge_workspace::workspace",
10494                slot = %session_key.display(),
10495                "resume_cwd_for_slot: no catalog cwd and no live worker entry; \
10496                 passing empty cwd to claude (resume will fail with ConnectionFailed)",
10497            );
10498            String::new()
10499        })
10500    }
10501
10502    /// Look up a project root path by its `ProjectKey`. Searches
10503    /// the test overlay first (under `cfg(test)` / `feature =
10504    /// "testing"`), then `config.projects`. Returns `None` when no
10505    /// loaded project's path canonicalises to the given key.
10506    ///
10507    /// Used by [`Self::git_scan_cwd_for_session`] and
10508    /// [`Self::resume_cwd_for_slot`] to derive the project root
10509    /// from a worker's project_key without depending on the worker's
10510    /// `cwd_raw` value (which carries the project root for fresh
10511    /// spawns and the worktree path for resumed sessions - the two
10512    /// cases would otherwise need different composition logic).
10513    pub(crate) fn project_root_for_key(&self, target: &ProjectKey) -> Option<std::path::PathBuf> {
10514        self.project_for_key(target).map(|p| p.path)
10515    }
10516
10517    /// The project whose path canonicalises to `target`. Consults the
10518    /// test overlay, like every other project lookup - a resolution
10519    /// that saw only `config.projects` would return `None` for a seeded
10520    /// project and make an absence assertion pass vacuously.
10521    /// The registry key for a project, resolved from its `forge.toml`
10522    /// name. `None` when the config no longer names it.
10523    pub(crate) fn project_key_for_name(&self, name: &str) -> Option<ProjectKey> {
10524        let project = self.find_project_view_by_name(name)?;
10525        Some(ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(
10526            Some(&project.path.to_string_lossy()),
10527        )))
10528    }
10529
10530    pub(crate) fn project_for_key(&self, target: &ProjectKey) -> Option<LoadedProject> {
10531        let derive_key = |project: &LoadedProject| {
10532            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
10533                &project.path.to_string_lossy(),
10534            )))
10535        };
10536        #[cfg(any(test, feature = "testing"))]
10537        {
10538            if let Some(project) =
10539                self.test_extra_projects.lock().iter().find(|p| &derive_key(p) == target).cloned()
10540            {
10541                return Some(project);
10542            }
10543        }
10544        // `None` when two projects share the key rather than the first
10545        // match: the key sanitises away punctuation and resolves
10546        // symlinks, so one repo declared under two org scopes collides.
10547        // Picking either would hand one project's env to the other's
10548        // sessions; no project env is the failure that cannot leak.
10549        let config = self.config_arc();
10550        let mut matches = config.projects.iter().filter(|p| &derive_key(p) == target);
10551        let first = matches.next()?;
10552        if matches.next().is_some() {
10553            tracing::warn!(
10554                target: "forge_workspace::workspace",
10555                event_name = "project_key_ambiguous",
10556                project_key = target.as_str(),
10557                "two projects resolve to this session-storage key, so neither project's \
10558                 settings are applied - give them distinct paths, or merge the entries if they \
10559                 are the same directory declared twice",
10560            );
10561            return None;
10562        }
10563        Some(first.clone())
10564    }
10565
10566    /// Resolve the cwd a git-diff scan should run against for the
10567    /// session at `session_key`. Workers spawned in a git tree run
10568    /// inside claude's `--worktree <label>` fork at
10569    /// `<base>/.claude/worktrees/<label>/` (`base` the canonical root of
10570    /// the tree the worker's spawn resolved - its own when it has one, the
10571    /// project's `forge.toml` path otherwise), and the worker's `cwd_raw`
10572    /// carries different values depending on lifecycle:
10573    /// - fresh spawn -> `cwd_raw = <root>` (the value claude
10574    ///   sends in `AgentEvent::Connected.cwd` before it chdirs into
10575    ///   the worktree),
10576    /// - resumed session -> `cwd_raw = <base>/.claude/worktrees/<label>`
10577    ///   (claude chdirs before writing the first catalog row, so the
10578    ///   resume path reads the worktree path back as `cwd`).
10579    ///
10580    /// Anchor the composition on the worker's own tree (via
10581    /// [`Self::worker_lookup_for_session`] + the internal
10582    /// `live_worker_root` lookup) rather than `cwd_raw` so both
10583    /// lifecycle states resolve to the same final path. For non-
10584    /// worker sessions (project leads), non-git workers, or workers
10585    /// whose root can't be resolved, returns `cwd_raw` unchanged.
10586    pub fn git_scan_cwd_for_session(
10587        &self,
10588        session_key: &SessionSlot,
10589        cwd_raw: &std::path::Path,
10590    ) -> std::path::PathBuf {
10591        let Some((project_key, label, is_git_repo_at_spawn, _, cwd)) =
10592            self.worker_lookup_for_session(session_key)
10593        else {
10594            // Trace-level so a real lookup-miss (race during
10595            // worker spawn, lead-session call) leaves a grep-able
10596            // trail without flooding normal logs. The lead-session
10597            // case is the common path and intentionally not
10598            // promoted higher.
10599            tracing::trace!(
10600                target: "forge_workspace::git_scan",
10601                slot = %session_key.display(),
10602                "no worker lookup; using cwd_raw unchanged"
10603            );
10604            return cwd_raw.to_path_buf();
10605        };
10606        if !is_git_repo_at_spawn {
10607            // Non-git workers don't fork into a worktree; they run
10608            // in their tree itself, so `cwd_raw` is already
10609            // the correct scan target.
10610            return cwd_raw.to_path_buf();
10611        }
10612        let Some(root) = self.live_worker_root(&project_key, cwd.as_deref()) else {
10613            // Root lookup miss is structurally unusual (a worker
10614            // entry exists but its project_key doesn't resolve to a
10615            // loaded project) - log so a regression on the
10616            // forge.toml refresh path is visible, then fall back to
10617            // cwd_raw rather than synthesise a wrong path.
10618            tracing::warn!(
10619                target: "forge_workspace::git_scan",
10620                slot = %session_key.display(),
10621                project_key = project_key.as_str(),
10622                "worker entry present but its tree could not be resolved; falling back to cwd_raw"
10623            );
10624            return cwd_raw.to_path_buf();
10625        };
10626        crate::mcp::workers::types::worker_tag_dir(&root, &label, true)
10627    }
10628
10629    /// Handle an async worker-spawn failure. Two paths, branching
10630    /// on the classifier outcome (#245 Layer C):
10631    ///
10632    /// - **Worktree-creation failure** (the worker never actually
10633    ///   started because git worktree setup failed): roll back the
10634    ///   `WorkerEntry` (the worker doesn't exist; the user-visible
10635    ///   signal is a typed notice routed to the lead's chat) and
10636    ///   emit `WorkerStatusChanged { Removed }`. Mirrors the sync
10637    ///   rollback in `handle_spawn_worker`.
10638    /// - **Any other failure** (resume against missing JSONL,
10639    ///   generic dispatch error, claude subprocess exit, etc.):
10640    ///   keep the `WorkerEntry` and transition it to
10641    ///   [`WorkerLiveness::Failed`](forge_primitives::WorkerLiveness::Failed)
10642    ///   with the first line of the message as the diagnostic. The Projects
10643    ///   pane renders the worker as a red `✕` with a DIM sub-row carrying the
10644    ///   diagnostic, so a stuck-Spawning-forever case becomes visible instead
10645    ///   of silently disappearing.
10646    ///
10647    /// The classifier uses `is_git_repo_at_spawn` + substring match
10648    /// against the bridge-wrapped message; see
10649    /// [`classify_worker_spawn_failure`] for the routing rules and
10650    /// the bridge-prefix contract.
10651    ///
10652    /// Returns `true` when the caller IS a worker (so it knows the
10653    /// failure was consumed here - the existing `ConnectionFailed`
10654    /// emission still fires for the view side). `false` for lead
10655    /// sessions or any other non-worker, in which case the caller's
10656    /// existing behaviour proceeds unchanged.
10657    ///
10658    /// [`classify_worker_spawn_failure`]: crate::mcp::workers::facade::classify_worker_spawn_failure
10659    pub(crate) fn handle_async_worker_spawn_failure(
10660        self: &Arc<Self>,
10661        session_key: &SessionSlot,
10662        message: &str,
10663        kind: forge_agent::client::SpawnFailureKind,
10664    ) -> bool {
10665        // Look up the worker entry WITHOUT removing it yet - the
10666        // worktree-failure path still removes (rollback semantics
10667        // for "worker never existed"); the general-failure path
10668        // transitions to Failed so the user sees what happened.
10669        //
10670        // A failed spawn reports the key it ran under, which is the one
10671        // its entry holds: every worker spawn, fresh or resumed, hands
10672        // both the same string.
10673        let Some((project_key, entry)) = ({
10674            let workers = self.live_workers.lock();
10675            workers.iter().find_map(|(project_key, entries)| {
10676                entries
10677                    .iter()
10678                    .find(|e| e.slot == *session_key)
10679                    .map(|entry| (project_key.clone(), entry.clone()))
10680            })
10681        }) else {
10682            return false;
10683        };
10684        // A payload parked for this worker's label has no session left to
10685        // drain it, so its bucket goes with the spawn.
10686        self.expire_parked_for_slot(
10687            &entry.slot,
10688            crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
10689        );
10690        // Classify against the entry's recorded is_git_repo_at_spawn
10691        // flag - same heuristic the sync agents__spawn path uses.
10692        let classified = crate::mcp::workers::facade::classify_worker_spawn_failure(
10693            message,
10694            entry.is_git_repo_at_spawn,
10695            kind,
10696        );
10697        if let crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { reason } =
10698            &classified
10699        {
10700            // Worktree-creation failure: roll back the worker entry
10701            // (the worker never existed; the user-visible signal is
10702            // the typed notice routed to the lead, not the worker row).
10703            self.remove_worker_by_session_key(&entry.slot);
10704            // The failure record goes with the row this arm deletes, or the
10705            // slot reads Failed with nothing left to clear it.
10706            self.clear_spawn_failure(session_key);
10707            // Same release the tag-rollback arm runs: without it the
10708            // pool entry + command sender + domain handle + SessionTask
10709            // leak per failed fresh spawn, unbounded across retries.
10710            self.release_session(session_key);
10711            // A dynamic worker persisted its row on the optimistic spawn
10712            // reply, before this async failure. A worktree-creation
10713            // failure is a hard removal (the worker never started), so
10714            // delete the row too - otherwise it zombie-re-spawns every
10715            // restart despite a visibly-failed spawn. The
10716            // transition-to-Failed path below
10717            // deliberately keeps the row: a Failed-but-visible worker
10718            // wasn't despawned, so it should re-spawn to recover or
10719            // re-fail visibly.
10720            let _ = self.delete_worker_row(&project_key, &entry.label);
10721            // Notice goes to the lead session that spawned this
10722            // worker. Use the workspace's update channel + a fresh
10723            // Command::Prompt so the lead's claude subprocess sees
10724            // the envelope as a user turn and the view render path
10725            // picks up the bracketed prefix via forge_server::envelope::detect_inbound.
10726            let lead_slot = entry.spawned_by.clone();
10727            let pool_has_lead = self.pool.lock().contains_key(&lead_slot);
10728            if pool_has_lead {
10729                let wrapped = WrappedPrompt {
10730                    id: MessageId::mint(),
10731                    kind: WrappedKind::WorkerSpawnFailedNotice,
10732                    sender_name: entry.label.clone(),
10733                    sender_org: String::new(),
10734                    body: reason.clone(),
10735                };
10736                let uuid = forge_sdk::request_id::next_prompt_id();
10737                if let Err(err) = self.dispatch_workspace_prompt_under(
10738                    &lead_slot,
10739                    wrapped.to_prose(),
10740                    PromptSource::Peer,
10741                    uuid.clone(),
10742                ) {
10743                    tracing::warn!(
10744                        target: "forge_workspace::worker_async_failure",
10745                        project = %project_key.as_str(),
10746                        label = %entry.label,
10747                        error = ?err,
10748                        "WorkerSpawnFailedNotice dispatch to lead failed",
10749                    );
10750                }
10751                // And the visible echo, the way every other injected prompt
10752                // paints one: the CLI does not echo a prompt it was handed on
10753                // stdin, so a view that only drew the wire would show nothing
10754                // until the page reloaded.
10755                crate::spawn::push_peer_user_turn_into_chat(self, &lead_slot, &wrapped, &uuid);
10756            } else {
10757                tracing::warn!(
10758                    target: "forge_workspace::worker_async_failure",
10759                    project = %project_key.as_str(),
10760                    label = %entry.label,
10761                    slot = %lead_slot.display(),
10762                    "worker spawn failed but lead session is gone; dropping notice",
10763                );
10764            }
10765            // Emit WorkerStatusChanged::Removed - parity with the
10766            // sync rollback in handle_spawn_worker.
10767            let _ = self.update_tx.send(SessionUpdate::WorkerStatusChanged {
10768                project_key,
10769                action: crate::protocol::WorkerStatusAction::Removed,
10770                status: entry.to_status(),
10771                // Creating the worktree is what failed, and nothing
10772                // cleans up a partial one, so Absent is the only claim
10773                // that holds either way.
10774                worktree: crate::protocol::WorktreeDisposition::Absent,
10775            });
10776        } else {
10777            // Non-worktree failure (resume not found, generic
10778            // ConnectionFailed, etc.): transition to Failed via
10779            // Layer A's machinery. The worker entry stays visible
10780            // in `live_workers` + the Projects pane renders it as
10781            // `✕` with the captured message as the diagnostic
10782            // sub-row. Without this, the worker would vanish (as
10783            // it did pre-#245) and the user would be left wondering
10784            // why a team worker disappeared mid-flight.
10785            let diagnostic = message.lines().next().map(str::to_owned);
10786            transition_worker_to_failed(self, &project_key, &entry.slot, diagnostic);
10787        }
10788        true
10789    }
10790
10791    /// Mark a git worker Failed and release its session when the worktree its
10792    /// connect is about to run in is gone or unusable. Answers whether it
10793    /// did, so the first-connect branch can stop before it kicks the worker,
10794    /// announces it live or hands it parked prompts (#1593).
10795    ///
10796    /// The path composes from the row's own root, never the connect cwd: a
10797    /// resumed worker connects with its worktree as cwd, and composing from
10798    /// that doubled the path in the reason.
10799    pub(crate) fn fail_worker_without_worktree(self: &Arc<Self>, slot: &SessionSlot) -> bool {
10800        let Some((project_key, label, is_git, _, root)) = self.worker_lookup_for_session(slot)
10801        else {
10802            return false;
10803        };
10804        if !is_git {
10805            return false;
10806        }
10807        let Some(root) = self.live_worker_root(&project_key, root.as_deref()) else {
10808            return false;
10809        };
10810        let path = crate::mcp::workers::types::worker_tag_dir(&root, &label, true);
10811        let Some(reason) = forge_agent::env::worktree::worktree_missing_reason(&path) else {
10812            return false;
10813        };
10814        tracing::warn!(
10815            target: "forge_workspace::workspace",
10816            event_name = "worker_worktree_gone_at_connect",
10817            slot = %slot.display(),
10818            label = %label,
10819            worktree = %path.display(),
10820            "the worker's worktree is not usable at connect; the worker is marked failed",
10821        );
10822        transition_worker_to_failed(self, &project_key, slot, Some(reason));
10823        // A Failed row means nothing is running: every other Failed
10824        // transition releases the session, and a pooled handle here would let
10825        // a later re-spawn fast-path a child under an id the row no longer
10826        // means.
10827        self.release_session(slot);
10828        true
10829    }
10830
10831    /// Tag a worker session's JSONL with `forge:worker:<label>` and
10832    /// transition its `WorkerEntry` from `Spawning` to `Running`.
10833    /// Called from `SessionTask::translate_event` on the worker's first
10834    /// `Connected`.
10835    ///
10836    /// `cwd` is the tree the worker runs in (the `directory`
10837    /// discriminator `tag_session` uses to find the right `.jsonl` when
10838    /// CONFIG_DIR hosts multiple project directories).
10839    ///
10840    /// The tag-write races against claude CLI's first JSONL write. For
10841    /// workers spawned with an `initial_prompt`, the file lands within
10842    /// ~100 ms-2 s of `Connected`, so a 30 x 100 ms retry loop reliably
10843    /// catches it. For idle-spawned workers (no prompt) claude doesn't
10844    /// create the JSONL at all until the first user turn arrives later,
10845    /// so the retry will exhaust with `Io(NotFound)`. That's NOT a
10846    /// rollback condition - we keep the worker live (it's fully
10847    /// functional from `live_workers`), transition to `Running`, mark
10848    /// the entry `needs_tag = true`, and emit `StatusChanged`. The
10849    /// opportunistic retry on first `DeliverWorkerPrompt`
10850    /// (see `spawn::handle_deliver_worker_prompt`) catches it once
10851    /// claude is processing the turn.
10852    ///
10853    /// Other errors (permission denied, disk full, invalid UUID) still
10854    /// indicate the worker can't be properly tracked on disk, so we
10855    /// roll back: remove from `live_workers`, release the session, emit
10856    /// a `Removed` status event.
10857    ///
10858    /// The tag-write runs in a detached tokio task to keep
10859    /// `translate_event` synchronous.
10860    ///
10861    /// Idempotent - calling twice for the same session_key is a no-op
10862    /// if the entry is already `Running`.
10863    pub(crate) fn apply_worker_tag_or_rollback(
10864        self: &Arc<Self>,
10865        session_key: &SessionSlot,
10866        session_id: &str,
10867        cwd: &str,
10868        minted_row: bool,
10869    ) {
10870        let Some((project_key, label, is_git_repo_at_spawn, needs_tag, _)) =
10871            self.worker_lookup_for_session(session_key)
10872        else {
10873            return;
10874        };
10875        // Resolve the config_dir for this session via the bridge so
10876        // the tag-write lands under the workspace's projects/ tree.
10877        let Some(config_dir) = self.config_dir_for(session_key) else {
10878            tracing::warn!(
10879                target: "forge_workspace::workspace",
10880                slot = %session_key.display(),
10881                "apply_worker_tag: no agent registered; cannot resolve config_dir"
10882            );
10883            return;
10884        };
10885        self.apply_worker_tag_or_rollback_with_config_dir(
10886            session_key,
10887            session_id,
10888            &project_key,
10889            &label,
10890            cwd,
10891            is_git_repo_at_spawn,
10892            needs_tag,
10893            minted_row,
10894            &config_dir,
10895        );
10896    }
10897
10898    /// Testable inner of [`Self::apply_worker_tag_or_rollback`] that
10899    /// takes the resolved `config_dir` directly. The production caller
10900    /// resolves via `config_dir_for`; unit tests pass a tempdir to
10901    /// exercise the retry / rollback / deferred branches without
10902    /// having to register a full `AgentHandle`.
10903    ///
10904    /// `cwd` is the tree this worker runs in - the root its spawn
10905    /// resolved, which is the project's `forge.toml` path for a worker
10906    /// that stated none. For git-repo workers
10907    /// (`is_git_repo_at_spawn = true`) the tag-write is routed to
10908    /// `<base>/.claude/worktrees/<label>` via
10909    /// [`crate::mcp::workers::types::worker_tag_dir`], `base` the CLI's
10910    /// main working tree, so the lookup matches where claude's
10911    /// `--worktree <label>` actually wrote the JSONL.
10912    ///
10913    /// `needs_tag` and `minted_row` together decide whether a rollback
10914    /// takes the worker's durable row with it; see the arm below.
10915    pub(crate) fn apply_worker_tag_or_rollback_with_config_dir(
10916        self: &Arc<Self>,
10917        session_key: &SessionSlot,
10918        session_id: &str,
10919        project_key: &ProjectKey,
10920        label: &str,
10921        cwd: &str,
10922        is_git_repo_at_spawn: bool,
10923        needs_tag: bool,
10924        minted_row: bool,
10925        config_dir: &std::path::Path,
10926    ) {
10927        let effective_cwd = crate::mcp::workers::types::worker_tag_dir(
10928            std::path::Path::new(cwd),
10929            label,
10930            is_git_repo_at_spawn,
10931        );
10932        // **The spawn checked the path before claude created it; nothing
10933        // read it again.** A worktree removed before the session connected
10934        // otherwise left the worker reading live with nowhere to work
10935        // (#1593). That read is `fail_worker_without_worktree`'s, on the
10936        // first-connect path before this tag write runs.
10937        self.apply_worker_tag_or_rollback_with_budget(
10938            session_key,
10939            session_id,
10940            project_key,
10941            label,
10942            &effective_cwd,
10943            WorkerTagFacts { needs_tag, minted_row },
10944            config_dir,
10945            WorkerTagRetryBudget {
10946                max_attempts: WORKER_TAG_RETRY_ATTEMPTS,
10947                delay: WORKER_TAG_RETRY_DELAY,
10948            },
10949        );
10950    }
10951
10952    /// [`Self::apply_worker_tag_or_rollback_with_config_dir`] with the
10953    /// worktree-aware tag directory and the retry budget both injected,
10954    /// so a test can shrink the detached task's wall time and derive its
10955    /// own wait from the same numbers the task runs on.
10956    pub(crate) fn apply_worker_tag_or_rollback_with_budget(
10957        self: &Arc<Self>,
10958        session_key: &SessionSlot,
10959        session_id: &str,
10960        project_key: &ProjectKey,
10961        label: &str,
10962        effective_cwd: &std::path::Path,
10963        facts: WorkerTagFacts,
10964        config_dir: &std::path::Path,
10965        budget: WorkerTagRetryBudget,
10966    ) {
10967        use tracing::Instrument;
10968        let WorkerTagFacts { needs_tag, minted_row } = facts;
10969        let workspace = Arc::clone(self);
10970        let project_key = project_key.clone();
10971        let session_key = session_key.clone();
10972        let session_id = session_id.to_owned();
10973        let label = label.to_owned();
10974        let effective_cwd = effective_cwd.to_path_buf();
10975        let config_dir = config_dir.to_path_buf();
10976        let span = tracing::info_span!(
10977            "forge_workspace::worker_tag_write",
10978            slot = %session_key.display(),
10979            label = %label,
10980        );
10981        let Some((org, name)) = self.project_identity_for_key(&project_key) else {
10982            tracing::warn!(
10983                target: "forge_workspace::workspace",
10984                event_name = "worker_tag_project_unknown",
10985                project = %project_key.as_str(),
10986                label = %label,
10987                "the worker's project is no longer configured, so its transcript is not tagged; \
10988                 an unscoped row would be another project's to adopt",
10989            );
10990            return;
10991        };
10992        let project = format!("{org}/{name}");
10993        tokio::spawn(async move {
10994            let tag = forge_primitives::worker_tag(&label);
10995            let result = tag_session_with_retry(
10996                &config_dir,
10997                &session_id,
10998                &tag,
10999                &project,
11000                &effective_cwd.to_string_lossy(),
11001                budget.max_attempts,
11002                budget.delay,
11003            )
11004            .await;
11005            match result {
11006                Ok(()) => {
11007                    transition_worker_to_running(
11008                        &workspace,
11009                        &project_key,
11010                        &session_key,
11011                        TagWriteResult::Succeeded,
11012                    );
11013                }
11014                Err(forge_sdk::Error::Io(io_err))
11015                    if io_err.kind() == std::io::ErrorKind::NotFound =>
11016                {
11017                    tracing::warn!(
11018                        target: "forge_workspace::workspace",
11019                        slot = %session_key.display(),
11020                        label = %label,
11021                        "tag_session_deferred: JSONL not yet on disk after retries; worker stays Running, tag will retry on first turn"
11022                    );
11023                    transition_worker_to_running(
11024                        &workspace,
11025                        &project_key,
11026                        &session_key,
11027                        TagWriteResult::DeferredNotFound,
11028                    );
11029                }
11030                Err(err) => {
11031                    tracing::warn!(
11032                        target: "forge_workspace::workspace",
11033                        slot = %session_key.display(),
11034                        label = %label,
11035                        error = ?err,
11036                        "tag_session failed for worker (non-NotFound); rolling back spawn"
11037                    );
11038                    let removed = workspace.remove_latest_worker(&project_key, &label);
11039                    if let Some(entry) = removed {
11040                        // The row goes with the worker only when all
11041                        // three hold: this connection is the first of a
11042                        // spawn that minted the row, and the worker never
11043                        // got as far as a tag.
11044                        //
11045                        // Every other shape of this arm - a resume, a
11046                        // boot re-spawn, a `/new` re-tag - runs over a
11047                        // row that pre-existed and holds the worker's
11048                        // charter, kick and the id being resumed, and the
11049                        // row is the worker rather than this spawn's
11050                        // leftover: deleting it loses a worker that only
11051                        // failed to write a JSONL tag. The `/new` case is
11052                        // why `minted_row` carries the first-connect half:
11053                        // a `/new` reuses this task and its domain, so the
11054                        // spawn's provenance is still stamped there, and
11055                        // only the Connected count tells the two apart.
11056                        //
11057                        // `needs_tag` is what separates those from the
11058                        // case this arm exists for. The spawn sets it and
11059                        // the first successful tag write clears it, so a
11060                        // row still carrying it belongs to a worker that
11061                        // never established itself on disk.
11062                        if minted_row && needs_tag {
11063                            let _ = workspace.delete_worker_row(&project_key, &label);
11064                        }
11065                        let worktree = crate::protocol::WorktreeDisposition::untouched(
11066                            entry.is_git_repo_at_spawn,
11067                        );
11068                        let _ = workspace.update_tx.send(SessionUpdate::WorkerStatusChanged {
11069                            project_key,
11070                            action: crate::protocol::WorkerStatusAction::Removed,
11071                            status: entry.to_status(),
11072                            worktree,
11073                        });
11074                    }
11075                    workspace.release_session(&session_key);
11076                }
11077            }
11078        }
11079        .instrument(span));
11080    }
11081
11082    /// Opportunistically retry the JSONL tag-write for a worker whose
11083    /// `needs_tag` flag is set. Called by
11084    /// `spawn::handle_deliver_worker_prompt` when a `DeliverWorkerPrompt`
11085    /// arrives for a worker that was spawned idle (no `initial_prompt`)
11086    /// and therefore had no JSONL at `Connected`. By the time the first
11087    /// turn fires, claude is writing the JSONL, so the tag-write should
11088    /// succeed within a turn or two.
11089    ///
11090    /// On success: clear `needs_tag` and emit
11091    /// `WorkerStatusChanged { StatusChanged }`.
11092    ///
11093    /// On failure (any kind): log warn, leave `needs_tag = true` so the
11094    /// next turn retries again. Never rolls back - the worker stays
11095    /// functional regardless of the tag's on-disk state.
11096    pub(crate) fn retry_worker_tag_opportunistic(
11097        self: &Arc<Self>,
11098        project_key: &ProjectKey,
11099        session_key: &SessionSlot,
11100        session_id: &str,
11101        label: &str,
11102        cwd: &str,
11103        is_git_repo_at_spawn: bool,
11104    ) {
11105        let Some(config_dir) = self.config_dir_for(session_key) else {
11106            tracing::warn!(
11107                target: "forge_workspace::workspace",
11108                slot = %session_key.display(),
11109                "retry_worker_tag: no agent registered; cannot resolve config_dir"
11110            );
11111            return;
11112        };
11113        self.retry_worker_tag_opportunistic_with_config_dir(
11114            project_key,
11115            session_key,
11116            session_id,
11117            label,
11118            cwd,
11119            is_git_repo_at_spawn,
11120            &config_dir,
11121        );
11122    }
11123
11124    /// Testable inner of [`Self::retry_worker_tag_opportunistic`] that
11125    /// takes the resolved `config_dir` directly. Same shape as the
11126    /// `_with_config_dir` variant of `apply_worker_tag_or_rollback`,
11127    /// including the worktree-aware cwd routing for git-repo workers.
11128    pub(crate) fn retry_worker_tag_opportunistic_with_config_dir(
11129        self: &Arc<Self>,
11130        project_key: &ProjectKey,
11131        session_key: &SessionSlot,
11132        session_id: &str,
11133        label: &str,
11134        cwd: &str,
11135        is_git_repo_at_spawn: bool,
11136        config_dir: &std::path::Path,
11137    ) {
11138        use tracing::Instrument;
11139        let workspace = Arc::clone(self);
11140        let project_key = project_key.clone();
11141        let session_key = session_key.clone();
11142        let session_id = session_id.to_owned();
11143        let label = label.to_owned();
11144        let effective_cwd = crate::mcp::workers::types::worker_tag_dir(
11145            std::path::Path::new(cwd),
11146            &label,
11147            is_git_repo_at_spawn,
11148        );
11149        let config_dir = config_dir.to_path_buf();
11150        let span = tracing::info_span!(
11151            "forge_workspace::worker_tag_write",
11152            slot = %session_key.display(),
11153            label = %label,
11154        );
11155        let Some((org, name)) = self.project_identity_for_key(&project_key) else {
11156            tracing::warn!(
11157                target: "forge_workspace::workspace",
11158                event_name = "worker_tag_project_unknown",
11159                project = %project_key.as_str(),
11160                label = %label,
11161                "the worker's project is no longer configured, so its transcript is not tagged; \
11162                 an unscoped row would be another project's to adopt",
11163            );
11164            return;
11165        };
11166        let project = format!("{org}/{name}");
11167        tokio::spawn(async move {
11168            let tag = forge_primitives::worker_tag(&label);
11169            let result = tag_session_with_retry(
11170                &config_dir,
11171                &session_id,
11172                &tag,
11173                &project,
11174                &effective_cwd.to_string_lossy(),
11175                WORKER_TAG_RETRY_ATTEMPTS,
11176                WORKER_TAG_RETRY_DELAY,
11177            )
11178            .await;
11179            match result {
11180                Ok(()) => {
11181                    let updated = {
11182                        let mut workers = workspace.live_workers.lock();
11183                        workers.get_mut(&project_key).and_then(|entries| {
11184                            entries.iter_mut().find(|e| e.slot == session_key).map(|entry| {
11185                                entry.needs_tag = false;
11186                                (entry.to_status(), entry.is_git_repo_at_spawn)
11187                            })
11188                        })
11189                    };
11190                    if let Some((status, is_git_repo_at_spawn)) = updated {
11191                        tracing::info!(
11192                            target: "forge_workspace::workspace",
11193                            slot = %session_key.display(),
11194                            label = %label,
11195                            "tag_session_retry: deferred tag-write succeeded on opportunistic retry"
11196                        );
11197                        let _ = workspace.update_tx.send(SessionUpdate::WorkerStatusChanged {
11198                            project_key,
11199                            action: crate::protocol::WorkerStatusAction::StatusChanged,
11200                            status,
11201                            worktree: crate::protocol::WorktreeDisposition::untouched(
11202                                is_git_repo_at_spawn,
11203                            ),
11204                        });
11205                    }
11206                }
11207                Err(err) => {
11208                    tracing::warn!(
11209                        target: "forge_workspace::workspace",
11210                        slot = %session_key.display(),
11211                        label = %label,
11212                        error = ?err,
11213                        "tag_session_retry: opportunistic retry failed; will try again on next turn"
11214                    );
11215                }
11216            }
11217        }
11218        .instrument(span));
11219    }
11220
11221    // ---- Refresh helpers (workspace → agent) ----
11222    //
11223    // These two methods are query-style: a view says "re-emit state X
11224    // for `key`" and the payload returns via `SessionUpdate`. They
11225    // bypass the [`Command`] envelope because they don't carry
11226    // mutation state, and they need the workspace-side
11227    // `Arc<AgentHandle>` lookup rather than per-session task routing.
11228
11229    /// Request a fresh context-usage snapshot for `key`. The bridge
11230    /// replies via [`SessionUpdate::ContextUsageSnapshot`].
11231    ///
11232    /// # Errors
11233    ///
11234    /// Returns [`DispatchError::UnknownSession`] when no agent is
11235    /// registered for `key` (e.g., the session was just closed) or
11236    /// when the bridge hasn't stamped a `session_id` yet.
11237    pub fn refresh_context_usage(&self, key: &SessionSlot) -> Result<(), DispatchError> {
11238        let (handle, sid) = self.handle_and_session_id(key)?;
11239        handle.get_context_usage(sid).map_err(|_| DispatchError::SessionClosed(key.clone()))
11240    }
11241
11242    /// Request a fresh MCP server snapshot for `key`. The bridge
11243    /// replies via [`SessionUpdate::McpSnapshot`].
11244    ///
11245    /// # Errors
11246    ///
11247    /// See [`Self::refresh_context_usage`].
11248    pub fn refresh_mcp_snapshot(&self, key: &SessionSlot) -> Result<(), DispatchError> {
11249        let (handle, sid) = self.handle_and_session_id(key)?;
11250        handle.get_mcp_snapshot(sid).map_err(|_| DispatchError::SessionClosed(key.clone()))
11251    }
11252
11253    /// Record the OS walk of `key`'s process tree for views to read.
11254    ///
11255    /// The walk itself belongs to whoever owns the tick that drives it,
11256    /// because a walk is a `sysinfo` refresh that costs tens of
11257    /// milliseconds and no read should pay for one. The answer belongs
11258    /// here, so a second view reads the tree rather than walking the OS
11259    /// again. `None` clears it: the walk described a subprocess tree
11260    /// that is gone.
11261    ///
11262    /// No-op for a slot with no session: minting a domain for whoever
11263    /// asked would leave the workspace routing to a session nobody runs.
11264    pub fn store_process_snapshot(
11265        &self,
11266        key: &SessionSlot,
11267        snapshot: Option<forge_agent::env::processes::ProcessSnapshot>,
11268    ) {
11269        if let Some(domain) = self.domain_session_for(key) {
11270            domain.lock().process_snapshot = snapshot;
11271        }
11272    }
11273
11274    /// The last OS walk of `key`'s process tree, or `None` when nothing
11275    /// has walked it or the tree it described is gone.
11276    pub fn process_snapshot(
11277        &self,
11278        key: &SessionSlot,
11279    ) -> Option<forge_agent::env::processes::ProcessSnapshot> {
11280        self.domain_session_for(key)?.lock().process_snapshot.clone()
11281    }
11282
11283    // ---- Direct-accessor facades (workspace owns the bridge call) ----
11284
11285    /// Resolve the auto-memory path the bridge would consult for
11286    /// `cwd`, scoped to `key`'s configured account. Returns `None`
11287    /// when no agent is registered for `key`.
11288    pub fn project_memory_path(&self, key: &SessionSlot, cwd: &std::path::Path) -> Option<PathBuf> {
11289        let handle = self.agent_handle_for(key)?;
11290        Some(handle.project_memory_path(cwd))
11291    }
11292
11293    /// Snapshot the bridge's settings documents for `key` at `cwd`.
11294    /// Returns `None` when no agent is registered for `key`.
11295    pub fn settings_documents(
11296        &self,
11297        key: &SessionSlot,
11298        cwd: Option<&std::path::Path>,
11299    ) -> Option<forge_agent::userdata::settings::SettingsDocuments> {
11300        let handle = self.agent_handle_for(key)?;
11301        Some(handle.settings_documents(cwd))
11302    }
11303
11304    /// The CLI's per-user preferences document, from `$HOME/.claude.json`.
11305    ///
11306    /// Read on each call rather than held, because the user can change a
11307    /// preference while forge runs.
11308    pub fn user_preferences(&self) -> Option<serde_json::Value> {
11309        #[cfg(any(test, feature = "testing"))]
11310        {
11311            if let Some(seeded) = self.test_user_preferences.lock().clone() {
11312                return Some(seeded);
11313            }
11314        }
11315        forge_agent::userdata::settings::user_preferences()
11316    }
11317
11318    /// Resolve the agent's configured config_dir for `key`. Returns
11319    /// `None` when no agent is registered for `key`.
11320    pub fn config_dir_for(&self, key: &SessionSlot) -> Option<PathBuf> {
11321        let handle = self.agent_handle_for(key)?;
11322        Some(handle.config_dir())
11323    }
11324
11325    /// OS PID of the `claude` subprocess bound to `key`. Returns
11326    /// `None` when the session has no live client (pre-spawn /
11327    /// post-disconnect). The PID is stable
11328    /// for the lifetime of the subprocess, so consumers (e.g. the
11329    /// Inspector pane's PROCESSES OS walk) can cache snapshots
11330    /// keyed off this value.
11331    pub fn claude_pid(&self, key: &SessionSlot) -> Option<u32> {
11332        #[cfg(any(test, feature = "testing"))]
11333        {
11334            if let Some(seeded) = self.test_claude_pid.lock().get(key) {
11335                return Some(*seeded);
11336            }
11337        }
11338        self.agent_handle_for(key).and_then(|handle| handle.claude_pid())
11339    }
11340
11341    /// Borrow the [`Arc<AgentHandle>`] registered against `key`.
11342    /// Workspace-internal helper - surfaces a sometimes-`None` to keep
11343    /// the early-init / disconnected branches explicit.
11344    fn agent_handle_for(&self, key: &SessionSlot) -> Option<Arc<AgentHandle>> {
11345        let pool = self.pool.lock();
11346        if let Some(pooled) = pool.get(key) {
11347            return Some(Arc::clone(&pooled.handle));
11348        }
11349        drop(pool);
11350        // Fall back to `domain_handles[key].conn` for pre-Connect /
11351        // testing-stub callers that never went through the pool path.
11352        let domain = self.domain_handles.lock().get(key).cloned()?;
11353        domain.lock().conn.clone()
11354    }
11355
11356    /// Resolve `(handle, session_id_string)` for `key`. Both must be
11357    /// available; missing either surfaces as `UnknownSession` for
11358    /// uniform error handling.
11359    fn handle_and_session_id(
11360        &self,
11361        key: &SessionSlot,
11362    ) -> Result<(Arc<AgentHandle>, String), DispatchError> {
11363        let handle =
11364            self.agent_handle_for(key).ok_or_else(|| DispatchError::UnknownSession(key.clone()))?;
11365        let sid = self
11366            .domain_handles
11367            .lock()
11368            .get(key)
11369            .and_then(|d| d.lock().session_id.as_ref().map(std::string::ToString::to_string))
11370            .ok_or_else(|| DispatchError::UnknownSession(key.clone()))?;
11371        Ok((handle, sid))
11372    }
11373}
11374
11375/// The repair line the usage poller logs under an auth-classified
11376/// failure, keyed on how the account authenticates. A session's
11377/// credentials are fixed at its spawn, so both classes point at the
11378/// flat-key edit on the account block, never a re-authentication of the
11379/// shared config dir.
11380///
11381/// The base-url test must stay first: a global `[env]` setup token
11382/// reaches base-url accounts too, and the re-mint advice is for a
11383/// credential that account never reads.
11384fn auth_repair_hint(provider: forge_primitives::account::Provider) -> &'static str {
11385    if provider.uses_base_url() {
11386        "usage_poll fetch failed with auth error; fix the account's token and base_url keys and restart forge"
11387    } else {
11388        "usage_poll fetch failed with auth error; mint the setup token on the account block (claude setup-token) and restart forge"
11389    }
11390}
11391
11392/// Map a failed probe to the [`forge_gateway::UsageFetchStatus`]
11393/// bucket. Separates HTTP 429 (the common multi-instance throttle
11394/// case) from the auth-related
11395/// failures (`Expired` / `NoCredentials` / `Unauthorized`) and
11396/// transport failures (`Network`), so each class keeps its own
11397/// meaning rather than a generic "fetch error". `Unmappable` never
11398/// reaches the classifiers - both callers handle a 200 that maps to
11399/// nothing before classifying.
11400pub(crate) fn classify_oauth_usage_error(
11401    err: &forge_gateway::ProbeError,
11402) -> forge_gateway::UsageFetchStatus {
11403    use forge_gateway::UsageFetchStatus;
11404    use forge_primitives::usage::oauth::OauthUsageError;
11405    match err {
11406        forge_gateway::ProbeError::NoCredentials => UsageFetchStatus::Expired,
11407        forge_gateway::ProbeError::Unmappable(_) => UsageFetchStatus::Other,
11408        forge_gateway::ProbeError::Fetch(err) => match err {
11409            OauthUsageError::RateLimited { .. } | OauthUsageError::HttpStatus(429, _) => {
11410                UsageFetchStatus::RateLimited
11411            }
11412            OauthUsageError::Unauthorized(_) => UsageFetchStatus::Unauthorized,
11413            OauthUsageError::NoCredentials | OauthUsageError::Expired => UsageFetchStatus::Expired,
11414            OauthUsageError::Network(_) => UsageFetchStatus::NetworkFailed,
11415            OauthUsageError::UaProbe(_)
11416            | OauthUsageError::HttpStatus(_, _)
11417            // No probe converts a scope refusal - the token arm calls
11418            // /v1/messages, which has no scope refusal - and a 403
11419            // there is not an auth failure either.
11420            | OauthUsageError::ScopeInsufficient
11421            | OauthUsageError::Decode(_) => UsageFetchStatus::Other,
11422        },
11423    }
11424}
11425
11426impl Workspace {
11427    /// Register a fresh `DomainSession` for `key` under this workspace.
11428    /// `handle` is `None` for pre-spawn / pre-Connect domains (filled
11429    /// in later when the spawn handler runs); `Some` for test fixtures
11430    /// that wire a stub handle up front.
11431    ///
11432    /// `DomainSession` carries workspace-internal routing metadata
11433    /// (`conn` / `session_id` / `pending_interactions`). A view's per-
11434    /// session operational state lives on the view side; the workspace
11435    /// does not read or write those fields.
11436    ///
11437    /// Returns the inserted `Arc<Mutex<DomainSession>>` so callers
11438    /// can seed fields on the same handle they just registered.
11439    pub fn register_domain_session(
11440        &self,
11441        key: SessionSlot,
11442        handle: Option<Arc<forge_agent::AgentHandle>>,
11443    ) -> Arc<Mutex<DomainSession>> {
11444        let domain = Arc::new(Mutex::new(DomainSession::new(key.clone(), handle)));
11445        let mut handles = self.domain_handles.lock();
11446        if handles.contains_key(&key) {
11447            // Overwriting silently drops the previous DomainSession
11448            // along with any pending_interactions oneshots - pending
11449            // permission/question round-trips would then deny with
11450            // "response channel closed" instead of completing. Log
11451            // loudly so a future programming error doesn't manifest
11452            // as a stale-deny that's hard to trace.
11453            tracing::error!(
11454                target: "forge_workspace::workspace",
11455                slot = %key.display(),
11456                "register_domain_session overwriting existing entry - pending interactions lost"
11457            );
11458        }
11459        handles.insert(key, Arc::clone(&domain));
11460        domain
11461    }
11462
11463    /// Tell a sender its message never landed.
11464    ///
11465    /// The one delivery-ack path, and it is NOT outstanding state coming
11466    /// back: nothing is tracked between the send and this notice. A message
11467    /// parked for a sleeping project is dropped when the spawn it waited on
11468    /// fails, and the parked entry itself carries the sender, so the sender
11469    /// is told rather than left believing the words arrived.
11470    pub(crate) fn notice_undelivered_message(
11471        self: &Arc<Self>,
11472        sender: &SessionSlot,
11473        target: &SessionSlot,
11474        reason: crate::mcp::peers::types::PeerFailureReason,
11475    ) {
11476        // Body carries the human-readable failure reason - the sender's
11477        // chat block surfaces it underneath the bracket header.
11478        let body = match reason {
11479            crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed => {
11480                "target session connection lost".to_owned()
11481            }
11482        };
11483        let notice = WrappedPrompt {
11484            id: MessageId::mint(),
11485            kind: WrappedKind::DeliveryFailureNotice,
11486            sender_name: crate::mcp::peers::types::seat_name(target),
11487            sender_org: target.org().to_owned(),
11488            body,
11489        };
11490        // The CLI never echoes stdin-injected prompts back, so paint the
11491        // visible notice block ourselves before the LLM-side dispatch.
11492        let uuid = forge_sdk::request_id::next_prompt_id();
11493        crate::spawn::push_peer_user_turn_into_chat(self, sender, &notice, &uuid);
11494        if let Err(err) = self.dispatch_workspace_prompt_under(
11495            sender,
11496            notice.to_prose(),
11497            PromptSource::Peer,
11498            uuid,
11499        ) {
11500            tracing::warn!(
11501                target: "forge_workspace::workspace",
11502                slot = %sender.display(),
11503                error = ?err,
11504                "notice_undelivered_message: notice dispatch failed (sender closed?)"
11505            );
11506        }
11507    }
11508}
11509
11510/// Retry budget for the detached tag-write: how many attempts
11511/// `tag_session_with_retry` gets to find the worker's
11512/// `<session_id>.jsonl`, and the delay between them.
11513#[derive(Clone, Copy)]
11514pub(crate) struct WorkerTagRetryBudget {
11515    max_attempts: u32,
11516    delay: Duration,
11517}
11518
11519/// The two facts the connect-time tag path decides on: whether the JSONL
11520/// still needs its tag, and whether this spawn minted the row a rollback
11521/// would delete.
11522#[derive(Clone, Copy)]
11523pub(crate) struct WorkerTagFacts {
11524    needs_tag: bool,
11525    minted_row: bool,
11526}
11527
11528/// Discriminator for how a successful `apply_worker_tag_or_rollback`
11529/// arm decided to transition a worker to `Running`. The `Succeeded`
11530/// arm clears `needs_tag`; the `DeferredNotFound` arm keeps it set
11531/// so the opportunistic retry on first `DeliverWorkerPrompt` knows
11532/// to try again.
11533#[derive(Clone, Copy)]
11534enum TagWriteResult {
11535    /// Tag row was appended to the JSONL on disk.
11536    Succeeded,
11537    /// JSONL never appeared during the retry window. Worker stays
11538    /// live with `needs_tag = true` for opportunistic retry later.
11539    DeferredNotFound,
11540}
11541
11542/// Shared transition: flip a worker's status to `Running`, update
11543/// `needs_tag` according to the tag-write outcome, and emit
11544/// `WorkerStatusChanged { StatusChanged }`. Idempotent.
11545fn transition_worker_to_running(
11546    workspace: &Arc<Workspace>,
11547    project_key: &ProjectKey,
11548    session_key: &SessionSlot,
11549    result: TagWriteResult,
11550) {
11551    let updated = {
11552        let mut workers = workspace.live_workers.lock();
11553        workers.get_mut(project_key).and_then(|entries| {
11554            entries.iter_mut().find(|e| e.slot == *session_key).map(|entry| {
11555                entry.status = forge_primitives::WorkerLiveness::Running;
11556                entry.needs_tag = matches!(result, TagWriteResult::DeferredNotFound);
11557                // Clear any stale diagnostic from a prior Failed
11558                // transition - the worker is alive again.
11559                entry.diagnostic = None;
11560                (entry.to_status(), entry.is_git_repo_at_spawn)
11561            })
11562        })
11563    };
11564    if let Some((status, is_git_repo_at_spawn)) = updated {
11565        let _ = workspace.update_tx.send(SessionUpdate::WorkerStatusChanged {
11566            project_key: project_key.clone(),
11567            action: crate::protocol::WorkerStatusAction::StatusChanged,
11568            status,
11569            worktree: crate::protocol::WorktreeDisposition::untouched(is_git_repo_at_spawn),
11570        });
11571    }
11572}
11573
11574/// Shared transition: flip a worker's status to `Failed` with a
11575/// human-readable `diagnostic`, and emit `WorkerStatusChanged
11576/// { StatusChanged }`. Called by the `Connected`-never-arrived
11577/// paths (subprocess exit, ConnectionFailed event, resume rejected
11578/// by claude when the fall-through-to-fresh path declines to
11579/// retry).
11580///
11581/// Idempotent: when the entry is already `Failed` with an identical
11582/// diagnostic, this is a no-op (no mutation, no event emission). A
11583/// fresh diagnostic for an already-Failed entry DOES re-emit so the
11584/// UI picks up the new reason text.
11585///
11586/// Also clears `needs_tag` because a Failed worker won't be reached
11587/// by the opportunistic tag-retry path - leaving the flag set keeps
11588/// stale state on the entry the next time the worker resumes and
11589/// transitions back to Running.
11590///
11591/// The diagnostic should be the first line of claude's stderr (when
11592/// available) or the error variant name. Keep it short - the
11593/// Projects pane renders it as a one-row sub-line below the worker
11594/// label, truncated to the row's available width.
11595pub(crate) fn transition_worker_to_failed(
11596    workspace: &Arc<Workspace>,
11597    project_key: &ProjectKey,
11598    session_key: &SessionSlot,
11599    diagnostic: Option<String>,
11600) {
11601    let updated = {
11602        let mut workers = workspace.live_workers.lock();
11603        workers.get_mut(project_key).and_then(|entries| {
11604            entries.iter_mut().find(|e| e.slot == *session_key).and_then(|entry| {
11605                // A seat the supervisor is already bringing back keeps its
11606                // `Restarting`: this failure is the death it is healing, and
11607                // the row must not be repainted failed under it.
11608                if workspace.heal_progress(&entry.slot).is_some() {
11609                    return None;
11610                }
11611                // Idempotency: same status + same diagnostic -> no-op.
11612                if entry.status == forge_primitives::WorkerLiveness::Failed
11613                    && entry.diagnostic == diagnostic
11614                {
11615                    return None;
11616                }
11617                entry.status = forge_primitives::WorkerLiveness::Failed;
11618                entry.diagnostic = diagnostic;
11619                // Clear needs_tag - a Failed worker won't reach the
11620                // opportunistic tag-retry path, and leaving the flag
11621                // set would keep stale state if the entry later
11622                // transitions back to Running on a successful resume.
11623                entry.needs_tag = false;
11624                Some((entry.to_status(), entry.is_git_repo_at_spawn))
11625            })
11626        })
11627    };
11628    if let Some((status, is_git_repo_at_spawn)) = updated {
11629        tracing::warn!(
11630            target: "forge_workspace::workspace",
11631            event_name = "worker_failed",
11632            project = %project_key.as_str(),
11633            slot = %session_key.display(),
11634            diagnostic = ?status.diagnostic,
11635            "worker session transitioned to Failed; row will render with diagnostic sub-line",
11636        );
11637        let _ = workspace.update_tx.send(SessionUpdate::WorkerStatusChanged {
11638            project_key: project_key.clone(),
11639            action: crate::protocol::WorkerStatusAction::StatusChanged,
11640            status,
11641            worktree: crate::protocol::WorktreeDisposition::untouched(is_git_repo_at_spawn),
11642        });
11643    }
11644}
11645
11646/// Wrap `mutations::tag_session` with a retry loop scoped to the JSONL-
11647/// not-yet-on-disk race after `Connected`. claude CLI creates
11648/// `<session_id>.jsonl` lazily; until that lands, `find_session_file`
11649/// returns `None` and `tag_session` surfaces `Io(NotFound)`. We retry
11650/// only on that variant - any other error (permission denied, disk
11651/// full, invalid UUID, encode error) propagates immediately. The async
11652/// `tokio::time::sleep` is mandatory: this runs in a tokio task spawned
11653/// from `apply_worker_tag_or_rollback` (or `retry_worker_tag_opportunistic`),
11654/// never on a blocking thread.
11655async fn tag_session_with_retry(
11656    config_dir: &std::path::Path,
11657    session_id: &str,
11658    tag: &str,
11659    project: &str,
11660    directory: &str,
11661    max_attempts: u32,
11662    delay: Duration,
11663) -> Result<(), forge_sdk::Error> {
11664    let mut last_err: Option<forge_sdk::Error> = None;
11665    for attempt in 0..max_attempts {
11666        match forge_agent::userdata::catalog::mutations::tag_session(
11667            config_dir,
11668            session_id,
11669            Some(tag),
11670            project,
11671            Some(directory),
11672        ) {
11673            Ok(()) => return Ok(()),
11674            Err(forge_sdk::Error::Io(io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
11675                last_err = Some(forge_sdk::Error::Io(io_err));
11676                tracing::trace!(
11677                    target: "forge_workspace::workspace",
11678                    session_id = %session_id,
11679                    attempt = attempt + 1,
11680                    max_attempts,
11681                    "tag_session: JSONL not yet on disk, retrying"
11682                );
11683                tokio::time::sleep(delay).await;
11684            }
11685            Err(other) => return Err(other),
11686        }
11687    }
11688    Err(last_err.unwrap_or_else(|| {
11689        forge_sdk::Error::Io(std::io::Error::new(
11690            std::io::ErrorKind::NotFound,
11691            format!("session {session_id} not found after {max_attempts} attempts"),
11692        ))
11693    }))
11694}
11695
11696/// Env for a spawn: the account env with `project`'s declared keys
11697/// merged over it. The spawn resolves the project first and refuses a
11698/// target that maps to none, so there is always one to merge.
11699fn session_env_for(
11700    project: &LoadedProject,
11701    account_env: &std::collections::HashMap<String, String>,
11702) -> std::collections::HashMap<String, String> {
11703    // Logged even when the project declares nothing, so a spawn
11704    // carrying the wrong project's env is visible.
11705    tracing::info!(
11706        target: "forge_workspace::workspace",
11707        event_name = "session_env_project_applied",
11708        project = %project.name,
11709        keys = %crate::config::applied_env_keys(project),
11710        "`keys` lists what the project env contributed, empty when it declares none",
11711    );
11712    crate::config::session_env(project, account_env)
11713}
11714
11715/// Stamp the project's `permission_mode` into the launch settings'
11716/// `permissions.defaultMode`. A spawn that resolved to no project
11717/// stamps nothing, so the launcher's session default applies.
11718fn apply_project_permission_mode(
11719    project: Option<&crate::config::LoadedProject>,
11720    settings: &mut SessionLaunchSettings,
11721) {
11722    if let Some(mode) = project.map(|project| project.permission_mode) {
11723        spawn::stamp_permission_mode(settings, mode);
11724    }
11725}
11726
11727/// Stamp the project's canonical `model` into the launch settings, which
11728/// is how the session's model reaches the CLI. A project that declares
11729/// no model stamps nothing, so the caller's pin (forge defaults it to
11730/// the literal "opus") stands.
11731fn apply_project_model(
11732    project: Option<&crate::config::LoadedProject>,
11733    settings: &mut SessionLaunchSettings,
11734) {
11735    if let Some(model) = project.and_then(|project| project.model.as_deref()) {
11736        settings.settings = Some(pin_model_into_settings(settings.settings.take(), model));
11737    }
11738}
11739
11740/// `settings` with `model` pinned to `model`, every other key kept.
11741fn pin_model_into_settings(settings: Option<serde_json::Value>, model: &str) -> serde_json::Value {
11742    let mut document = match settings {
11743        Some(serde_json::Value::Object(map)) => map,
11744        _ => serde_json::Map::new(),
11745    };
11746    document.insert("model".to_owned(), serde_json::Value::String(model.to_owned()));
11747    serde_json::Value::Object(document)
11748}
11749
11750/// Test helper: ensure `forge/` exists and return the production
11751/// `forge/forge.toml` path, so tests write where forge reads (not the
11752/// legacy top-level fallback).
11753#[cfg(test)]
11754fn forge_toml_path(config_dir: &std::path::Path) -> PathBuf {
11755    crate::config::ensure_forge_data_dir(config_dir).expect("forge/ dir").join("forge.toml")
11756}
11757
11758/// Test helper: persist a worker row for `label` in `project_key` and
11759/// fail loudly when it did not land. `project_key` must resolve to a
11760/// configured project - the row is keyed by `(org, project name, label)`,
11761/// so a bare map key writes nothing and every assertion downstream would
11762/// then hold for the wrong reason.
11763#[cfg(test)]
11764fn seed_worker_row(ws: &Workspace, project_key: &ProjectKey, label: &str) {
11765    ws.seed_test_worker_row(project_key, label);
11766    assert!(
11767        ws.stored_worker_row(project_key, label).expect("read the seeded row").is_some(),
11768        "the worker row for {label} did not land; does {project_key:?} resolve to a project?",
11769    );
11770}
11771
11772#[cfg(test)]
11773mod account_stamp_tests {
11774    use super::*;
11775    use crate::config::LoadedProject;
11776
11777    fn project_with_mode(
11778        permission_mode: forge_primitives::permission::PermissionMode,
11779    ) -> LoadedProject {
11780        LoadedProject {
11781            name: "forge".to_owned(),
11782            path: PathBuf::from("/tmp/forge"),
11783            display_path: "~/Projects/forge".to_owned(),
11784            org: "Personal".to_owned(),
11785            accounts: vec!["Stargate".to_owned()],
11786            fallback_accounts: Vec::new(),
11787            model: None,
11788            env: HashMap::new(),
11789            env_file: None,
11790            max_workers: None,
11791            permission_mode,
11792            document_roots: Vec::new(),
11793            handoff: None,
11794            browser_profile: forge_primitives::browser::ProfileKey::of("Personal", "default")
11795                .expect("a profile key"),
11796            slack_workspace: None,
11797        }
11798    }
11799
11800    fn stamped_mode(settings: &SessionLaunchSettings) -> Option<String> {
11801        settings
11802            .settings
11803            .as_ref()
11804            .and_then(|s| s.get("permissions"))
11805            .and_then(|p| p.get("defaultMode"))
11806            .and_then(serde_json::Value::as_str)
11807            .map(str::to_owned)
11808    }
11809
11810    #[test]
11811    fn project_mode_stamps_fresh_spawn_settings_and_projectless_leaves_them() {
11812        let bypass =
11813            project_with_mode(forge_primitives::permission::PermissionMode::BypassPermissions);
11814        let mut stamped = SessionLaunchSettings::default();
11815        apply_project_permission_mode(Some(&bypass), &mut stamped);
11816        assert_eq!(
11817            stamped_mode(&stamped).as_deref(),
11818            Some("bypassPermissions"),
11819            "a project carrying permission_mode stamps the fresh spawn settings",
11820        );
11821
11822        let mut untouched = SessionLaunchSettings::default();
11823        apply_project_permission_mode(None, &mut untouched);
11824        assert!(
11825            untouched.settings.is_none(),
11826            "a spawn that resolved to no project leaves fresh settings untouched"
11827        );
11828    }
11829}
11830
11831/// Wait for one slot's teardown answer. `subscribe` marks the current value
11832/// as seen, so the value is checked before waiting on a change.
11833async fn wait_for_teardown(rx: &mut tokio::sync::watch::Receiver<bool>) {
11834    if *rx.borrow_and_update() {
11835        return;
11836    }
11837    let _ = rx.changed().await;
11838}
11839
11840/// The slots whose task has not answered yet, read off the channel values.
11841fn unready_slots(
11842    receivers: &[(SessionSlot, tokio::sync::watch::Receiver<bool>)],
11843) -> Vec<SessionSlot> {
11844    receivers.iter().filter(|(_, rx)| !*rx.borrow()).map(|(slot, _)| slot.clone()).collect()
11845}
11846
11847/// Name each session that outlasted the shutdown's wait. One line per slot:
11848/// the shared deadline says nothing about which task was slow, and the
11849/// value is the only thing that does.
11850fn warn_shutdown_expired(slots: &[SessionSlot], wait: std::time::Duration) {
11851    for slot in slots {
11852        tracing::warn!(
11853            target: "forge_workspace::workspace",
11854            event_name = "shutdown_teardown_wait_expired",
11855            org = slot.org(),
11856            project = slot.project(),
11857            label = slot.label(),
11858            wait = ?wait,
11859            "shutdown: this session's task did not finish its teardown in the wait; the \
11860             usual stall is the stderr join after the group kill, where a detached \
11861             descendant still holds the child's stderr open, so its tree may outlive forge"
11862        );
11863    }
11864}
11865
11866#[cfg(test)]
11867#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
11868mod tests {
11869    use super::*;
11870    use std::fs;
11871    use tempfile::tempdir;
11872
11873    /// A probe the test steers: a fixed snapshot, and a count of the calls
11874    /// it answered, so the scheduling around the probe is observable
11875    /// without a real `claude --version` or a network call.
11876    fn scripted_prober(
11877        calls: &Arc<std::sync::atomic::AtomicUsize>,
11878        snapshot: CliVersionInfo,
11879    ) -> CliVersionProber {
11880        let calls = Arc::clone(calls);
11881        Box::new(move || {
11882            calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
11883            Box::pin(std::future::ready(snapshot.clone()))
11884        })
11885    }
11886
11887    /// The constructor's own launch is what starts the probe. Delete it and
11888    /// the core reads no version for the life of the process while every
11889    /// other test stays green, so nothing else notices that the feature's
11890    /// only production write is gone.
11891    #[tokio::test]
11892    async fn the_boot_construction_starts_the_probe_and_stores_what_it_reads() {
11893        let dir = make_workspace_dir();
11894        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
11895        let probed = CliVersionInfo {
11896            installed: Some("2.1.156".to_owned()),
11897            latest: Some("2.1.201".to_owned()),
11898        };
11899        let workspace = Workspace::new_for_test_with_cli_version_prober(
11900            dir.path().to_owned(),
11901            scripted_prober(&calls, probed.clone()),
11902        )
11903        .expect("new");
11904        let mut woken = workspace.subscribe_answerer();
11905
11906        let event = tokio::time::timeout(Duration::from_secs(5), woken.recv()).await;
11907
11908        assert!(
11909            matches!(
11910                event,
11911                Ok(Some(frame)) if matches!(&*frame.update, SessionUpdate::CliVersionChanged)
11912            ),
11913            "the constructor's launch probed and woke the views; without that launch the core reads no version for the life of the process",
11914        );
11915        assert_eq!(
11916            calls.load(std::sync::atomic::Ordering::Acquire),
11917            1,
11918            "the boot launch probed exactly once",
11919        );
11920        assert_eq!(
11921            workspace.cli_version(),
11922            Some(probed),
11923            "and the store holds what the probe returned, not a snapshot that resolved nothing",
11924        );
11925    }
11926
11927    /// A second start is a no-op. Two loops would each spawn
11928    /// `claude --version` and reach npm every five minutes, and each would
11929    /// write into the store the other one reads.
11930    #[tokio::test]
11931    async fn a_second_cli_version_probe_start_is_a_no_op() {
11932        let dir = make_workspace_dir();
11933        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
11934        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
11935        let first = CliVersionInfo { installed: Some("2.1.156".to_owned()), latest: None };
11936        let second = CliVersionInfo { installed: Some("2.1.100".to_owned()), latest: None };
11937
11938        workspace.start_cli_version_probe(scripted_prober(&calls, first.clone()));
11939        workspace.start_cli_version_probe(scripted_prober(&calls, second));
11940        let mut woken = workspace.subscribe_answerer();
11941        let event = tokio::time::timeout(Duration::from_secs(5), woken.recv()).await;
11942        assert!(
11943            matches!(
11944                event,
11945                Ok(Some(frame)) if matches!(&*frame.update, SessionUpdate::CliVersionChanged)
11946            ),
11947            "the first start probed and woke the views",
11948        );
11949
11950        // The second loop's first tick is immediate too, so an unguarded
11951        // start lands a second snapshot and a second event within
11952        // microseconds of the first.
11953        let again = tokio::time::timeout(Duration::from_millis(100), woken.recv()).await;
11954        assert!(
11955            again.is_err(),
11956            "the second start was a no-op; two loops would each spawn `claude --version` and reach npm",
11957        );
11958        assert_eq!(
11959            calls.load(std::sync::atomic::Ordering::Acquire),
11960            1,
11961            "and only one probe call happened at all",
11962        );
11963        assert_eq!(
11964            workspace.cli_version(),
11965            Some(first),
11966            "the store holds the first probe's answer, which is what says the second never ran after it",
11967        );
11968    }
11969
11970    /// A probe that came back empty on one side keeps the value an earlier
11971    /// probe resolved: a transient failure must not blank a version the
11972    /// views were already showing.
11973    #[test]
11974    fn an_empty_cli_version_probe_keeps_the_resolved_field() {
11975        let held = CliVersionInfo {
11976            installed: Some("2.1.156".to_owned()),
11977            latest: Some("2.1.201".to_owned()),
11978        };
11979
11980        let merged = merge_cli_version(
11981            Some(&held),
11982            CliVersionInfo { installed: Some("2.1.156".to_owned()), latest: None },
11983        );
11984        assert_eq!(
11985            merged.latest.as_deref(),
11986            Some("2.1.201"),
11987            "a latest probe that failed must not wipe the latest an earlier probe found",
11988        );
11989
11990        let merged = merge_cli_version(
11991            Some(&held),
11992            CliVersionInfo { installed: None, latest: Some("2.1.201".to_owned()) },
11993        );
11994        assert_eq!(
11995            merged.installed.as_deref(),
11996            Some("2.1.156"),
11997            "an installed probe that failed must not wipe the version already read",
11998        );
11999    }
12000
12001    /// And a probe that did resolve a side replaces what was held, so the
12002    /// keep-the-old-value rule cannot pin the views to a stale answer.
12003    #[test]
12004    fn a_resolved_cli_version_probe_replaces_the_held_value() {
12005        let held = CliVersionInfo {
12006            installed: Some("2.1.156".to_owned()),
12007            latest: Some("2.1.201".to_owned()),
12008        };
12009        let next = CliVersionInfo {
12010            installed: Some("2.1.160".to_owned()),
12011            latest: Some("2.1.210".to_owned()),
12012        };
12013
12014        assert_eq!(
12015            merge_cli_version(Some(&held), next.clone()),
12016            next,
12017            "a probe that resolved both sides replaces both, rather than pinning the held ones",
12018        );
12019    }
12020
12021    /// Only a change wakes the views. The probe re-runs on a timer, so a
12022    /// re-probe landing the same snapshot must not wake them - both views
12023    /// would redraw for nothing every few minutes.
12024    #[test]
12025    fn only_a_changed_cli_version_wakes_the_views() {
12026        let store = Mutex::new(None);
12027        let update_tx = UpdateFanout::default();
12028        let mut woken = update_tx.subscribe(SubscriberRole::Answering);
12029        let first = CliVersionInfo { installed: Some("2.1.156".to_owned()), latest: None };
12030
12031        store_cli_version(&store, &update_tx, first.clone());
12032        assert!(
12033            matches!(
12034                woken.try_recv(),
12035                Ok(frame) if matches!(&*frame.update, SessionUpdate::CliVersionChanged)
12036            ),
12037            "the first probe is a change from none",
12038        );
12039
12040        store_cli_version(&store, &update_tx, first);
12041        assert!(woken.try_recv().is_err(), "an identical re-probe is not news");
12042
12043        store_cli_version(
12044            &store,
12045            &update_tx,
12046            CliVersionInfo {
12047                installed: Some("2.1.156".to_owned()),
12048                latest: Some("2.1.201".to_owned()),
12049            },
12050        );
12051        assert!(
12052            matches!(
12053                woken.try_recv(),
12054                Ok(frame) if matches!(&*frame.update, SessionUpdate::CliVersionChanged)
12055            ),
12056            "a latest that appears where there was none is news",
12057        );
12058        assert_eq!(
12059            store.lock().as_ref().and_then(|held| held.latest.as_deref()),
12060            Some("2.1.201"),
12061            "and the store holds what the last merge resolved",
12062        );
12063
12064        // A first probe that resolved nothing holds a snapshot and draws
12065        // what the views already had, so it is not news either.
12066        let unresolved = Mutex::new(None);
12067        store_cli_version(
12068            &unresolved,
12069            &update_tx,
12070            CliVersionInfo { installed: None, latest: None },
12071        );
12072        assert!(
12073            woken.try_recv().is_err(),
12074            "a first probe that resolved nothing draws what the views already had",
12075        );
12076        assert_eq!(
12077            unresolved
12078                .lock()
12079                .as_ref()
12080                .map(|held| (held.installed.as_deref(), held.latest.as_deref())),
12081            Some((None, None)),
12082            "the store holds what the probe read, and both sides are still empty",
12083        );
12084    }
12085
12086    /// Every provider logs the repair line that can actually repair
12087    /// it. The base-url arm is load-bearing: a global `[env]` setup
12088    /// token reaches base-url accounts too, and a token-first order
12089    /// would send their 401 to the re-mint advice against a credential
12090    /// those providers never read.
12091    #[test]
12092    fn auth_repair_hint_keys_on_the_credential_shape() {
12093        use forge_primitives::account::Provider;
12094
12095        for provider in [Provider::Codex, Provider::Openrouter, Provider::Zai] {
12096            assert_eq!(
12097                auth_repair_hint(provider),
12098                "usage_poll fetch failed with auth error; fix the account's token and \
12099                 base_url keys and restart forge",
12100                "{provider:?} is repaired by a flat-key edit",
12101            );
12102        }
12103
12104        assert_eq!(
12105            auth_repair_hint(Provider::Anthropic),
12106            "usage_poll fetch failed with auth error; mint the setup token on the account \
12107             block (claude setup-token) and restart forge",
12108            "an anthropic account repairs through its setup token, token or not",
12109        );
12110    }
12111
12112    /// The supersession guard that keeps a re-spawn intact: a stale
12113    /// predecessor task exiting must NOT wipe
12114    /// the successor's pool entry, command sender, or domain handle -
12115    /// all three are gated together on `Arc` identity. The current
12116    /// owner's own exit still releases all three.
12117    #[test]
12118    fn release_session_if_current_is_supersession_safe() {
12119        let (ws, _rx) = Workspace::testing_stub();
12120        let key = SessionSlot::from_str_for_test("sup-key");
12121
12122        let (ha, _rxa) = Workspace::testing_stub_handle();
12123        let arc_a = Arc::new(ha);
12124        let (hb, _rxb) = Workspace::testing_stub_handle();
12125        let arc_b = Arc::new(hb);
12126
12127        // The successor (account B) owns all three registrations.
12128        let (tx, _cmd_rx) = mpsc::unbounded_channel::<Command>();
12129        ws.pool.lock().insert(
12130            key.clone(),
12131            PooledAgent {
12132                handle: Arc::clone(&arc_b),
12133                account: AccountKey("B".to_owned()),
12134                permission_mode: None,
12135                registration: None,
12136                session_id: "pooled-session".to_owned(),
12137            },
12138        );
12139        ws.command_senders.lock().insert(key.clone(), tx);
12140        ws.register_domain_session(key.clone(), Some(Arc::clone(&arc_b)));
12141
12142        // The superseded predecessor (account A) exits and runs its
12143        // cleanup. Its handle no longer matches the pooled one, so the
12144        // guard no-ops across every map and B's live session survives.
12145        ws.release_session_if_current(&key, &arc_a);
12146        assert!(ws.pool.lock().contains_key(&key), "pool entry for B survives A's exit");
12147        assert!(ws.command_senders.lock().contains_key(&key), "command sender for B survives");
12148        assert!(ws.domain_handles.lock().contains_key(&key), "domain handle for B survives");
12149
12150        // The current owner's own exit DOES release all three maps.
12151        ws.release_session_if_current(&key, &arc_b);
12152        assert!(!ws.pool.lock().contains_key(&key), "current owner removes the pool entry");
12153        assert!(!ws.command_senders.lock().contains_key(&key), "command sender removed");
12154        assert!(!ws.domain_handles.lock().contains_key(&key), "domain handle removed");
12155    }
12156
12157    /// The board's reserve toggle reaches the store through the command bus
12158    /// (#2423/C): the pick is the user's, and releasing it clears it.
12159    #[tokio::test]
12160    async fn a_board_reservation_dispatches_and_lands() {
12161        use forge_primitives::tasks::{ReservedBy, Task, TaskId, TaskStatus};
12162        let dir = tempdir().expect("tempdir");
12163        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12164        ws.seed_test_project("proj", "/tmp/tp-board-reserve");
12165        ws.install_db_for_test(
12166            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12167        );
12168        ws.push_task(Task {
12169            id: TaskId::from("t-1"),
12170            project_name: "proj".to_owned(),
12171            subject: "subject t-1".to_owned(),
12172            active_form: None,
12173            detail: None,
12174            status: TaskStatus::Pending,
12175            owner: None,
12176            parent: None,
12177            waiting_on: None,
12178            estimate: None,
12179            rank: None,
12180            verify: None,
12181            attested: None,
12182            reserved: None,
12183            links: Vec::new(),
12184            attempt: 0,
12185            archived_at: None,
12186            created_at: std::time::SystemTime::UNIX_EPOCH,
12187            updated_at: std::time::SystemTime::UNIX_EPOCH,
12188        });
12189
12190        ws.dispatch(Command::TaskReserve {
12191            project: "proj".to_owned(),
12192            id: "t-1".to_owned(),
12193            reserved: true,
12194        })
12195        .expect("app-level commands are accepted");
12196        assert_eq!(
12197            ws.tasks_for_project("proj")[0].reserved,
12198            Some(ReservedBy::User),
12199            "the pick is reserved through the board's own command",
12200        );
12201
12202        ws.dispatch(Command::TaskReserve {
12203            project: "proj".to_owned(),
12204            id: "t-1".to_owned(),
12205            reserved: false,
12206        })
12207        .expect("accepted");
12208        assert_eq!(ws.tasks_for_project("proj")[0].reserved, None, "and released");
12209    }
12210
12211    /// The board's reopen reaches the store through the command bus
12212    /// (#2423/B): an attestation is struck, the gate re-minted and the row
12213    /// parked on the user again.
12214    #[tokio::test]
12215    async fn a_board_reopen_dispatches_and_parks_the_row() {
12216        use forge_primitives::tasks::{By, Task, TaskId, TaskStatus, Verify};
12217        let dir = tempdir().expect("tempdir");
12218        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12219        ws.seed_test_project("proj", "/tmp/tp-board-reopen");
12220        ws.install_db_for_test(
12221            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12222        );
12223        let lead = SessionSlot::lead("TestOrg", "proj");
12224        ws.push_task(Task {
12225            id: TaskId::from("epic"),
12226            project_name: "proj".to_owned(),
12227            subject: "the epic".to_owned(),
12228            active_form: None,
12229            detail: None,
12230            status: TaskStatus::Pending,
12231            owner: None,
12232            parent: None,
12233            waiting_on: None,
12234            estimate: None,
12235            rank: None,
12236            verify: None,
12237            attested: None,
12238            reserved: None,
12239            links: Vec::new(),
12240            attempt: 0,
12241            archived_at: None,
12242            created_at: std::time::SystemTime::UNIX_EPOCH,
12243            updated_at: std::time::SystemTime::UNIX_EPOCH,
12244        });
12245        ws.push_task(Task {
12246            id: TaskId::from("t-1"),
12247            project_name: "proj".to_owned(),
12248            subject: "the looked-at child".to_owned(),
12249            active_form: None,
12250            detail: None,
12251            status: TaskStatus::Pending,
12252            owner: None,
12253            parent: Some(TaskId::from("epic")),
12254            waiting_on: None,
12255            estimate: None,
12256            rank: None,
12257            verify: Some(Verify::User),
12258            attested: None,
12259            reserved: None,
12260            links: Vec::new(),
12261            attempt: 0,
12262            archived_at: None,
12263            created_at: std::time::SystemTime::UNIX_EPOCH,
12264            updated_at: std::time::SystemTime::UNIX_EPOCH,
12265        });
12266        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
12267            task.status = TaskStatus::Completed;
12268        })
12269        .expect("no refusal")
12270        .expect("the row is there");
12271        ws.attest_task("proj", &TaskId::from("t-1"), &lead, "looks good")
12272            .expect("no refusal")
12273            .expect("the row is there");
12274
12275        ws.dispatch(Command::TaskReopen { project: "proj".to_owned(), id: "t-1".to_owned() })
12276            .expect("app-level commands are accepted");
12277        let reopened = ws
12278            .tasks_for_project("proj")
12279            .into_iter()
12280            .find(|task| task.id == TaskId::from("t-1"))
12281            .expect("t-1");
12282        assert_eq!(reopened.status, TaskStatus::Waiting, "the row is back on the look");
12283        assert_eq!(reopened.attested, None, "the attestation is struck");
12284        assert_eq!(reopened.verify, Some(Verify::User), "and the gate re-minted");
12285        assert!(
12286            reopened.waiting_on.as_ref().is_some_and(|wait| wait.verification),
12287            "parked on the user: {:?}",
12288            reopened.waiting_on,
12289        );
12290    }
12291
12292    /// The board's edits reach the store through the command bus, and a
12293    /// refusal says so on the service line - a press that did nothing and
12294    /// a press the core refused must not read the same.
12295    #[tokio::test]
12296    async fn a_board_verdict_dispatches_and_a_refusal_says_so() {
12297        use forge_primitives::tasks::{By, Task, TaskId, TaskStatus};
12298        let dir = tempdir().expect("tempdir");
12299        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12300        ws.seed_test_project("proj", "/tmp/tp-board-dispatch");
12301        ws.install_db_for_test(
12302            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12303        );
12304        let sample = |id: &str| Task {
12305            id: TaskId::from(id),
12306            project_name: "proj".to_owned(),
12307            subject: format!("subject {id}"),
12308            active_form: None,
12309            detail: None,
12310            status: TaskStatus::Pending,
12311            owner: None,
12312            // A child, so the approval completes it on the live board -
12313            // a root would close and archive, which is a different test.
12314            parent: Some(TaskId::from("epic")),
12315            waiting_on: None,
12316            estimate: None,
12317            rank: None,
12318            verify: Some(forge_primitives::tasks::Verify::User),
12319            attested: None,
12320            reserved: None,
12321            links: Vec::new(),
12322            attempt: 0,
12323            archived_at: None,
12324            created_at: std::time::SystemTime::UNIX_EPOCH,
12325            updated_at: std::time::SystemTime::UNIX_EPOCH,
12326        };
12327        ws.push_task(sample("t-1"));
12328        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
12329            task.status = TaskStatus::Completed;
12330        })
12331        .expect("no refusal")
12332        .expect("the row is there");
12333
12334        ws.dispatch(Command::TaskVerdict {
12335            project: "proj".to_owned(),
12336            id: "t-1".to_owned(),
12337            approve: true,
12338            words: None,
12339        })
12340        .expect("app-level commands are accepted");
12341        let stored = ws.tasks_for_project("proj");
12342        assert_eq!(stored[0].status, TaskStatus::Completed, "the verdict landed");
12343
12344        // The same verdict again names a row no longer waiting: the refusal
12345        // reaches the service line rather than only the log.
12346        ws.dispatch(Command::TaskVerdict {
12347            project: "proj".to_owned(),
12348            id: "t-1".to_owned(),
12349            approve: true,
12350            words: None,
12351        })
12352        .expect("accepted");
12353        let mut refused = false;
12354        while let Ok(frame) = rx.try_recv() {
12355            if let crate::protocol::SessionUpdate::ServiceStatus { message, .. } =
12356                std::sync::Arc::unwrap_or_clone(frame.update)
12357                && message.contains("refused a verdict")
12358            {
12359                refused = true;
12360            }
12361        }
12362        assert!(refused, "a refused edit is said on the service line");
12363    }
12364
12365    /// The rest of the board's edits say so too, the not-found case included:
12366    /// a rank or an assignment naming a row that has gone, and an assignment
12367    /// naming a project this forge does not carry, are presses whose silence
12368    /// would read as success.
12369    #[tokio::test]
12370    async fn a_board_edit_that_does_not_land_says_so() {
12371        use forge_primitives::tasks::TaskStatus;
12372        let dir = tempdir().expect("tempdir");
12373        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12374        ws.seed_test_project("proj", "/tmp/tp-board-misses");
12375        ws.install_db_for_test(
12376            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12377        );
12378
12379        for (command, named) in [
12380            (
12381                Command::TaskRank {
12382                    project: "proj".to_owned(),
12383                    id: "gone".to_owned(),
12384                    to: crate::protocol::RankMove::Top,
12385                },
12386                "refused a re-order",
12387            ),
12388            (
12389                Command::TaskAssign {
12390                    project: "proj".to_owned(),
12391                    id: "gone".to_owned(),
12392                    owner: Some("lead".to_owned()),
12393                },
12394                "refused an assignment",
12395            ),
12396            (
12397                Command::TaskMove {
12398                    project: "proj".to_owned(),
12399                    id: "gone".to_owned(),
12400                    to: TaskStatus::InProgress,
12401                    place: None,
12402                },
12403                "refused a move",
12404            ),
12405            (
12406                Command::TaskAssign {
12407                    project: "no-such-project".to_owned(),
12408                    id: "gone".to_owned(),
12409                    owner: None,
12410                },
12411                "refused an assignment",
12412            ),
12413        ] {
12414            ws.dispatch(command).expect("app-level commands are accepted");
12415            let mut said = false;
12416            while let Ok(frame) = rx.try_recv() {
12417                if let crate::protocol::SessionUpdate::ServiceStatus { message, .. } =
12418                    std::sync::Arc::unwrap_or_clone(frame.update)
12419                    && message.contains(named)
12420                {
12421                    said = true;
12422                }
12423            }
12424            assert!(said, "{named} was not said on the service line");
12425        }
12426    }
12427
12428    /// **A drag carries its place**: a move with a `place` lands the row at
12429    /// the position the drop chose, and one without it leaves the queue
12430    /// alone - the keyboard's lane step moves state only.
12431    #[tokio::test]
12432    async fn a_board_move_with_a_place_lands_the_row_there() {
12433        use forge_primitives::tasks::{Task, TaskId, TaskStatus};
12434        let dir = tempdir().expect("tempdir");
12435        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12436        ws.seed_test_project("proj", "/tmp/tp-board-place");
12437        ws.install_db_for_test(
12438            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12439        );
12440        let owner = SessionSlot::new("TestOrg", "proj", "w1");
12441        let sample = |id: &str| Task {
12442            id: TaskId::from(id),
12443            project_name: "proj".to_owned(),
12444            subject: format!("subject {id}"),
12445            active_form: None,
12446            detail: None,
12447            status: TaskStatus::Pending,
12448            owner: Some(owner.clone()),
12449            parent: None,
12450            waiting_on: None,
12451            estimate: None,
12452            rank: None,
12453            verify: None,
12454            attested: None,
12455            reserved: None,
12456            links: Vec::new(),
12457            attempt: 0,
12458            archived_at: None,
12459            created_at: std::time::SystemTime::UNIX_EPOCH,
12460            updated_at: std::time::SystemTime::UNIX_EPOCH,
12461        };
12462        for id in ["a", "b", "c"] {
12463            ws.push_task(sample(id));
12464        }
12465        let order = || {
12466            let mut rows = ws.tasks_for_project("proj");
12467            rows.sort_by_key(|t| (t.rank.unwrap_or(i64::MAX), t.created_at));
12468            rows.iter().map(|t| t.id.as_str().to_owned()).collect::<Vec<_>>()
12469        };
12470        assert_eq!(order(), vec!["a", "b", "c"], "the push order is the queue");
12471
12472        // A drop above `a`, coming from across the board: `c` lands first.
12473        ws.dispatch(Command::TaskMove {
12474            project: "proj".to_owned(),
12475            id: "c".to_owned(),
12476            to: TaskStatus::InProgress,
12477            place: Some(crate::protocol::RankMove::Before(Some("a".to_owned()))),
12478        })
12479        .expect("accepted");
12480        assert_eq!(order(), vec!["c", "a", "b"], "the drop IS the priority");
12481
12482        // The control: a step carrying no place moves state only. It moves
12483        // the row in the MIDDLE, so a default that ranks it to either end
12484        // shows - a row already last would be blind to one.
12485        ws.dispatch(Command::TaskMove {
12486            project: "proj".to_owned(),
12487            id: "a".to_owned(),
12488            to: TaskStatus::InProgress,
12489            place: None,
12490        })
12491        .expect("accepted");
12492        assert_eq!(order(), vec!["c", "a", "b"], "a step without a place leaves the queue");
12493    }
12494
12495    /// Every `ChatAppended` frame the stub's stream carries, each with the
12496    /// seat it is addressed to.
12497    fn told(rx: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<(SessionSlot, String)> {
12498        drain_updates(rx)
12499            .into_iter()
12500            .filter_map(|update| match update {
12501                crate::protocol::SessionUpdate::ChatAppended { key, msg, .. } => {
12502                    Some((key, format!("{msg:?}")))
12503                }
12504                _ => None,
12505            })
12506            .collect()
12507    }
12508
12509    /// The single frame a dispatch drew, or a failure naming what arrived.
12510    fn told_one(rx: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> (SessionSlot, String) {
12511        let mut frames = told(rx);
12512        assert_eq!(frames.len(), 1, "exactly one frame: {frames:?}");
12513        frames.pop().expect("one frame")
12514    }
12515
12516    /// **A board edit the user makes tells the row's live owner**, one line -
12517    /// and the answer carries the user's own words, because the notice tells
12518    /// the owner to resume and it should not have to go looking for what was
12519    /// said. Forged, so the line draws the moment it lands. The owner is a
12520    /// worker here and the project's lead is just as live, so the frame's
12521    /// seat is what pins the targeting.
12522    #[tokio::test]
12523    async fn a_board_edit_tells_the_rows_live_owner() {
12524        use forge_primitives::tasks::{Task, TaskId, TaskStatus, Waiting, WaitingKind};
12525        let dir = tempdir().expect("tempdir");
12526        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12527        ws.seed_test_project("proj", "/tmp/tp-board-owner-told");
12528        ws.install_db_for_test(
12529            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12530        );
12531        let owner = SessionSlot::new("TestOrg", "proj", "w1");
12532        let lead = SessionSlot::new("TestOrg", "proj", "lead");
12533        // Both registered (a conn each) so `seat_is_live` reads true: the
12534        // owner holds the rows, and the lead is the seat a fallback would
12535        // reach for.
12536        let _owner_routed = install_fake_session_task(&ws, &owner);
12537        let _lead_routed = install_fake_session_task(&ws, &lead);
12538        let sample = |id: &str, status: TaskStatus| Task {
12539            id: TaskId::from(id),
12540            project_name: "proj".to_owned(),
12541            subject: format!("subject {id}"),
12542            active_form: None,
12543            detail: None,
12544            status,
12545            owner: Some(owner.clone()),
12546            parent: None,
12547            waiting_on: None,
12548            estimate: None,
12549            rank: None,
12550            verify: None,
12551            attested: None,
12552            reserved: None,
12553            links: Vec::new(),
12554            attempt: 0,
12555            archived_at: None,
12556            created_at: std::time::SystemTime::UNIX_EPOCH,
12557            updated_at: std::time::SystemTime::UNIX_EPOCH,
12558        };
12559        let question = || Waiting {
12560            kind: Some(WaitingKind::Decision),
12561            detail: None,
12562            on: None,
12563            verification: false,
12564            parked: false,
12565            since: None,
12566        };
12567
12568        // The wait, answered: the words ride the notice, and it goes to the
12569        // row's owner rather than to the live lead.
12570        let mut waiting = sample("t-1", TaskStatus::Waiting);
12571        waiting.waiting_on = Some(question());
12572        ws.push_task(waiting);
12573        ws.dispatch(Command::TaskAnswer {
12574            project: "proj".to_owned(),
12575            id: "t-1".to_owned(),
12576            words: "get going".to_owned(),
12577        })
12578        .expect("app-level commands are accepted");
12579        let (key, text) = told_one(&mut rx);
12580        assert_eq!(key, owner, "the owner hears it, not the lead");
12581        assert!(text.contains("get going"), "the words ride the notice: {text}");
12582        assert!(text.contains("answered"), "and it says what happened: {text}");
12583
12584        // An answer with no words says so rather than pointing at a detail
12585        // the empty append never filled.
12586        let mut quiet = sample("t-2", TaskStatus::Waiting);
12587        quiet.waiting_on = Some(question());
12588        ws.push_task(quiet);
12589        ws.dispatch(Command::TaskAnswer {
12590            project: "proj".to_owned(),
12591            id: "t-2".to_owned(),
12592            words: String::new(),
12593        })
12594        .expect("accepted");
12595        let (_, text) = told_one(&mut rx);
12596        assert!(text.contains("no words"), "an empty answer is said plainly: {text}");
12597
12598        // A send-back carries the words too.
12599        let mut sent = sample("t-3", TaskStatus::Waiting);
12600        sent.waiting_on = Some(Waiting { verification: true, ..question() });
12601        ws.push_task(sent);
12602        ws.dispatch(Command::TaskVerdict {
12603            project: "proj".to_owned(),
12604            id: "t-3".to_owned(),
12605            approve: false,
12606            words: Some("narrow it".to_owned()),
12607        })
12608        .expect("accepted");
12609        let (_, text) = told_one(&mut rx);
12610        assert!(text.contains("narrow it"), "a send-back carries the words: {text}");
12611        assert!(text.contains("came back"), "and names what happened: {text}");
12612
12613        // A send-back with no words at all says so, the same as the answer.
12614        let mut bare = sample("t-6", TaskStatus::Waiting);
12615        bare.waiting_on = Some(Waiting { verification: true, ..question() });
12616        ws.push_task(bare);
12617        ws.dispatch(Command::TaskVerdict {
12618            project: "proj".to_owned(),
12619            id: "t-6".to_owned(),
12620            approve: false,
12621            words: None,
12622        })
12623        .expect("accepted");
12624        let (_, text) = told_one(&mut rx);
12625        assert!(text.contains("no words"), "an empty send-back is said plainly: {text}");
12626
12627        // A re-order that lands the row where it started is not an edit: the
12628        // queue is unchanged and nobody is told.
12629        let mut queued = sample("t-4", TaskStatus::Pending);
12630        queued.rank = Some(0);
12631        ws.push_task(queued);
12632        ws.dispatch(Command::TaskRank {
12633            project: "proj".to_owned(),
12634            id: "t-4".to_owned(),
12635            to: crate::protocol::RankMove::Up,
12636        })
12637        .expect("accepted");
12638        assert!(told(&mut rx).is_empty(), "no change to the queue, no notice");
12639
12640        // And a re-order that does move the row is told: the owner hears it.
12641        let beside = sample("t-5", TaskStatus::Pending);
12642        ws.push_task(beside);
12643        ws.dispatch(Command::TaskRank {
12644            project: "proj".to_owned(),
12645            id: "t-5".to_owned(),
12646            to: crate::protocol::RankMove::Up,
12647        })
12648        .expect("accepted");
12649        let (_, text) = told_one(&mut rx);
12650        assert!(text.contains("re-ordered"), "the owner hears the re-order: {text}");
12651    }
12652
12653    /// A row with no owner tells nobody even though the project's lead is
12654    /// live: the notice goes to the row's owner, and there is no fallback.
12655    #[tokio::test]
12656    async fn a_board_edit_on_an_ownerless_row_tells_nobody() {
12657        use forge_primitives::tasks::{Task, TaskId, TaskStatus};
12658        let dir = tempdir().expect("tempdir");
12659        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12660        ws.seed_test_project("proj", "/tmp/tp-board-ownerless");
12661        ws.install_db_for_test(
12662            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12663        );
12664        // Live, so silence can only be the ownerless hold, never a missing
12665        // recipient.
12666        let lead = SessionSlot::new("TestOrg", "proj", "lead");
12667        let _lead_routed = install_fake_session_task(&ws, &lead);
12668        ws.push_task(Task {
12669            id: TaskId::from("t-1"),
12670            project_name: "proj".to_owned(),
12671            subject: "nobody holds this".to_owned(),
12672            active_form: None,
12673            detail: None,
12674            status: TaskStatus::InProgress,
12675            owner: None,
12676            parent: None,
12677            waiting_on: None,
12678            estimate: None,
12679            rank: None,
12680            verify: None,
12681            attested: None,
12682            reserved: None,
12683            links: Vec::new(),
12684            attempt: 0,
12685            archived_at: None,
12686            created_at: std::time::SystemTime::UNIX_EPOCH,
12687            updated_at: std::time::SystemTime::UNIX_EPOCH,
12688        });
12689        ws.dispatch(Command::TaskMove {
12690            project: "proj".to_owned(),
12691            id: "t-1".to_owned(),
12692            to: TaskStatus::Waiting,
12693            place: None,
12694        })
12695        .expect("accepted");
12696        assert!(
12697            told(&mut rx).is_empty(),
12698            "an ownerless row has nobody to tell, and the board is where the move shows",
12699        );
12700    }
12701
12702    /// A sleeping owner is not a recipient either, with the lead live: the
12703    /// row waits for its owner to read the board.
12704    #[tokio::test]
12705    async fn a_board_edit_on_a_sleeping_owners_row_tells_nobody() {
12706        use forge_primitives::tasks::{Task, TaskId, TaskStatus};
12707        let dir = tempdir().expect("tempdir");
12708        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12709        ws.seed_test_project("proj", "/tmp/tp-board-sleeping-owner");
12710        ws.install_db_for_test(
12711            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12712        );
12713        let lead = SessionSlot::new("TestOrg", "proj", "lead");
12714        let _lead_routed = install_fake_session_task(&ws, &lead);
12715        ws.push_task(Task {
12716            id: TaskId::from("t-1"),
12717            project_name: "proj".to_owned(),
12718            subject: "its owner is asleep".to_owned(),
12719            active_form: None,
12720            detail: None,
12721            status: TaskStatus::InProgress,
12722            owner: Some(SessionSlot::new("TestOrg", "proj", "sleeper")),
12723            parent: None,
12724            waiting_on: None,
12725            estimate: None,
12726            rank: None,
12727            verify: None,
12728            attested: None,
12729            reserved: None,
12730            links: Vec::new(),
12731            attempt: 0,
12732            archived_at: None,
12733            created_at: std::time::SystemTime::UNIX_EPOCH,
12734            updated_at: std::time::SystemTime::UNIX_EPOCH,
12735        });
12736        ws.dispatch(Command::TaskMove {
12737            project: "proj".to_owned(),
12738            id: "t-1".to_owned(),
12739            to: TaskStatus::Waiting,
12740            place: None,
12741        })
12742        .expect("accepted");
12743        assert!(told(&mut rx).is_empty(), "a sleeping owner is told when it wakes, from the board");
12744    }
12745
12746    /// **A drag is told by the lane the row LANDED in, not the one it asked
12747    /// for**: the verify gate turns a drag into `completed` on a verify=user
12748    /// row into a wait, and `pending` reads as the board's own `backlog`.
12749    #[tokio::test]
12750    async fn a_board_move_is_told_by_its_landed_lane() {
12751        use forge_primitives::tasks::{Task, TaskId, TaskStatus, Verify};
12752        let dir = tempdir().expect("tempdir");
12753        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12754        ws.seed_test_project("proj", "/tmp/tp-board-landed-lane");
12755        ws.install_db_for_test(
12756            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12757        );
12758        let owner = SessionSlot::new("TestOrg", "proj", "w1");
12759        let _owner_routed = install_fake_session_task(&ws, &owner);
12760        let sample = |id: &str, verify: Option<Verify>| Task {
12761            id: TaskId::from(id),
12762            project_name: "proj".to_owned(),
12763            subject: format!("subject {id}"),
12764            active_form: None,
12765            detail: None,
12766            status: TaskStatus::InProgress,
12767            owner: Some(owner.clone()),
12768            parent: None,
12769            waiting_on: None,
12770            estimate: None,
12771            rank: None,
12772            verify,
12773            attested: None,
12774            reserved: None,
12775            links: Vec::new(),
12776            attempt: 0,
12777            archived_at: None,
12778            created_at: std::time::SystemTime::UNIX_EPOCH,
12779            updated_at: std::time::SystemTime::UNIX_EPOCH,
12780        };
12781
12782        // Backlog is the board's word for `Pending`, and the notice wears it.
12783        ws.push_task(sample("t-1", None));
12784        ws.dispatch(Command::TaskMove {
12785            project: "proj".to_owned(),
12786            id: "t-1".to_owned(),
12787            to: TaskStatus::Pending,
12788            place: None,
12789        })
12790        .expect("accepted");
12791        let (key, text) = told_one(&mut rx);
12792        assert_eq!(key, owner);
12793        assert!(text.contains("backlog"), "the lane's own word: {text}");
12794        assert!(!text.contains("pending"), "not the state's spelling: {text}");
12795
12796        // The verify gate holds the user's own drag too: completed lands in
12797        // waiting, and the notice follows the row, not the drop.
12798        ws.push_task(sample("t-2", Some(Verify::User)));
12799        ws.dispatch(Command::TaskMove {
12800            project: "proj".to_owned(),
12801            id: "t-2".to_owned(),
12802            to: TaskStatus::Completed,
12803            place: None,
12804        })
12805        .expect("accepted");
12806        let (_, text) = told_one(&mut rx);
12807        assert!(text.contains("moved") && text.contains("waiting"), "the landed lane: {text}");
12808        assert!(!text.contains("completed"), "not the asked one: {text}");
12809    }
12810
12811    /// **An assignment tells both ends of the hand-over** - the seat that
12812    /// lost the row and the one that got it - and a re-assignment to the
12813    /// same owner tells nobody, because nothing changed.
12814    #[tokio::test]
12815    async fn a_board_assignment_tells_both_ends() {
12816        use forge_primitives::tasks::{Task, TaskId, TaskStatus};
12817        let dir = tempdir().expect("tempdir");
12818        // The assign arm resolves the project through `config.projects`,
12819        // which the test overlay does not populate - so this stub carries a
12820        // real forge.toml.
12821        let path = dir.path().join("proj");
12822        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
12823        fs::write(
12824            forge_dir.join("forge.toml"),
12825            format!(
12826                r#"
12827[[accounts]]
12828display_name = "Stargate"
12829token = "t"
12830models = ["claude-sonnet-5"]
12831provider = "anthropic"
12832
12833[[orgs]]
12834name = "TestOrg"
12835accounts = ["Stargate"]
12836[[orgs.projects]]
12837name = "proj"
12838path = "{}"
12839"#,
12840                path.display()
12841            ),
12842        )
12843        .expect("write forge.toml");
12844        let config = crate::config::load_from_dir(dir.path()).expect("load config");
12845        let (ws, mut rx) = Workspace::testing_stub_with_config(dir.path().to_owned(), config)
12846            .expect("the stub config is well-formed");
12847        ws.install_db_for_test(
12848            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12849        );
12850        let lead = SessionSlot::new("TestOrg", "proj", "lead");
12851        let worker = SessionSlot::new("TestOrg", "proj", "w1");
12852        let _lead_routed = install_fake_session_task(&ws, &lead);
12853        let _worker_routed = install_fake_session_task(&ws, &worker);
12854        ws.push_task(Task {
12855            id: TaskId::from("t-1"),
12856            project_name: "proj".to_owned(),
12857            subject: "subject t-1".to_owned(),
12858            active_form: None,
12859            detail: None,
12860            status: TaskStatus::InProgress,
12861            owner: Some(lead.clone()),
12862            parent: None,
12863            waiting_on: None,
12864            estimate: None,
12865            rank: None,
12866            verify: None,
12867            attested: None,
12868            reserved: None,
12869            links: Vec::new(),
12870            attempt: 0,
12871            archived_at: None,
12872            created_at: std::time::SystemTime::UNIX_EPOCH,
12873            updated_at: std::time::SystemTime::UNIX_EPOCH,
12874        });
12875        ws.dispatch(Command::TaskAssign {
12876            project: "proj".to_owned(),
12877            id: "t-1".to_owned(),
12878            owner: Some("w1".to_owned()),
12879        })
12880        .expect("accepted");
12881        let mut frames = told(&mut rx).into_iter();
12882        let (first_key, first_text) = frames.next().expect("the seat that lost it is told");
12883        assert_eq!(first_key, lead, "the one who lost the row hears first");
12884        assert!(first_text.contains("took"), "and hears what happened: {first_text}");
12885        let (second_key, second_text) = frames.next().expect("the seat that got it is told");
12886        assert_eq!(second_key, worker, "then the one who got it");
12887        assert!(second_text.contains("gave"), "hearing what happened: {second_text}");
12888        assert!(frames.next().is_none(), "and exactly the two ends");
12889
12890        // The same owner again: nothing changed, nobody is told.
12891        ws.dispatch(Command::TaskAssign {
12892            project: "proj".to_owned(),
12893            id: "t-1".to_owned(),
12894            owner: Some("w1".to_owned()),
12895        })
12896        .expect("accepted");
12897        assert!(told(&mut rx).is_empty(), "a re-assignment to the same owner is not an edit");
12898    }
12899
12900    fn usage_workspace() -> (tempfile::TempDir, Arc<Workspace>) {
12901        let dir = tempdir().expect("tempdir");
12902        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
12903        ws.install_db_for_test(
12904            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
12905        );
12906        (dir, ws)
12907    }
12908
12909    #[test]
12910    fn scan_usage_rolls_up_lifetime_and_dedups() {
12911        let (dir, ws) = usage_workspace();
12912        let slug_dir = dir.path().join("projects").join("-slug");
12913        std::fs::create_dir_all(&slug_dir).expect("mkdir");
12914        let rec = |id: &str, model: &str, out: u64| {
12915            format!(
12916                r#"{{"type":"assistant","timestamp":"2026-07-08T09:30:34.184Z","message":{{"id":"{id}","model":"{model}","usage":{{"output_tokens":{out}}}}}}}"#
12917            )
12918        };
12919        // "a" appears twice (a resume re-log) and must count once; "b"
12920        // lands on a second model in the same project.
12921        std::fs::write(
12922            slug_dir.join("s.jsonl"),
12923            [rec("a", "m", 10), rec("a", "m", 10), rec("b", "n", 5)].join("\n"),
12924        )
12925        .expect("write");
12926
12927        let report = ws.scan_usage();
12928        assert_eq!(report.lifetime.total.output, 15, "duplicate id counted once");
12929        let m = report.lifetime.by_model.iter().find(|r| r.label == "m").expect("m row");
12930        assert_eq!(m.output, 10, "the re-logged duplicate is not double-counted");
12931        assert_eq!(
12932            report.lifetime.by_model.iter().find(|r| r.label == "n").expect("n row").output,
12933            5,
12934        );
12935        assert_eq!(report.lifetime.by_project.len(), 1, "one project folds from one slug");
12936        assert_eq!(report.lifetime.by_project[0].output, 15);
12937    }
12938
12939    #[test]
12940    fn scan_usage_reuses_cached_summary_for_unchanged_file() {
12941        let (dir, ws) = usage_workspace();
12942        let slug_dir = dir.path().join("projects").join("-slug");
12943        std::fs::create_dir_all(&slug_dir).expect("mkdir");
12944        std::fs::write(
12945            slug_dir.join("s.jsonl"),
12946            r#"{"type":"assistant","timestamp":"2026-07-08T00:00:00Z","message":{"id":"a","model":"m","usage":{"output_tokens":10}}}"#,
12947        )
12948        .expect("write");
12949
12950        // First scan parses and caches the file.
12951        let _ = ws.scan_usage();
12952
12953        // Poison the cache under the exact key scan_usage uses, keeping
12954        // the file's real mtime/size so the reuse condition holds. A
12955        // second scan returning the poison proves it did not re-parse.
12956        let canonical = std::fs::canonicalize(dir.path().join("projects")).expect("canon");
12957        let file = forge_agent::env::token_usage::usage_files(&canonical)
12958            .into_iter()
12959            .next()
12960            .expect("one file");
12961        let meta = std::fs::metadata(&file).expect("meta");
12962        let mut days = std::collections::BTreeMap::new();
12963        days.insert(
12964            "2026-07-08".to_owned(),
12965            forge_agent::env::token_usage::TokenCounts {
12966                output: 999,
12967                ..forge_agent::env::token_usage::TokenCounts::default()
12968            },
12969        );
12970        let mut by_model_day = std::collections::BTreeMap::new();
12971        by_model_day.insert("POISON".to_owned(), days);
12972        ws.store_usage_summary(
12973            &file.to_string_lossy(),
12974            &forge_agent::env::token_usage::FileUsageSummary {
12975                mtime: meta.modified().expect("mtime"),
12976                size: meta.len(),
12977                folded_project: "slug".to_owned(),
12978                project_resolved: true,
12979                by_model_day,
12980            },
12981        );
12982
12983        let report = ws.scan_usage();
12984        assert!(
12985            report.lifetime.by_model.iter().any(|r| r.label == "POISON"),
12986            "an unchanged file reuses the cached summary instead of re-parsing",
12987        );
12988    }
12989
12990    #[test]
12991    fn scan_usage_reparses_a_changed_file() {
12992        let (dir, ws) = usage_workspace();
12993        let slug_dir = dir.path().join("projects").join("-slug");
12994        std::fs::create_dir_all(&slug_dir).expect("mkdir");
12995        let path = slug_dir.join("s.jsonl");
12996        let rec = |id: &str, out: u64| {
12997            format!(
12998                r#"{{"type":"assistant","timestamp":"2026-07-08T09:30:34.184Z","message":{{"id":"{id}","model":"m","usage":{{"output_tokens":{out}}}}}}}"#
12999            )
13000        };
13001        std::fs::write(&path, rec("a", 10)).expect("write");
13002        assert_eq!(ws.scan_usage().lifetime.total.output, 10);
13003
13004        // Appending a record grows the file, so the cached summary's size
13005        // no longer matches and the file must be re-parsed - otherwise
13006        // "usage never updates" until a restart.
13007        std::fs::write(&path, [rec("a", 10), rec("b", 5)].join("\n")).expect("rewrite");
13008        assert_eq!(
13009            ws.scan_usage().lifetime.total.output,
13010            15,
13011            "a changed file is re-parsed, not served stale from the cache",
13012        );
13013    }
13014
13015    #[test]
13016    fn scan_usage_re_derives_only_an_unresolved_project_label() {
13017        let (dir, ws) = usage_workspace();
13018        let rec = r#"{"type":"assistant","timestamp":"2026-07-08T09:30:34.184Z","message":{"id":"a","model":"m","usage":{"output_tokens":10}}}"#;
13019        for slug in ["-guessed", "-settled"] {
13020            let slug_dir = dir.path().join("projects").join(slug);
13021            std::fs::create_dir_all(&slug_dir).expect("mkdir");
13022            std::fs::write(slug_dir.join("s.jsonl"), rec).expect("write");
13023        }
13024        let _ = ws.scan_usage();
13025
13026        // Poison both cached labels, keeping each file's real mtime/size
13027        // so the reuse condition still holds. The unresolved row stands
13028        // for a label guessed while the repo was not checked out.
13029        let canonical = std::fs::canonicalize(dir.path().join("projects")).expect("canon");
13030        for file in forge_agent::env::token_usage::usage_files(&canonical) {
13031            let mut summary = ws
13032                .load_usage_summary(&file.to_string_lossy())
13033                .expect("the first scan cached this file");
13034            let guessed = file.to_string_lossy().contains("-guessed");
13035            summary.folded_project =
13036                if guessed { "GUESS-POISON" } else { "SETTLED-POISON" }.to_owned();
13037            summary.project_resolved = !guessed;
13038            ws.store_usage_summary(&file.to_string_lossy(), &summary);
13039        }
13040
13041        let labels: Vec<String> =
13042            ws.scan_usage().lifetime.by_project.into_iter().map(|row| row.label).collect();
13043        assert!(
13044            labels.iter().any(|l| l == "guessed") && !labels.iter().any(|l| l == "GUESS-POISON"),
13045            "a guessed label is re-derived on cache reuse, so it heals: {labels:?}",
13046        );
13047        assert!(
13048            labels.iter().any(|l| l == "SETTLED-POISON"),
13049            "a settled label is trusted from cache, not re-derived: {labels:?}",
13050        );
13051    }
13052
13053    #[test]
13054    fn pricing_is_fresh_only_within_the_daily_window() {
13055        let (_dir, ws) = usage_workspace();
13056        assert!(!ws.pricing_is_fresh(), "no cache is not fresh");
13057        ws.store_pricing(&crate::store::pricing::CachedPricing {
13058            fetched_at: std::time::SystemTime::now(),
13059            json: r#"{"m":{"input_cost_per_token":1,"output_cost_per_token":1}}"#.to_owned(),
13060        });
13061        assert!(ws.pricing_is_fresh(), "a just-now fetch is within the window");
13062        ws.store_pricing(&crate::store::pricing::CachedPricing {
13063            fetched_at: std::time::SystemTime::now()
13064                - std::time::Duration::from_secs(2 * 24 * 60 * 60),
13065            json: "{}".to_owned(),
13066        });
13067        assert!(!ws.pricing_is_fresh(), "a two-day-old fetch is stale and re-fetched");
13068    }
13069
13070    // -- /model pricing cache ----------------------------------------
13071
13072    #[test]
13073    fn store_fresh_pricing_keeps_a_good_cache_on_a_garbage_response() {
13074        let (_dir, ws) = usage_workspace();
13075        let good = r#"{"m":{"input_cost_per_token":0.001,"output_cost_per_token":0.002}}"#;
13076        assert!(ws.store_fresh_pricing(good.to_owned()), "a valid table stores");
13077        assert!(!ws.load_pricing().is_empty(), "the cache holds the priced model");
13078        // A garbage 200 parses empty and must NOT wipe the good cache.
13079        assert!(!ws.store_fresh_pricing("not json".to_owned()), "garbage is rejected");
13080        assert!(!ws.load_pricing().is_empty(), "the good cache survives the garbage response");
13081    }
13082
13083    #[test]
13084    fn project_name_for_path_resolves_by_cwd_and_degrades_cleanly() {
13085        let dir = tempdir().expect("tempdir");
13086        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
13087
13088        let path = "/tmp/cron-project-path-proj";
13089        ws.seed_test_project("cronproj", path);
13090
13091        // The escalation guard for the one-time Connected stamp: a clean
13092        // project-root cwd MUST resolve the name. If this ever returns
13093        // None the prefix logic itself is broken, not the input cwd.
13094        assert_eq!(
13095            ws.project_name_for_path(path).as_deref(),
13096            Some("cronproj"),
13097            "a clean project-root cwd resolves the project name",
13098        );
13099        assert_eq!(
13100            ws.project_name_for_path(&format!("{path}/.claude/worktrees/reviewer")).as_deref(),
13101            Some("cronproj"),
13102            "a worktree worker's cwd resolves to its parent project",
13103        );
13104        assert!(
13105            ws.project_name_for_path("/tmp/no-such-configured-project").is_none(),
13106            "a cwd mapping to no configured project resolves to no name",
13107        );
13108        assert!(ws.project_name_for_path("").is_none(), "a blank cwd resolves to no name");
13109    }
13110
13111    /// Regression guard for symmetric matching: the project root exists
13112    /// on disk under a symlinked ancestor (`link` -> `real`, so the root
13113    /// canonicalizes to a different path) while the queried worktree
13114    /// subdir does NOT exist. A per-side canonicalize would resolve the
13115    /// root but leave the absent subdir lexical, so the two would stop
13116    /// sharing a prefix and the tab's SCHEDULES / GOTIFY would go blank.
13117    /// Lexical matching keeps both sides in the same form.
13118    #[test]
13119    #[cfg(unix)]
13120    fn project_name_for_path_resolves_absent_worktree_under_symlinked_root() {
13121        let dir = tempdir().expect("tempdir");
13122        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
13123
13124        std::fs::create_dir_all(dir.path().join("real").join("proj")).expect("create real root");
13125        let link = dir.path().join("link");
13126        std::os::unix::fs::symlink(dir.path().join("real"), &link).expect("symlink");
13127        let root = link.join("proj");
13128        ws.seed_test_project("symproj", &root.to_string_lossy());
13129
13130        let absent_worktree = root.join(".claude").join("worktrees").join("reviewer");
13131        assert_eq!(
13132            ws.project_name_for_path(&absent_worktree.to_string_lossy()).as_deref(),
13133            Some("symproj"),
13134            "an absent worktree subdir under a symlinked-ancestor root resolves to its parent",
13135        );
13136    }
13137
13138    /// Buffer tracing output so the applied record can be read back.
13139    #[derive(Clone, Default)]
13140    struct LogCapture(Arc<Mutex<Vec<u8>>>);
13141
13142    impl std::io::Write for LogCapture {
13143        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
13144            self.0.lock().extend_from_slice(buf);
13145            Ok(buf.len())
13146        }
13147        fn flush(&mut self) -> std::io::Result<()> {
13148            Ok(())
13149        }
13150    }
13151
13152    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
13153        type Writer = Self;
13154        fn make_writer(&'a self) -> Self::Writer {
13155            self.clone()
13156        }
13157    }
13158
13159    /// A stuck one-shot is re-evaluated on every 60s tick, so its warning
13160    /// has to be given once rather than once a minute for as long as the
13161    /// directory is missing. The repeat is still recorded, at debug, under
13162    /// the same `event_name`, so one filter still finds the whole story.
13163    ///
13164    /// The clearing is the half that matters. The state holds only the
13165    /// last pass's answer, so a cron that goes unwakeable AGAIN warns
13166    /// again; a set that never cleared would leave a durable cron that IS
13167    /// firing silent, which is the failure this family of changes exists
13168    /// to remove.
13169    ///
13170    /// Runs real fire passes under a log capture, which is why it sits
13171    /// beside `LogCapture` rather than with the other cron tests.
13172    #[test]
13173    fn a_stuck_cron_warns_once_then_warns_again_after_it_clears() {
13174        use forge_primitives::cron::{CronEntry, CronId, CronKind};
13175        let (ws, _rx) = Workspace::testing_stub();
13176        let db_dir = tempdir().expect("db dir");
13177        ws.install_db_for_test(
13178            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
13179        );
13180        let project_dir = tempdir().expect("project dir");
13181        ws.seed_test_project("proj", &project_dir.path().to_string_lossy());
13182        let key = ws.project_key_for_name("proj").expect("seeded project");
13183        // A git worker whose worktree is not there, so the wave would skip
13184        // it and no fire can land.
13185        ws.record_worker_row(
13186            &key,
13187            "steward",
13188            "steward-uuid",
13189            "c",
13190            None,
13191            None,
13192            false,
13193            crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
13194            None,
13195        )
13196        .expect("seed the stranded row");
13197
13198        // A whole minute, so the recurring's next `*/5` slot is at least a
13199        // minute later and the middle pass below cannot catch it.
13200        let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(600_000_000);
13201        for (id, kind) in
13202            [("once", CronKind::Once(t0)), ("again", CronKind::Recurring("*/5 * * * *".to_owned()))]
13203        {
13204            ws.push_cron(CronEntry {
13205                id: CronId::from(id),
13206                project_name: "proj".to_owned(),
13207                kind,
13208                prompt: "p".to_owned(),
13209                created_at: std::time::SystemTime::UNIX_EPOCH,
13210                description: None,
13211                last_fire: None,
13212                next_fire: t0,
13213                team_role: Some("steward".to_owned()),
13214                wake: true,
13215            });
13216        }
13217
13218        let log_of = |capture: &LogCapture| String::from_utf8_lossy(&capture.0.lock()).into_owned();
13219        let warns = |capture: &LogCapture| -> usize {
13220            log_of(capture)
13221                .lines()
13222                .filter(|line| line.contains("cron_owner_cannot_be_woken") && line.contains("WARN"))
13223                .count()
13224        };
13225        let pass = |capture: &LogCapture, now: std::time::SystemTime| {
13226            let subscriber = tracing_subscriber::fmt()
13227                .with_max_level(tracing::Level::DEBUG)
13228                .with_writer(capture.clone())
13229                .finish();
13230            tracing::subscriber::with_default(subscriber, || ws.fire_due_crons(now));
13231        };
13232
13233        let first = LogCapture::default();
13234        pass(&first, t0);
13235        assert_eq!(
13236            warns(&first),
13237            2,
13238            "both crons are newly unwakeable, so both warn: {}",
13239            log_of(&first)
13240        );
13241
13242        // Half a minute on: the one-shot is still stuck and still due, and
13243        // the recurring has advanced past this pass entirely.
13244        let second = LogCapture::default();
13245        pass(&second, t0 + std::time::Duration::from_secs(30));
13246        assert_eq!(
13247            warns(&second),
13248            0,
13249            "a cron that was already unwakeable last pass does not warn again: {}",
13250            log_of(&second),
13251        );
13252        assert!(
13253            log_of(&second).contains("cron_owner_cannot_be_woken"),
13254            "and it is still recorded, at debug, under the same event_name: {}",
13255            log_of(&second),
13256        );
13257
13258        // A day on, the recurring is due again with its owner still
13259        // stranded: this time that is new again.
13260        let third = LogCapture::default();
13261        pass(&third, t0 + std::time::Duration::from_secs(86_400));
13262        assert_eq!(
13263            warns(&third),
13264            1,
13265            "a cron unwakeable again after a pass that did not see it must warn again - a \
13266             set that never cleared would leave a firing cron silent: {}",
13267            log_of(&third),
13268        );
13269    }
13270
13271    /// The applied record names the keys a project contributed and must
13272    /// never carry their values - it is always-on, so a widened field
13273    /// writes tokens to disk on every spawn. Asserted on a DIRECT call:
13274    /// the record is emitted in this crate before any subprocess, so
13275    /// this needs no binary and no wait.
13276    #[test]
13277    fn the_applied_record_logs_key_names_and_never_a_value() {
13278        const SENTINEL: &str = "value-must-never-be-logged";
13279        let dir = tempdir().expect("tempdir");
13280        let root = dir.path().join("solo");
13281        fs::create_dir_all(&root).expect("root");
13282        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
13283        fs::write(
13284            forge_dir.join("forge.toml"),
13285            format!(
13286                r#"
13287[[orgs]]
13288name = "Personal"
13289accounts = ["Stargate"]
13290[[orgs.projects]]
13291name = "solo"
13292path = "{root}"
13293env = {{ SOLO_TOKEN = "value-must-never-be-logged" }}
13294[[accounts]]
13295display_name = "Stargate"
13296token = "t"
13297models = ["claude-sonnet-5"]
13298provider = "anthropic"
13299"#,
13300                root = root.display()
13301            ),
13302        )
13303        .expect("write forge.toml");
13304        let config = crate::config::load_from_dir(dir.path()).expect("load config");
13305        let project = config.projects[0].clone();
13306
13307        let capture = LogCapture::default();
13308        let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
13309        tracing::subscriber::with_default(subscriber, || {
13310            session_env_for(&project, &std::collections::HashMap::new());
13311        });
13312        let log = String::from_utf8_lossy(&capture.0.lock()).into_owned();
13313
13314        assert!(log.contains("SOLO_TOKEN"), "the record names the key: {log}");
13315        assert!(!log.contains(SENTINEL), "and never its value: {log}");
13316    }
13317
13318    /// Two projects at one path collide on the session-storage key, so
13319    /// neither can be told apart: an ambiguous target resolves to NO
13320    /// project rather than the first match's, which is what refuses the
13321    /// spawn rather than handing it one twin's env and the other's pin.
13322    /// Second assertion is the control: an unambiguous project still
13323    /// resolves, so the refusal above is about the twins and not about
13324    /// the lookup being broken for every target.
13325    #[test]
13326    fn an_ambiguous_storage_key_resolves_to_no_project() {
13327        let dir = tempdir().expect("tempdir");
13328        let shared = dir.path().join("shared");
13329        let solo = dir.path().join("solo");
13330        fs::create_dir_all(&shared).expect("shared");
13331        fs::create_dir_all(&solo).expect("solo");
13332        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
13333        fs::write(
13334            forge_dir.join("forge.toml"),
13335            format!(
13336                r#"
13337[[orgs]]
13338name = "Personal"
13339accounts = ["Stargate"]
13340[[orgs.projects]]
13341name = "twin-a"
13342path = "{shared}"
13343env = {{ TWIN_TOKEN = "twin-a-secret" }}
13344[[orgs.projects]]
13345name = "twin-b"
13346path = "{shared}"
13347[[orgs.projects]]
13348name = "solo"
13349path = "{solo}"
13350env = {{ SOLO_TOKEN = "solo-secret" }}
13351
13352[[accounts]]
13353display_name = "Stargate"
13354token = "t"
13355models = ["claude-sonnet-5"]
13356provider = "anthropic"
13357"#,
13358                shared = shared.display(),
13359                solo = solo.display()
13360            ),
13361        )
13362        .expect("write forge.toml");
13363        let config = crate::config::load_from_dir(dir.path()).expect("load config");
13364        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_owned(), config)
13365            .expect("the stub config's [[slack]] entries are well-formed");
13366        let key = |p: &std::path::Path| {
13367            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
13368                &p.to_string_lossy(),
13369            )))
13370        };
13371
13372        let _ = key(&shared);
13373        let ambiguous = SessionTarget::FreshInProject {
13374            slot: SessionSlot::worker("TestOrg", "no-such-project", "minted-twin-id"),
13375            cwd: None,
13376        };
13377        assert!(
13378            ws.project_for_target(&ambiguous).is_none(),
13379            "a slot naming no declared project resolves to none, which refuses the spawn",
13380        );
13381
13382        let solo_target = SessionTarget::Named("solo".to_owned());
13383        assert_eq!(
13384            ws.project_for_target(&solo_target).map(|project| project.name),
13385            Some("solo".to_owned()),
13386            "an unambiguous project still resolves with nothing on disk yet",
13387        );
13388    }
13389
13390    /// The case where cwd has nothing to read: a resume of a session
13391    /// whose transcript is gone. The slot names the project, so the
13392    /// project resolves from `forge.toml` with an empty catalog - there
13393    /// is no transcript to carry it.
13394    #[test]
13395    fn a_resume_with_no_transcript_resolves_its_project() {
13396        let dir = tempdir().expect("tempdir");
13397        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
13398        fs::write(
13399            forge_dir.join("forge.toml"),
13400            r#"
13401[[orgs]]
13402name = "Personal"
13403accounts = ["Stargate"]
13404[[orgs.projects]]
13405name = "gone"
13406path = "/tmp/gone"
13407[[accounts]]
13408display_name = "Stargate"
13409token = "t"
13410models = ["claude-sonnet-5"]
13411provider = "anthropic"
13412"#,
13413        )
13414        .expect("write forge.toml");
13415        let config = crate::config::load_from_dir(dir.path()).expect("load config");
13416        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_owned(), config)
13417            .expect("the stub config's [[slack]] entries are well-formed");
13418        assert!(ws.catalog.lock().is_empty(), "no transcript was ever scanned");
13419        let key = SessionSlot::lead("Personal", "gone");
13420
13421        assert_eq!(
13422            ws.project_for_target(&SessionTarget::Session(key)).map(|p| p.name),
13423            Some("gone".to_owned()),
13424            "the project resolves from the slot, with no transcript to read it from",
13425        );
13426    }
13427
13428    /// A mock `claude` that reports the id the spawn was told to resume, so
13429    /// a test can see which session the seat actually runs under. The
13430    /// fixture always names `mock-session-001`; awk rewrites that on each
13431    /// line and flushes, because a buffered init line would stall the
13432    /// handshake instead of arriving.
13433    fn id_preserving_mock(dir: &std::path::Path) -> String {
13434        use std::os::unix::fs::PermissionsExt;
13435
13436        let fixture =
13437            concat!(env!("CARGO_MANIFEST_DIR"), "/../forge-sdk/tests/fixtures/mock_claude.sh");
13438        let path = dir.join("mock_claude_id.sh");
13439        fs::write(
13440            &path,
13441            format!(
13442                r#"#!/bin/sh
13443id=""
13444prev=""
13445for arg in "$@"; do
13446  if [ "$prev" = "--resume" ] || [ "$prev" = "--session-id" ]; then
13447    id="$arg"
13448  fi
13449  prev="$arg"
13450done
13451if [ -z "$id" ]; then id="mock-session-001"; fi
13452"{fixture}" "$@" | awk -v id="$id" '{{ gsub("mock-session-001", id); print; fflush() }}'
13453"#
13454            ),
13455        )
13456        .expect("write the id-preserving mock");
13457        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod the mock");
13458        path.to_string_lossy().into_owned()
13459    }
13460
13461    /// A workspace whose lead slot has no task, a ready account and a mock
13462    /// CLI that reports the resumed id.
13463    fn resume_workspace(dir: &std::path::Path, project: &std::path::Path) -> Arc<Workspace> {
13464        let forge_dir = crate::config::ensure_forge_data_dir(dir).expect("forge dir");
13465        fs::write(
13466            forge_dir.join("forge.toml"),
13467            format!(
13468                r#"
13469[[orgs]]
13470name = "Default"
13471accounts = ["Stargate"]
13472
13473[[orgs.projects]]
13474name = "forge"
13475path = "{}"
13476model = "claude-sonnet-5"
13477
13478[[accounts]]
13479display_name = "Stargate"
13480token = "t"
13481models = ["claude-sonnet-5"]
13482provider = "anthropic"
13483"#,
13484                project.display()
13485            ),
13486        )
13487        .expect("write forge.toml");
13488        let workspace = Arc::new(Workspace::new_for_test(dir.to_owned()).expect("workspace"));
13489        workspace
13490            .install_db_for_test(crate::store::Db::open(&dir.join("db.redb")).expect("open db"));
13491        workspace.seed_test_ready_account("Stargate");
13492        workspace.seed_test_gateway_ready(true);
13493        workspace
13494    }
13495
13496    /// **A `/resume` on a slot whose task has exited must start the seat.**
13497    /// The task leaves with its child, so the forge command lands with no
13498    /// sender; answering `UnknownSession` is the defect the handoff's
13499    /// recovery promises against. The lead goes through the project wake.
13500    #[tokio::test]
13501    async fn a_resume_with_no_task_starts_the_lead_on_the_named_id() {
13502        let dir = tempdir().expect("tempdir");
13503        let project = tempdir().expect("project");
13504        let workspace = resume_workspace(dir.path(), project.path());
13505        forge_agent::set_test_sdk_binary(Some(id_preserving_mock(dir.path())));
13506        let mut updates = workspace.subscribe_mirror();
13507        let key = SessionSlot::lead("Default", "forge");
13508        let known = "11111111-2222-3333-4444-555555555555";
13509
13510        let sent = workspace.dispatch_from_view(Command::Prompt {
13511            key: key.clone(),
13512            text: format!("/resume {known}"),
13513            attachments: Vec::new(),
13514        });
13515        assert!(sent.is_ok(), "the resume is taken rather than refused: {sent:?}");
13516
13517        let connected = tokio::time::timeout(Duration::from_secs(30), async {
13518            while let Some(seat) = updates.recv().await {
13519                if let SessionUpdate::Connected { key: seen, session_id, .. } = &*seat.update
13520                    && *seen == key
13521                {
13522                    return session_id.to_string();
13523                }
13524                if let SessionUpdate::ConnectionFailed { key: seen, message, .. } = &*seat.update
13525                    && *seen == key
13526                {
13527                    panic!("the resume's start failed: {message}");
13528                }
13529            }
13530            panic!("the update stream closed before the resumed seat connected");
13531        })
13532        .await
13533        .expect("the task-less resume reaches Connected");
13534        assert_eq!(connected, known, "the seat runs under the id that was named");
13535        let domain = workspace.domain_session_for(&key).expect("the task registered its domain");
13536        assert_eq!(
13537            domain.lock().session_id.as_ref().map(std::string::ToString::to_string),
13538            Some(known.to_owned()),
13539            "and the domain is the resumed occupant",
13540        );
13541        let stored = {
13542            let db = workspace.db.lock();
13543            let db = db.as_ref().expect("db");
13544            crate::store::sessions::get(db, "Default", "forge", "lead")
13545                .expect("read")
13546                .and_then(|row| row.session_id)
13547        };
13548        assert_eq!(stored.as_deref(), Some(known), "and the row names it for the next boot");
13549        forge_agent::set_test_sdk_binary(None);
13550    }
13551
13552    /// A workspace whose lead can actually spawn, over a mock CLI that
13553    /// reports the id it was told to resume, with the kick dispatcher
13554    /// running so a stamped note would reach the stream.
13555    fn boot_lead_workspace(dir: &std::path::Path, project: &std::path::Path) -> Arc<Workspace> {
13556        let workspace = resume_workspace(dir, project);
13557        forge_agent::set_test_sdk_binary(Some(id_preserving_mock(dir)));
13558        workspace.start_kick_dispatcher();
13559        workspace
13560    }
13561
13562    /// Dispatch a `SpawnProject` for the fixture's lead with the launch
13563    /// settings a caller states. The boot wave passes the restart note for
13564    /// a seat it read as mid-turn; a parked seat and every other wake pass
13565    /// `None`.
13566    fn spawn_lead(workspace: &Arc<Workspace>, force_new: bool, resume_kick: Option<String>) {
13567        let spawned = workspace.dispatch(Command::SpawnProject {
13568            project_name: "forge".to_owned(),
13569            launch_settings: SessionLaunchSettings { force_new, resume_kick, ..Default::default() },
13570        });
13571        assert!(spawned.is_ok(), "the spawn is taken: {spawned:?}");
13572    }
13573
13574    /// Whether `update` is a user turn carrying the restart note on `key`.
13575    fn is_restart_note(update: &SessionUpdate, key: &SessionSlot) -> bool {
13576        if let SessionUpdate::ChatAppended { key: seen, msg: Message::User { message, .. }, .. } =
13577            update
13578            && seen == key
13579        {
13580            matches!(
13581                message.content.first(),
13582                Some(forge_primitives::ContentBlock::Text { text, .. }) if text == LEAD_RESTART_NOTE
13583            )
13584        } else {
13585            false
13586        }
13587    }
13588
13589    /// Watch the stream until the lead connects, noting any restart turn
13590    /// that arrives before or just after it. The extra `note_window`
13591    /// after `Connected` is what makes a negative meaningful: the kick
13592    /// dispatcher fires a first enqueue with no added latency, so a note
13593    /// a first boot or `--new` should not send has to be absent by then.
13594    async fn lead_boot_frames(
13595        updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
13596        key: &SessionSlot,
13597        note_window: Duration,
13598    ) -> (bool, bool) {
13599        let before = tokio::time::timeout(Duration::from_secs(30), async {
13600            let mut note_seen = false;
13601            while let Some(seat) = updates.recv().await {
13602                note_seen |= is_restart_note(&seat.update, key);
13603                if let SessionUpdate::Connected { key: seen, .. } = &*seat.update
13604                    && seen == key
13605                {
13606                    return (true, note_seen);
13607                }
13608            }
13609            (false, note_seen)
13610        })
13611        .await
13612        .expect("the spawn reaches Connected");
13613        if !before.0 {
13614            return before;
13615        }
13616        let after = tokio::time::timeout(note_window, async {
13617            while let Some(seat) = updates.recv().await {
13618                if is_restart_note(&seat.update, key) {
13619                    return true;
13620                }
13621            }
13622            false
13623        })
13624        .await
13625        .unwrap_or(false);
13626        (true, before.1 || after)
13627    }
13628
13629    /// **The first boot on this build seeds a row written before the field
13630    /// and opens it.** The migration is what every existing install relies
13631    /// on: without it the row's state is absent, which the wave reads as a
13632    /// lead that never spawned, and no project reopens after the upgrade.
13633    #[test]
13634    fn the_boot_seeds_a_row_written_before_the_field_and_opens_it() {
13635        use crate::store::sessions::SeatState;
13636
13637        let dir = tempdir().expect("tempdir");
13638        let project = tempdir().expect("project");
13639        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
13640        fs::write(
13641            forge_dir.join("forge.toml"),
13642            format!(
13643                r#"
13644[[orgs]]
13645name = "Default"
13646accounts = ["Stargate"]
13647
13648[[orgs.projects]]
13649name = "forge"
13650path = "{}"
13651model = "claude-sonnet-5"
13652
13653[[accounts]]
13654display_name = "Stargate"
13655token = "t"
13656models = ["claude-sonnet-5"]
13657provider = "anthropic"
13658"#,
13659                project.path().display()
13660            ),
13661        )
13662        .expect("write forge.toml");
13663        // A row of the shape the previous build wrote, in the store the
13664        // workspace will open.
13665        let app_support = dir.path().join("app-support");
13666        std::fs::create_dir_all(app_support.join("stores")).expect("stores dir");
13667        let db = crate::store::Db::open(&crate::store::path_for(&app_support, dir.path()))
13668            .expect("open the scratch store");
13669        crate::store::sessions::put_raw_for_test(
13670            &db,
13671            "Default",
13672            "forge",
13673            "lead",
13674            br#"{"session_id":"id-1"}"#,
13675        )
13676        .expect("plant the pre-change row");
13677        drop(db);
13678
13679        let workspace =
13680            Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
13681        let key = SessionSlot::lead("Default", "forge");
13682
13683        let targets = workspace.boot_wave_targets(None);
13684        assert_eq!(targets.len(), 1, "the seeded seat is in the wave: {targets:?}");
13685        assert_eq!(targets[0].project_name, "forge");
13686        assert_eq!(
13687            targets[0].resume_kick, None,
13688            "a row from before the field has no interrupted turn to report",
13689        );
13690        assert_eq!(
13691            workspace.stored_seat_state(&key),
13692            Some(SeatState::Idle),
13693            "the migration wrote the state the wave then read",
13694        );
13695    }
13696
13697    /// **A bare boot opens exactly the seats that were open.** A mid-turn
13698    /// lead takes the restart note, a parked one is resumed with nothing, a
13699    /// closed one is left shut, and a project whose lead never spawned has
13700    /// no row to open.
13701    #[test]
13702    fn the_boot_wave_opens_the_seats_that_were_open() {
13703        use crate::store::sessions::SeatState;
13704
13705        let dir = tempdir().expect("tempdir");
13706        let project = tempdir().expect("project");
13707        let workspace = resume_workspace(dir.path(), project.path());
13708        let key = SessionSlot::lead("Default", "forge");
13709
13710        assert!(
13711            workspace.boot_wave_targets(None).is_empty(),
13712            "a lead that never spawned has no row to open",
13713        );
13714
13715        workspace.record_session_id("Default", "forge", "lead", "id-1");
13716        workspace.record_seat_state(&key, SeatState::Idle);
13717        let parked = workspace.boot_wave_targets(None);
13718        assert_eq!(parked.len(), 1, "a parked seat is open");
13719        assert_eq!(parked[0].project_name, "forge");
13720        assert_eq!(parked[0].resume_kick, None, "a parked seat is resumed with nothing");
13721
13722        workspace.record_seat_state(&key, SeatState::InTurn);
13723        let interrupted = workspace.boot_wave_targets(None);
13724        assert_eq!(interrupted.len(), 1, "a seat mid-turn is open");
13725        assert_eq!(
13726            interrupted[0].resume_kick.as_deref(),
13727            Some(LEAD_RESTART_NOTE),
13728            "an interrupted turn earns the restart note",
13729        );
13730
13731        workspace.record_seat_state(&key, SeatState::Closed);
13732        assert!(
13733            workspace.boot_wave_targets(None).is_empty(),
13734            "the close door keeps the project shut at the next boot",
13735        );
13736        let named = workspace.boot_wave_targets(Some("forge"));
13737        assert_eq!(named.len(), 1, "naming the project opens it whether or not it was closed");
13738        assert_eq!(named[0].project_name, "forge");
13739        assert_eq!(named[0].resume_kick, None, "a closed seat has no interrupted turn to report");
13740    }
13741
13742    /// **A boot spawn that carries a stored id hands the lead the restart
13743    /// note as its first turn.** This is the whole chain: the launch
13744    /// setting the boot wave sends, the spawn's stored-id gate, and the
13745    /// Connected hook.
13746    #[tokio::test]
13747    async fn a_boot_spawn_kicks_a_resumed_lead_with_the_restart_note() {
13748        let dir = tempdir().expect("tempdir");
13749        let project = tempdir().expect("project");
13750        let workspace = boot_lead_workspace(dir.path(), project.path());
13751        let key = SessionSlot::lead("Default", "forge");
13752        workspace.record_session_id(
13753            "Default",
13754            "forge",
13755            "lead",
13756            "11111111-2222-3333-4444-555555555555",
13757        );
13758
13759        let mut updates = workspace.subscribe_mirror();
13760        spawn_lead(&workspace, false, Some(LEAD_RESTART_NOTE.to_owned()));
13761        let (connected, note) = lead_boot_frames(&mut updates, &key, Duration::from_secs(5)).await;
13762        assert!(connected, "the lead connects before the note arrives");
13763        assert!(note, "a lead with a stored id to resume is kicked with the restart note");
13764        forge_agent::set_test_sdk_binary(None);
13765    }
13766
13767    /// **A first boot carries no stored id**, so the lead it starts gets
13768    /// no restart note even though the boot wave asked for one. The
13769    /// `--new` test pins the same gate from the other side, where an id
13770    /// exists and the launch refuses it.
13771    #[tokio::test]
13772    async fn a_first_boot_lead_gets_no_restart_note() {
13773        let dir = tempdir().expect("tempdir");
13774        let project = tempdir().expect("project");
13775        let workspace = boot_lead_workspace(dir.path(), project.path());
13776        let key = SessionSlot::lead("Default", "forge");
13777
13778        let mut updates = workspace.subscribe_mirror();
13779        spawn_lead(&workspace, false, Some(LEAD_RESTART_NOTE.to_owned()));
13780        let (connected, note) = lead_boot_frames(&mut updates, &key, Duration::from_secs(2)).await;
13781        assert!(connected, "the lead connects, so the absence of a note is the property");
13782        assert!(!note, "a first boot delivers no restart note");
13783        forge_agent::set_test_sdk_binary(None);
13784    }
13785
13786    /// **A spawn leaves the seat open with no turn in flight.** The boot
13787    /// wave read the pre-spawn state to decide whether to kick it; once the
13788    /// child is up and parked, the row says so, and only a prompt moves it.
13789    #[tokio::test]
13790    async fn a_spawn_leaves_the_seat_idle() {
13791        use crate::store::sessions::SeatState;
13792
13793        let dir = tempdir().expect("tempdir");
13794        let project = tempdir().expect("project");
13795        let workspace = boot_lead_workspace(dir.path(), project.path());
13796        let key = SessionSlot::lead("Default", "forge");
13797
13798        let mut updates = workspace.subscribe_mirror();
13799        spawn_lead(&workspace, false, None);
13800        let (connected, note) = lead_boot_frames(&mut updates, &key, Duration::from_secs(2)).await;
13801        assert!(connected, "the spawn reaches Connected");
13802        assert!(!note, "a spawn with no requested note is not kicked");
13803        assert_eq!(
13804            workspace.stored_seat_state(&key),
13805            Some(SeatState::Idle),
13806            "the spawned seat is open and parked",
13807        );
13808        forge_agent::set_test_sdk_binary(None);
13809    }
13810
13811    /// **`--new` mints over the stored session**, so the lead starts fresh
13812    /// even though an id exists, and gets no restart note.
13813    #[tokio::test]
13814    async fn a_force_new_boot_lead_gets_no_restart_note() {
13815        let dir = tempdir().expect("tempdir");
13816        let project = tempdir().expect("project");
13817        let workspace = boot_lead_workspace(dir.path(), project.path());
13818        let key = SessionSlot::lead("Default", "forge");
13819        workspace.record_session_id(
13820            "Default",
13821            "forge",
13822            "lead",
13823            "11111111-2222-3333-4444-555555555555",
13824        );
13825
13826        let mut updates = workspace.subscribe_mirror();
13827        spawn_lead(&workspace, true, Some(LEAD_RESTART_NOTE.to_owned()));
13828        let (connected, note) = lead_boot_frames(&mut updates, &key, Duration::from_secs(2)).await;
13829        assert!(connected, "the lead connects, so the absence of a note is the property");
13830        assert!(!note, "`--new` delivers no restart note over the stored session it replaced");
13831        forge_agent::set_test_sdk_binary(None);
13832    }
13833
13834    /// **A spawn with a stored id but no requested note sends none.** The
13835    /// note is a request the boot wave makes, not something a resume earns:
13836    /// a wake that states ordinary launch settings (a peer prompt, a cron)
13837    /// resumes the seat without kicking it with a restart note. Without
13838    /// this, defaulting the note whenever an id exists passes every test.
13839    #[tokio::test]
13840    async fn a_stored_id_without_a_requested_note_sends_none() {
13841        let dir = tempdir().expect("tempdir");
13842        let project = tempdir().expect("project");
13843        let workspace = boot_lead_workspace(dir.path(), project.path());
13844        let key = SessionSlot::lead("Default", "forge");
13845        workspace.record_session_id(
13846            "Default",
13847            "forge",
13848            "lead",
13849            "11111111-2222-3333-4444-555555555555",
13850        );
13851
13852        let mut updates = workspace.subscribe_mirror();
13853        spawn_lead(&workspace, false, None);
13854        let (connected, note) = lead_boot_frames(&mut updates, &key, Duration::from_secs(2)).await;
13855        assert!(connected, "the lead connects, so the absence of a note is the property");
13856        assert!(!note, "a spawn that was not asked for the note sends none");
13857        forge_agent::set_test_sdk_binary(None);
13858    }
13859
13860    /// **The worker restore dispatches the row's own spawn.** The id named
13861    /// wins over the stale id its row still holds, and the row's charter,
13862    /// kick, tree, families and interactive flag carry the spawn. The boot
13863    /// re-spawn's tests are the pattern for reading the command.
13864    #[tokio::test]
13865    async fn a_resume_with_no_task_dispatches_the_workers_stored_spawn() {
13866        let dir = tempdir().expect("tempdir");
13867        let project = tempdir().expect("project");
13868        let workspace = resume_workspace(dir.path(), project.path());
13869        let project_key = workspace.project_key_for_name("forge").expect("project");
13870        let stale = "33333333-4444-5555-6666-777777777777";
13871        let named = "22222222-3333-4444-5555-666666666666";
13872        workspace
13873            .record_worker_row(
13874                &project_key,
13875                "labourer",
13876                stale,
13877                "the stored charter",
13878                Some("the first kick"),
13879                Some("the stored resume kick"),
13880                true,
13881                crate::mcp::workers::types::WorkerTree {
13882                    is_git_repo: false,
13883                    cwd: Some(project.path().to_string_lossy().into_owned()),
13884                },
13885                Some(&["tasks".to_owned(), "agents".to_owned()]),
13886            )
13887            .expect("seed the worker row");
13888        workspace.enable_test_dispatch_intercept();
13889        let key = SessionSlot::worker("Default", "forge", "labourer");
13890
13891        // The intercept sits on the route, so it would capture the outer
13892        // `ResumeSession` itself; the fields under test are the spawn the
13893        // task-less route dispatches, so drive that route's own call. The
13894        // forged-prompt path is covered by the lead test and the refusals
13895        // below.
13896        workspace
13897            .resume_without_task(&key, named, SessionLaunchSettings::default())
13898            .expect("the restore is accepted");
13899
13900        let dispatched = workspace.drain_test_dispatch_buffer();
13901        let Some(Command::SpawnWorker {
13902            project_key: seen_project,
13903            label,
13904            charter,
13905            resume_existing,
13906            kick,
13907            resume_kick,
13908            interactive,
13909            mcp_families,
13910            cwd,
13911            from_boot_respawn,
13912            ..
13913        }) = dispatched.iter().find(|command| matches!(command, Command::SpawnWorker { .. }))
13914        else {
13915            panic!("the restore dispatches a worker spawn: {dispatched:?}");
13916        };
13917        assert_eq!(seen_project.as_str(), project_key.as_str(), "the row's project");
13918        assert_eq!(label, "labourer");
13919        assert_eq!(
13920            resume_existing.as_deref(),
13921            Some(named),
13922            "the id that was named, not the row's stale one",
13923        );
13924        assert_eq!(charter, "the stored charter");
13925        assert_eq!(
13926            kick.as_deref(),
13927            Some("the stored resume kick"),
13928            "the row's resume kick, not its first kick",
13929        );
13930        assert!(resume_kick.is_none());
13931        assert!(*interactive, "the row's interactive flag");
13932        assert_eq!(
13933            mcp_families.as_ref().map(|families| families.join(",")).as_deref(),
13934            Some("tasks,agents"),
13935            "the row's family selection",
13936        );
13937        assert_eq!(
13938            cwd.as_deref(),
13939            Some(project.path().to_string_lossy().as_ref()),
13940            "the row's tree",
13941        );
13942        assert!(*from_boot_respawn, "a restore carries the boot re-spawn's row semantics");
13943    }
13944
13945    /// **A failed tombstone is replaced, not doubled.** A failed spawn keeps
13946    /// its entry for a worker with no worktree; it is not live, so an insert
13947    /// that ignored it would append a second entry for the slot and the
13948    /// by-slot reads would move the tombstone while the new entry stranded as
13949    /// a phantom. The restore leaves exactly one entry, the live one.
13950    #[tokio::test]
13951    async fn a_worker_resume_replaces_the_failed_tombstone() {
13952        use forge_primitives::WorkerLiveness;
13953
13954        let dir = tempdir().expect("tempdir");
13955        let project = tempdir().expect("project");
13956        let workspace = resume_workspace(dir.path(), project.path());
13957        let project_key = workspace.project_key_for_name("forge").expect("project");
13958        let stale = "33333333-4444-5555-6666-777777777777";
13959        let named = "22222222-3333-4444-5555-666666666666";
13960        workspace
13961            .record_worker_row(
13962                &project_key,
13963                "labourer",
13964                stale,
13965                "the stored charter",
13966                None,
13967                None,
13968                false,
13969                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
13970                None,
13971            )
13972            .expect("seed the worker row");
13973        let slot = SessionSlot::worker("Default", "forge", "labourer");
13974        workspace.insert_live_worker(
13975            &project_key,
13976            crate::mcp::workers::types::WorkerEntry {
13977                label: "labourer".to_owned(),
13978                charter: "the stored charter".to_owned(),
13979                slot: slot.clone(),
13980                session_id: Some(forge_primitives::SessionId::new(stale)),
13981                status: WorkerLiveness::Failed,
13982                spawned_at: std::time::SystemTime::UNIX_EPOCH,
13983                spawned_by: SessionSlot::lead("Default", "forge"),
13984                needs_tag: false,
13985                is_git_repo_at_spawn: false,
13986                cwd: None,
13987                diagnostic: Some("the failed start's reason".to_owned()),
13988                kick: None,
13989            },
13990        );
13991        assert_eq!(workspace.count_live_workers(&project_key), 0, "the tombstone is not live");
13992        forge_agent::set_test_sdk_binary(Some(id_preserving_mock(dir.path())));
13993        let mut updates = workspace.subscribe_mirror();
13994
13995        let sent = workspace.dispatch_from_view(Command::Prompt {
13996            key: slot.clone(),
13997            text: format!("/resume {named}"),
13998            attachments: Vec::new(),
13999        });
14000        assert!(sent.is_ok(), "the resume is taken rather than refused: {sent:?}");
14001
14002        let connected = tokio::time::timeout(Duration::from_secs(30), async {
14003            while let Some(seat) = updates.recv().await {
14004                if let SessionUpdate::Connected { key: seen, session_id, .. } = &*seat.update
14005                    && *seen == slot
14006                {
14007                    return session_id.to_string();
14008                }
14009                if let SessionUpdate::ConnectionFailed { key: seen, message, .. } = &*seat.update
14010                    && *seen == slot
14011                {
14012                    panic!("the restore's start failed: {message}");
14013                }
14014            }
14015            panic!("the update stream closed before the restored worker connected");
14016        })
14017        .await
14018        .expect("the restore reaches Connected");
14019        assert_eq!(connected, named, "the restored worker runs under the named id");
14020
14021        let entries = {
14022            let workers = workspace.live_workers.lock();
14023            workers.get(&project_key).cloned().unwrap_or_default()
14024        };
14025        assert_eq!(entries.len(), 1, "the tombstone is replaced, not doubled: {entries:?}");
14026        assert!(entries[0].is_live(), "and the entry left is the live one, not the tombstone");
14027        assert_eq!(
14028            entries[0].session_id.as_ref().map(std::string::ToString::to_string),
14029            Some(named.to_owned()),
14030            "carrying the resumed id",
14031        );
14032        assert_eq!(workspace.count_live_workers(&project_key), 1, "and one cap slot");
14033        forge_agent::set_test_sdk_binary(None);
14034    }
14035
14036    /// **A refused restore says so.** The spawn handler answers a refusal
14037    /// with its reason; the route surfaces that as a Notice rather than
14038    /// echoing the `/resume` as accepted.
14039    #[tokio::test]
14040    async fn a_refused_worker_restore_says_so() {
14041        use forge_primitives::WorkerLiveness;
14042
14043        let dir = tempdir().expect("tempdir");
14044        let project = tempdir().expect("project");
14045        let workspace = resume_workspace(dir.path(), project.path());
14046        let project_key = workspace.project_key_for_name("forge").expect("project");
14047        let named = "22222222-3333-4444-5555-666666666666";
14048        workspace
14049            .record_worker_row(
14050                &project_key,
14051                "labourer",
14052                "33333333-4444-5555-6666-777777777777",
14053                "the stored charter",
14054                None,
14055                None,
14056                false,
14057                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
14058                None,
14059            )
14060            .expect("seed the worker row");
14061        let slot = SessionSlot::worker("Default", "forge", "labourer");
14062        workspace.insert_live_worker(
14063            &project_key,
14064            crate::mcp::workers::types::WorkerEntry {
14065                label: "labourer".to_owned(),
14066                charter: "the stored charter".to_owned(),
14067                slot: slot.clone(),
14068                session_id: Some(forge_primitives::SessionId::new(named)),
14069                status: WorkerLiveness::Running,
14070                spawned_at: std::time::SystemTime::UNIX_EPOCH,
14071                spawned_by: SessionSlot::lead("Default", "forge"),
14072                needs_tag: false,
14073                is_git_repo_at_spawn: false,
14074                cwd: None,
14075                diagnostic: None,
14076                kick: None,
14077            },
14078        );
14079        let mut updates = workspace.subscribe_mirror();
14080
14081        let sent = workspace.dispatch_from_view(Command::Prompt {
14082            key: slot.clone(),
14083            text: format!("/resume {named}"),
14084            attachments: Vec::new(),
14085        });
14086        assert!(sent.is_ok(), "the route takes the request: {sent:?}");
14087
14088        let said = tokio::time::timeout(Duration::from_secs(5), async {
14089            while let Some(seat) = updates.recv().await {
14090                if let SessionUpdate::Notice { key: seen, severity, text } = &*seat.update
14091                    && *seen == slot
14092                {
14093                    return (*severity, text.clone());
14094                }
14095            }
14096            panic!("the update stream closed before the refusal was announced");
14097        })
14098        .await
14099        .expect("the refusal is announced");
14100        assert_eq!(said.0, NoticeSeverity::Error, "as a failure the reader can act on");
14101        assert!(said.1.contains("the resume was refused"), "and it says what happened: {}", said.1);
14102        assert!(said.1.contains("already live"), "with the handler's own reason: {}", said.1);
14103    }
14104
14105    /// The worker door refuses, by name, what it cannot restore: a project
14106    /// the config does not declare, a label with no row, and a row the store
14107    /// cannot read.
14108    #[tokio::test]
14109    async fn a_resume_with_no_task_refuses_a_worker_it_cannot_restore() {
14110        let dir = tempdir().expect("tempdir");
14111        let project = tempdir().expect("project");
14112        let workspace = resume_workspace(dir.path(), project.path());
14113
14114        let unknown = SessionSlot::worker("Default", "gone", "labourer");
14115        let sent = workspace.dispatch_from_view(Command::Prompt {
14116            key: unknown.clone(),
14117            text: "/resume 22222222-3333-4444-5555-666666666666".to_owned(),
14118            attachments: Vec::new(),
14119        });
14120        assert!(
14121            matches!(sent, Err(DispatchError::UnknownSession(_))),
14122            "an undeclared project is refused: {sent:?}",
14123        );
14124
14125        let absent = SessionSlot::worker("Default", "forge", "absent");
14126        let sent = workspace.dispatch_from_view(Command::Prompt {
14127            key: absent.clone(),
14128            text: "/resume 22222222-3333-4444-5555-666666666666".to_owned(),
14129            attachments: Vec::new(),
14130        });
14131        assert!(
14132            matches!(sent, Err(DispatchError::UnknownSession(_))),
14133            "a label with no row is refused: {sent:?}",
14134        );
14135
14136        // No store at all: the row cannot be read, which is a refusal rather
14137        // than a spawn composed from defaults.
14138        *workspace.db.lock() = None;
14139        let unreadable = SessionSlot::worker("Default", "forge", "labourer");
14140        let sent = workspace.dispatch_from_view(Command::Prompt {
14141            key: unreadable.clone(),
14142            text: "/resume 22222222-3333-4444-5555-666666666666".to_owned(),
14143            attachments: Vec::new(),
14144        });
14145        assert!(
14146            matches!(sent, Err(DispatchError::UnknownSession(_))),
14147            "an unreadable row is refused: {sent:?}",
14148        );
14149    }
14150
14151    /// An update merges only the supplied fields onto the stored row and
14152    /// survives the redb round-trip. It must never create a row: a row
14153    /// means the worker should be alive, so an absent one reports "not
14154    /// updated" rather than bringing a worker into existence at the next
14155    /// lead connect.
14156    #[test]
14157    fn update_worker_row_merges_only_supplied_fields() {
14158        let (ws, _rx) = Workspace::testing_stub();
14159        let dir = tempdir().expect("tempdir");
14160        ws.install_db_for_test(
14161            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14162        );
14163        ws.seed_test_project("forge", "/tmp/update-worker-row");
14164        let project = ws.project_key_for_name("forge").expect("seeded project");
14165        ws.record_worker_row(
14166            &project,
14167            "steward",
14168            "steward-test-id",
14169            "original charter",
14170            Some("original kick"),
14171            Some("original resume"),
14172            false,
14173            crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
14174            None,
14175        )
14176        .expect("seed the row this test then updates");
14177        let stored = |ws: &Arc<Workspace>| {
14178            ws.worker_rows_for_project(&project)
14179                .into_iter()
14180                .find(|w| w.label == "steward")
14181                .expect("row present")
14182        };
14183
14184        assert!(
14185            ws.update_worker_row(
14186                &project,
14187                "steward",
14188                Some("new charter".to_owned()),
14189                None,
14190                None,
14191                None
14192            )
14193            .expect("update succeeds"),
14194            "an existing row reports updated",
14195        );
14196        let row = stored(&ws);
14197        assert_eq!(row.charter.as_deref(), Some("new charter"), "the supplied field changed");
14198        assert_eq!(row.kick.as_deref(), Some("original kick"), "an absent field is untouched");
14199        assert_eq!(
14200            row.resume_kick.as_deref(),
14201            Some("original resume"),
14202            "an absent field is untouched",
14203        );
14204
14205        // The other two fields update independently, and the charter set
14206        // above persists across a second call.
14207        assert!(
14208            ws.update_worker_row(
14209                &project,
14210                "steward",
14211                None,
14212                Some("new kick".to_owned()),
14213                Some("new resume".to_owned()),
14214                None,
14215            )
14216            .expect("second update succeeds"),
14217        );
14218        let row = stored(&ws);
14219        assert_eq!(row.charter.as_deref(), Some("new charter"), "the earlier update survived");
14220        assert_eq!(row.kick.as_deref(), Some("new kick"));
14221        assert_eq!(row.resume_kick.as_deref(), Some("new resume"));
14222
14223        // The family selection follows the same contract: supplied
14224        // replaces, absent keeps, empty resets to every family.
14225        assert!(
14226            ws.update_worker_row(
14227                &project,
14228                "steward",
14229                None,
14230                None,
14231                None,
14232                Some(vec!["cron".to_owned()])
14233            )
14234            .expect("families update succeeds"),
14235        );
14236        assert_eq!(stored(&ws).mcp_families, Some(vec!["cron".to_owned()]));
14237        assert!(
14238            ws.update_worker_row(&project, "steward", None, None, None, None)
14239                .expect("absent families update succeeds"),
14240        );
14241        assert_eq!(stored(&ws).mcp_families, Some(vec!["cron".to_owned()]), "absent keeps");
14242        assert!(
14243            ws.update_worker_row(&project, "steward", None, None, None, Some(Vec::new()))
14244                .expect("reset succeeds"),
14245        );
14246        assert_eq!(stored(&ws).mcp_families, None, "an empty list resets to every family");
14247
14248        assert!(
14249            !ws.update_worker_row(&project, "ghost", Some("c".to_owned()), None, None, None)
14250                .expect("absent row is not an error"),
14251            "no row means not updated",
14252        );
14253        assert!(
14254            ws.worker_rows_for_project(&project).iter().all(|w| w.label != "ghost"),
14255            "a failed update must not create the row",
14256        );
14257    }
14258
14259    fn live_worker_entry(label: &str, key: &str) -> crate::mcp::workers::types::WorkerEntry {
14260        crate::mcp::workers::types::WorkerEntry {
14261            label: label.to_owned(),
14262            charter: "c".to_owned(),
14263            slot: SessionSlot::from_str_for_test(key),
14264            session_id: None,
14265            status: forge_primitives::WorkerLiveness::Running,
14266            spawned_at: std::time::SystemTime::UNIX_EPOCH,
14267            spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
14268            needs_tag: false,
14269            is_git_repo_at_spawn: false,
14270            cwd: None,
14271            diagnostic: None,
14272            kick: None,
14273        }
14274    }
14275
14276    /// The Projects-pane close (`handle_close_worker` -> `teardown_worker`)
14277    /// deletes the persisted dynamic-worker row so it never re-spawns,
14278    /// scoped to the closed label - siblings survive.
14279    #[tokio::test]
14280    async fn projects_pane_close_deletes_persisted_dynamic_worker_row() {
14281        let (ws, _rx) = Workspace::testing_stub();
14282        let dir = tempdir().expect("tempdir");
14283        ws.install_db_for_test(
14284            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14285        );
14286        ws.seed_test_project("forge", "/tmp/pane-close-forge");
14287        let project = ws.project_key_for_name("forge").expect("seeded project");
14288
14289        seed_worker_row(&ws, &project, "reviewer");
14290        seed_worker_row(&ws, &project, "tester");
14291        ws.insert_live_worker(&project, live_worker_entry("reviewer", "worker-1"));
14292        ws.insert_live_worker(&project, live_worker_entry("tester", "worker-2"));
14293
14294        crate::spawn::handle_close_worker(&ws, &project, "reviewer");
14295
14296        let rows = {
14297            let guard = ws.db.lock();
14298            crate::store::sessions::list_for_project(
14299                guard.as_ref().expect("db installed"),
14300                "TestOrg",
14301                "forge",
14302            )
14303            .expect("list")
14304        };
14305        let labels: Vec<&str> = rows.iter().map(|w| w.label.as_str()).collect();
14306        assert_eq!(labels, vec!["tester"], "close deletes only the closed worker's row");
14307    }
14308
14309    /// The `agents__despawn` path (`handle_despawn_worker` ->
14310    /// `teardown_worker`) deletes the persisted dynamic-worker row too.
14311    #[tokio::test]
14312    async fn mcp_despawn_deletes_persisted_dynamic_worker_row() {
14313        let (ws, _rx) = Workspace::testing_stub();
14314        let dir = tempdir().expect("tempdir");
14315        ws.install_db_for_test(
14316            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14317        );
14318        ws.seed_test_project("forge", "/tmp/despawn-forge");
14319        let project = ws.project_key_for_name("forge").expect("seeded project");
14320
14321        seed_worker_row(&ws, &project, "reviewer");
14322        ws.insert_live_worker(&project, live_worker_entry("reviewer", "worker-1"));
14323
14324        let (tx, rx) = tokio::sync::oneshot::channel();
14325        crate::spawn::handle_despawn_worker(&ws, &project, "reviewer", false, tx);
14326        assert!(matches!(rx.await, Ok(crate::protocol::DespawnResult::Despawned { .. })));
14327
14328        let rows = {
14329            let guard = ws.db.lock();
14330            crate::store::sessions::list_for_project(
14331                guard.as_ref().expect("db installed"),
14332                "TestOrg",
14333                "forge",
14334            )
14335            .expect("list")
14336        };
14337        assert!(rows.is_empty(), "despawn deletes the persisted dynamic-worker row");
14338    }
14339
14340    fn worker_cron(
14341        id: &str,
14342        project: &str,
14343        team_role: Option<&str>,
14344    ) -> forge_primitives::CronEntry {
14345        forge_primitives::CronEntry {
14346            id: forge_primitives::CronId::from(id),
14347            project_name: project.to_owned(),
14348            kind: forge_primitives::CronKind::Recurring("0 9 * * *".to_owned()),
14349            prompt: "p".to_owned(),
14350            created_at: std::time::SystemTime::UNIX_EPOCH,
14351            description: None,
14352            last_fire: None,
14353            next_fire: std::time::SystemTime::UNIX_EPOCH,
14354            team_role: team_role.map(str::to_owned),
14355            wake: true,
14356        }
14357    }
14358
14359    #[tokio::test]
14360    async fn teardown_worker_drops_its_crons_and_subs_keeps_others() {
14361        let (ws, _rx) = Workspace::testing_stub();
14362        let dir = tempdir().expect("tempdir");
14363        ws.install_db_for_test(
14364            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
14365        );
14366        ws.seed_test_project("forge", "/tmp/cron-teardown-dyn");
14367        let view_key = ws
14368            .list_projects()
14369            .into_iter()
14370            .find(|v| v.name == "forge")
14371            .map(|v| v.key)
14372            .expect("seeded project view");
14373        ws.insert_live_worker(&view_key, live_worker_entry("scratch", "worker-1"));
14374
14375        let scratch_cron = worker_cron("scratch-cron", "forge", Some("scratch"));
14376        let lead_cron = worker_cron("lead-cron", "forge", None);
14377        let sibling_cron = worker_cron("sibling-cron", "forge", Some("reviewer"));
14378        ws.push_cron(scratch_cron.clone());
14379        ws.push_cron(lead_cron.clone());
14380        ws.push_cron(sibling_cron.clone());
14381        let mut scratch_sub = gotify_sub("forge", &[], None);
14382        scratch_sub.team_role = Some("scratch".to_owned());
14383        let lead_sub = gotify_sub("forge", &[], None);
14384        ws.add_gotify_subscription(scratch_sub.clone(), true);
14385        ws.add_gotify_subscription(lead_sub.clone(), true);
14386
14387        crate::spawn::handle_close_worker(&ws, &view_key, "scratch");
14388
14389        let crons = ws.crons_for_project("forge");
14390        assert!(
14391            crons.iter().all(|c| c.id != scratch_cron.id),
14392            "the dynamic worker's cron is dropped"
14393        );
14394        assert!(crons.iter().any(|c| c.id == lead_cron.id), "the lead cron survives");
14395        assert!(crons.iter().any(|c| c.id == sibling_cron.id), "a sibling worker's cron survives");
14396        let persisted_crons = {
14397            let guard = ws.db.lock();
14398            crate::store::cron::list(guard.as_ref().expect("db installed")).expect("list")
14399        };
14400        assert!(
14401            persisted_crons.iter().all(|c| c.id != scratch_cron.id),
14402            "the cron is dropped from the store too",
14403        );
14404        assert!(
14405            persisted_crons.iter().any(|c| c.id == lead_cron.id),
14406            "the lead cron is still stored"
14407        );
14408
14409        let subs = ws.gotify_subscriptions_for_project("forge");
14410        assert!(subs.iter().all(|s| s.id != scratch_sub.id), "the dynamic worker's sub is dropped");
14411        assert!(subs.iter().any(|s| s.id == lead_sub.id), "the lead sub survives");
14412    }
14413
14414    /// #3: persisting reports failure (rather than swallowing it) when
14415    /// the store is unavailable, so the MCP spawn path can warn the lead
14416    /// that the worker won't survive a restart.
14417    #[test]
14418    fn record_worker_row_errors_when_store_unavailable() {
14419        let (ws, _rx) = Workspace::testing_stub();
14420        // The project resolves, so the only thing left to fail on is the
14421        // store: without this seed an unresolvable key would error too and
14422        // the assertion would hold for the wrong reason.
14423        ws.seed_test_project("forge", "/tmp/row-no-store");
14424        let project = ws.project_key_for_name("forge").expect("seeded project");
14425        // No install_db_for_test: the store is closed for this session.
14426        let error = ws
14427            .record_worker_row(
14428                &project,
14429                "reviewer",
14430                "id",
14431                "charter",
14432                None,
14433                None,
14434                false,
14435                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
14436                None,
14437            )
14438            .expect_err("a closed store must surface a durability failure, not a silent no-op");
14439        assert!(
14440            error.to_string().contains("store is unavailable"),
14441            "and it names the store as the reason, got: {error}",
14442        );
14443    }
14444
14445    fn gotify_sub(
14446        project: &str,
14447        applications: &[&str],
14448        min_priority: Option<u8>,
14449    ) -> forge_primitives::GotifySubscription {
14450        forge_primitives::GotifySubscription {
14451            id: uuid::Uuid::new_v4(),
14452            project: project.to_owned(),
14453            team_role: None,
14454            applications: applications.iter().map(|s| (*s).to_owned()).collect(),
14455            min_priority,
14456            created_at: std::time::SystemTime::UNIX_EPOCH,
14457        }
14458    }
14459
14460    fn gotify_notif(
14461        app: &str,
14462        title: &str,
14463        message: &str,
14464        priority: u8,
14465    ) -> crate::GotifyNotification {
14466        crate::GotifyNotification {
14467            app: app.to_owned(),
14468            title: title.to_owned(),
14469            message: message.to_owned(),
14470            priority,
14471        }
14472    }
14473
14474    /// Drain every currently-queued `SessionUpdate` from the test rx.
14475    fn drain_updates(
14476        rx: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
14477    ) -> Vec<crate::protocol::SessionUpdate> {
14478        let mut out = Vec::new();
14479        while let Ok(frame) = rx.try_recv() {
14480            out.push(std::sync::Arc::unwrap_or_clone(frame.update));
14481        }
14482        out
14483    }
14484
14485    /// Each caller of `subscribe()` gets a stream of its own, so a second
14486    /// view attaches beside the first instead of being refused the one
14487    /// receiver. Catches a revert to the single-take slot, where the
14488    /// second caller receives nothing.
14489    #[tokio::test]
14490    async fn subscribe_hands_every_caller_its_own_stream() {
14491        let dir = make_workspace_dir();
14492        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
14493        let mut first = workspace.subscribe_answerer();
14494        let mut second = workspace.subscribe_answerer();
14495
14496        assert!(
14497            workspace.update_tx().send(SessionUpdate::CatalogLoaded),
14498            "a subscribed workspace delivers",
14499        );
14500
14501        assert!(
14502            matches!(
14503                first.try_recv(),
14504                Ok(frame) if matches!(&*frame.update, SessionUpdate::CatalogLoaded)
14505            ),
14506            "the first subscriber receives the update",
14507        );
14508        assert!(
14509            matches!(
14510                second.try_recv(),
14511                Ok(frame) if matches!(&*frame.update, SessionUpdate::CatalogLoaded)
14512            ),
14513            "the second subscriber receives the same update",
14514        );
14515    }
14516
14517    /// A prompt forge wrote itself and no view drew - a worker kick, an
14518    /// auto-continue - draws as the words the model received: the CLI does
14519    /// not echo a prompt back, and nothing else carries them. The delivery
14520    /// path is the other half of the pair: a delivery's words are drawn by
14521    /// the envelope update its own caller emits, so a bare frame beside it
14522    /// would draw the same turn twice.
14523    #[test]
14524    fn a_forged_prompt_draws_its_words_where_a_delivery_prompt_forges_none() {
14525        let dir = tempdir().expect("tempdir");
14526        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14527        ws.seed_test_project("fkey", "/tmp/f-forged-prompt");
14528        let cwd = project_expanded_path(&ws, "fkey");
14529        ws.record_connected_session(&cwd, "f-uuid", None);
14530        let key = SessionSlot::from_str_for_test("f-uuid");
14531        let (handle, _agent_rx) = Workspace::testing_stub_handle();
14532        ws.pool.lock().insert(
14533            key.clone(),
14534            PooledAgent {
14535                handle: Arc::new(handle),
14536                account: AccountKey("test".to_owned()),
14537                permission_mode: None,
14538                registration: None,
14539                session_id: "pooled-session".to_owned(),
14540            },
14541        );
14542        ws.mark_session_connected_for_test(&key, "f-uuid");
14543        ws.enable_test_dispatch_intercept();
14544
14545        ws.dispatch_forged_prompt(&key, "get on with it".to_owned()).expect("forged dispatch");
14546        let drawn = drain_updates(&mut rx);
14547        let frame = drawn.iter().find_map(|u| match u {
14548            SessionUpdate::ChatAppended { msg, source, .. } => Some((msg, source)),
14549            _ => None,
14550        });
14551        let Some((msg, source)) = frame else {
14552            panic!("a forged prompt draws its words; got {drawn:?}")
14553        };
14554        // **And the source is forge's own**, which is what keeps a parked
14555        // reader off the foot: the frame is the same one a person's send
14556        // forges, so a reader's follow has only this to tell the two apart
14557        // (#2198).
14558        assert!(
14559            matches!(source, Some(PromptSource::Forge)),
14560            "a forged machinery turn is nobody's send, and the frame says whose it is: {source:?}",
14561        );
14562        let Message::User { message, uuid, .. } = msg else {
14563            panic!("a forged prompt draws as the user turn the model received, got {msg:?}")
14564        };
14565        assert!(
14566            matches!(
14567                message.content.first(),
14568                Some(forge_primitives::ContentBlock::Text { text, .. }) if text == "get on with it"
14569            ),
14570            "the frame carries the prose the model received: {message:?}",
14571        );
14572        // **The id says whose words they are**: it is the one fact about the
14573        // sender the CLI carries into the transcript, so a view draws a
14574        // reloaded row as the machinery's own - never the reader's - by its
14575        // prefix alone.
14576        assert!(
14577            uuid.as_deref().is_some_and(|id| id.starts_with("forge_nudge_")),
14578            "a forged dispatch mints the machinery's own id: {uuid:?}",
14579        );
14580
14581        ws.dispatch_workspace_prompt(&key, "[Cron]\n\nrun the summary".to_owned())
14582            .expect("delivery dispatch");
14583        assert!(
14584            !drain_updates(&mut rx).iter().any(|u| matches!(u, SessionUpdate::ChatAppended { .. })),
14585            "a delivery's words are drawn by its own envelope update, so this path forges no frame",
14586        );
14587        // Both dispatches wire one prompt each, and both are the machinery's
14588        // own words: the source `Forge` is what the prefix follows.
14589        let ids: Vec<String> = ws
14590            .drain_test_dispatch_buffer()
14591            .iter()
14592            .filter_map(|command| match command {
14593                Command::PromptUnder { uuid, .. } => Some(uuid.clone()),
14594                _ => None,
14595            })
14596            .collect();
14597        assert_eq!(ids.len(), 2, "both dispatches wired one prompt: {ids:?}");
14598        assert!(
14599            ids.iter().all(|id| id.starts_with("forge_nudge_")),
14600            "a machinery dispatch carries the marker whatever door it took: {ids:?}",
14601        );
14602    }
14603
14604    /// **A cooldown and its thaw each announce the roster.** A project row's
14605    /// `would_bind` is derived from the selection walk, so a home subscriber
14606    /// that hears nothing keeps drawing an account's old bindability. The
14607    /// cooling start is a write the gateway publishes; the thaw is only a
14608    /// clock, so the announcer sleeps to it.
14609    ///
14610    /// **The arrival instants are the evidence that the second frame is the
14611    /// thaw and not a repeat.** A watcher announcing on every wake, or a
14612    /// second watcher the duplicate-start guard failed to stop, delivers its
14613    /// second frame before the reset; only the clock-driven wake lands after.
14614    #[tokio::test]
14615    async fn a_rotation_cool_and_its_thaw_each_announce_the_accounts() {
14616        let dir = tempdir().expect("tempdir");
14617        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14618        assert!(
14619            !ws.test_rotation_announcer_started(),
14620            "premise: no announcer before the boot call",
14621        );
14622        ws.start_rotation_announcer();
14623        ws.start_rotation_announcer();
14624        assert!(
14625            ws.test_rotation_announcer_started(),
14626            "the boot call starts the one watcher, and a second call is a no-op",
14627        );
14628
14629        let reset_at = SystemTime::now()
14630            .duration_since(std::time::UNIX_EPOCH)
14631            .expect("after the epoch")
14632            .as_secs()
14633            + 6;
14634        let thaw = forge_gateway::rotation::reset_instant(reset_at);
14635        ws.gateway.report_probe_limit(&AccountKey("Cooling".to_owned()), Some(reset_at));
14636
14637        let mut arrivals = Vec::new();
14638        // A ~5 s window for the cool's frame, so a loaded machine does not
14639        // red code that is right; the bound covers the thaw behind it.
14640        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
14641        while arrivals.len() < 2 {
14642            let frame = tokio::time::timeout_at(deadline, rx.recv())
14643                .await
14644                .expect("the cool and the thaw both announce inside the bound")
14645                .expect("the update channel stays open");
14646            if matches!(&*frame.update, SessionUpdate::AccountsChanged { .. }) {
14647                arrivals.push(SystemTime::now());
14648            }
14649        }
14650        assert!(
14651            arrivals[0] < thaw,
14652            "the cool is announced when the write lands, before the thaw: {:?}",
14653            arrivals[0],
14654        );
14655        assert!(
14656            arrivals[1] >= thaw,
14657            "the second frame is the thaw the clock drives, not a repeat: {:?} vs {thaw:?}",
14658            arrivals[1],
14659        );
14660    }
14661
14662    /// **The row forge's machinery forges carries the stamp a clock is read
14663    /// from.** The CLI never echoes a prompt, so these rows are the only
14664    /// opening rows a turn has, and a page attaching mid-turn anchors its
14665    /// running clock on the first row carrying a stamp. A view's own send is
14666    /// stamped by the session task that draws it now; that half is pinned
14667    /// beside the frame-order test in `session_task`.
14668    #[test]
14669    fn a_forged_prompt_row_carries_the_stamp_a_clock_starts_from() {
14670        let dir = tempdir().expect("tempdir");
14671        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14672        ws.seed_test_project("skey", "/tmp/f-stamped-prompt");
14673        let cwd = project_expanded_path(&ws, "skey");
14674        ws.record_connected_session(&cwd, "s-uuid", None);
14675        let key = SessionSlot::from_str_for_test("s-uuid");
14676        let (handle, _agent_rx) = Workspace::testing_stub_handle();
14677        ws.pool.lock().insert(
14678            key.clone(),
14679            PooledAgent {
14680                handle: Arc::new(handle),
14681                account: AccountKey("test".to_owned()),
14682                permission_mode: None,
14683                registration: None,
14684                session_id: "pooled-session".to_owned(),
14685            },
14686        );
14687        ws.mark_session_connected_for_test(&key, "s-uuid");
14688        ws.enable_test_dispatch_intercept();
14689
14690        let before = time::OffsetDateTime::now_utc();
14691        ws.dispatch_forged_prompt(&key, "get on with it".to_owned()).expect("forged dispatch");
14692        let after = time::OffsetDateTime::now_utc();
14693
14694        let stamps: Vec<Option<String>> = drain_updates(&mut rx)
14695            .into_iter()
14696            .filter_map(|update| match update {
14697                SessionUpdate::ChatAppended { msg, .. } => Some(msg),
14698                _ => None,
14699            })
14700            .map(|msg| {
14701                serde_json::to_value(&msg).expect("encode")["timestamp"].as_str().map(str::to_owned)
14702            })
14703            .collect();
14704        assert_eq!(stamps.len(), 1, "the machinery's prompt forges a row: {stamps:?}");
14705        let forged =
14706            stamps[0].as_deref().expect("the row forge's machinery forges carries a stamp");
14707
14708        // **The code's own clock, not merely a parseable string.** A constant
14709        // stamp would draw every forged row a clock years old while a
14710        // parse-only pin stayed green, so it has to land where the two
14711        // `now_utc`s bracket the dispatch.
14712        let slack = time::Duration::seconds(5);
14713        let at =
14714            time::OffsetDateTime::parse(forged, &time::format_description::well_known::Rfc3339)
14715                .unwrap_or_else(|error| {
14716                    panic!("the machinery stamped a time no client can read: {forged} ({error})")
14717                });
14718        assert!(
14719            at >= before - slack && at <= after + slack,
14720            "the row the machinery forges carries the forge's own clock, not a constant: \
14721             {forged} sits outside {before}..{after}",
14722        );
14723    }
14724
14725    /// A forged prompt that never landed draws nothing: the words did not
14726    /// reach a model, so a frame for them would show a turn nothing answers.
14727    #[test]
14728    fn a_refused_forged_prompt_draws_nothing() {
14729        let dir = tempdir().expect("tempdir");
14730        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14731        let gone = SessionSlot::from_str_for_test("no-such-seat");
14732
14733        let refused = ws.dispatch_forged_prompt(&gone, "get on with it".to_owned());
14734
14735        assert!(refused.is_err(), "a seat nothing holds refuses the dispatch");
14736        assert!(
14737            !drain_updates(&mut rx).iter().any(|u| matches!(u, SessionUpdate::ChatAppended { .. })),
14738            "a refused prompt never reached a model, so nothing draws its words",
14739        );
14740    }
14741
14742    /// **Only the reader's own send is a reader looking.** A standing death
14743    /// is spent by a prompt the person typed - they have moved the seat on -
14744    /// and is NOT spent by a cron's, a peer's or forge's own prompt, which
14745    /// are the machinery talking rather than anyone having seen it.
14746    #[test]
14747    fn a_readers_prompt_spends_a_death_and_another_sources_does_not() {
14748        let dir = tempdir().expect("tempdir");
14749        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14750        let key = SessionSlot::from_str_for_test("prompt-spend");
14751        // The routed path is what spends, so a command sender has to exist:
14752        // without one the dispatch falls to the bare stub below.
14753        let (tx, mut sent) = mpsc::unbounded_channel();
14754        ws.command_senders.lock().insert(key.clone(), tx);
14755        ws.record_death_for_test(
14756            &key,
14757            "dead-occupant",
14758            "the claude subprocess closed its output stream",
14759        );
14760
14761        let prompt = |uuid: &str, source: crate::protocol::PromptSource| Command::PromptUnder {
14762            key: key.clone(),
14763            text: "hello".to_owned(),
14764            attachments: Vec::new(),
14765            uuid: uuid.to_owned(),
14766            source,
14767            dispatch_id: None,
14768        };
14769
14770        ws.dispatch(prompt("cron-1", crate::protocol::PromptSource::Cron)).expect("routed");
14771        assert!(ws.death_stands(&key), "a cron's prompt is not a reader looking");
14772
14773        ws.dispatch(prompt("peer-1", crate::protocol::PromptSource::Peer)).expect("routed");
14774        assert!(ws.death_stands(&key), "a peer's prompt is not a reader looking");
14775
14776        ws.dispatch(prompt("you-1", crate::protocol::PromptSource::You)).expect("routed");
14777        assert!(!ws.death_stands(&key), "the reader's own send spends the death");
14778        assert!(!ws.spend_death(&key), "and there is nothing left to spend");
14779
14780        let delivered: Vec<Command> = std::iter::from_fn(|| sent.try_recv().ok()).collect();
14781        assert_eq!(delivered.len(), 3, "all three prompts still reached the seat");
14782    }
14783
14784    /// **A `/new` is a replacement, not a sweep.** The slot stays and the
14785    /// previous occupant's tabs close; a sweep would ask about every seat
14786    /// that is not live, which is the wrong question for a respawn.
14787    #[tokio::test]
14788    async fn a_new_session_replaces_the_seats_tabs_in_the_fleet() {
14789        let (ws, _rx) = Workspace::testing_stub();
14790        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
14791        ws.install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
14792        let seat = SessionSlot::lead("TestOrg", "proj");
14793
14794        let _ = ws.run_forge_prompt(&seat, &crate::prompt::ForgePrompt::NewSession);
14795
14796        assert_eq!(
14797            heard.recv().await,
14798            Some(("replace_seat".to_owned(), serde_json::json!(seat))),
14799            "the fleet is told whose tabs the respawn replaces",
14800        );
14801    }
14802
14803    /// A seat with a live domain session whose dispatch is captured rather
14804    /// than routed, plus the workspace's update stream. The caller folds the
14805    /// failure it wants - the arming under test - then calls
14806    /// [`Workspace::fire_auto_continue`], the way the session task does the
14807    /// moment that failure lands.
14808    fn nudge_seat(
14809        dir: &tempfile::TempDir,
14810        label: &str,
14811    ) -> (Arc<Workspace>, SessionSlot, tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>) {
14812        let (ws, rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14813        let key = SessionSlot::from_str_for_test(label);
14814        ws.mark_session_connected_for_test(&key, label);
14815        ws.enable_test_dispatch_intercept();
14816        (ws, key, rx)
14817    }
14818
14819    /// Fold one agent event into `key`'s domain the way a session task
14820    /// would, so these tests drive the real arming rather than hand-set it.
14821    fn fold_event(ws: &Arc<Workspace>, key: &SessionSlot, event: &forge_agent::client::AgentEvent) {
14822        let domain = ws.domain_session_for(key).expect("the seat's session");
14823        let mut guard = domain.lock();
14824        crate::session_task::apply_event_to_domain(&mut guard, event);
14825    }
14826
14827    /// An errored `Result` carrying the CLI's own errors.
14828    fn failed_result(errors: &[&str]) -> forge_agent::client::AgentEvent {
14829        forge_agent::client::AgentEvent::SdkMessage {
14830            session_id: "nudge".to_owned(),
14831            msg: serde_json::from_value(serde_json::json!({
14832                "type": "result",
14833                "subtype": "error_during_execution",
14834                "duration_ms": 1,
14835                "duration_api_ms": 1,
14836                "is_error": true,
14837                "num_turns": 1,
14838                "session_id": "nudge",
14839                "errors": errors,
14840            }))
14841            .expect("parse result message"),
14842        }
14843    }
14844
14845    /// The prompts `ws` captured, in order.
14846    fn dispatched_prompts(ws: &Arc<Workspace>) -> Vec<String> {
14847        ws.drain_test_dispatch_buffer()
14848            .into_iter()
14849            .filter_map(|cmd| match cmd {
14850                Command::PromptUnder { text, .. } => Some(text),
14851                _ => None,
14852            })
14853            .collect()
14854    }
14855
14856    /// The words the continuation carries for `reason`.
14857    fn expected_prompt(reason: &str) -> String {
14858        format!(
14859            "forge continued this session automatically after the previous turn failed: {reason}. \
14860Pick up exactly where you stopped - everything completed before the failure is in the history \
14861above, so do not restart the task, and do not repeat any step, tool call or side effect that \
14862already completed."
14863        )
14864    }
14865
14866    /// The want #1841 is about: a failed turn gets one prompt of forge's own
14867    /// through the dispatched-prompt path, and the frame draws in every view
14868    /// as the turn the model received.
14869    #[test]
14870    fn a_failed_turn_is_continued_in_forges_own_words() {
14871        let dir = tempdir().expect("tempdir");
14872        let (ws, key, mut rx) = nudge_seat(&dir, "nudge");
14873        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14874
14875        ws.fire_auto_continue(&key);
14876
14877        assert_eq!(
14878            dispatched_prompts(&ws),
14879            [expected_prompt("API Error: 400 ...")],
14880            "one prompt, naming who continued the session and the failure it answers",
14881        );
14882        let drawn = drain_updates(&mut rx);
14883        assert!(
14884            drawn.iter().any(|u| matches!(
14885                u,
14886                SessionUpdate::ChatAppended { msg: Message::User { .. }, .. }
14887            )),
14888            "the continuation draws as the turn the model received: {drawn:?}",
14889        );
14890    }
14891
14892    /// Condition (a): a turn the reader cancelled is the reader's own act.
14893    /// It arms nothing, so the fire sends nothing.
14894    #[test]
14895    fn a_cancelled_turn_never_fires() {
14896        let dir = tempdir().expect("tempdir");
14897        let (ws, key, _rx) = nudge_seat(&dir, "cancelled");
14898        ws.domain_session_for(&key).expect("session").lock().pending_cancel = true;
14899
14900        fold_event(&ws, &key, &failed_result(&["aborted_streaming"]));
14901        assert!(
14902            ws.domain_session_for(&key).expect("session").lock().auto_continue.is_none(),
14903            "the fold refuses to arm a cancelled turn",
14904        );
14905        ws.domain_session_for(&key).expect("session").lock().failed_turn_at =
14906            Some(SystemTime::now() - Duration::from_secs(60));
14907        ws.fire_auto_continue(&key);
14908
14909        assert!(
14910            dispatched_prompts(&ws).is_empty(),
14911            "a failure that armed nothing is nothing to send",
14912        );
14913    }
14914
14915    /// The sighting #2146 is about: the person was reading the seat when its
14916    /// turn failed, and nothing was continued until they typed. A seat a
14917    /// view is showing is continued like any other.
14918    #[tokio::test]
14919    async fn a_seat_a_view_is_holding_is_still_continued() {
14920        let dir = tempdir().expect("tempdir");
14921        let (ws, key, _rx) = nudge_seat(&dir, "watching");
14922        ws.hold_seat(&key);
14923
14924        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14925        ws.fire_auto_continue(&key);
14926
14927        assert_eq!(
14928            dispatched_prompts(&ws),
14929            [expected_prompt("API Error: 400 ...")],
14930            "the seat under the reader's eyes is resumed the same way",
14931        );
14932    }
14933
14934    /// The repeat case, settled: once per failure episode. The
14935    /// continuation's own turn failing must not become a prompt loop.
14936    #[test]
14937    fn a_second_failure_after_a_fire_does_not_fire_again() {
14938        let dir = tempdir().expect("tempdir");
14939        let (ws, key, _rx) = nudge_seat(&dir, "repeat");
14940        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14941        ws.fire_auto_continue(&key);
14942        assert_eq!(dispatched_prompts(&ws).len(), 1, "precondition: the failure was nudged");
14943
14944        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14945        assert!(
14946            ws.domain_session_for(&key).expect("session").lock().auto_continue.is_none(),
14947            "the episode has had its continuation; a second failure arms nothing",
14948        );
14949        ws.fire_auto_continue(&key);
14950
14951        assert!(
14952            dispatched_prompts(&ws).is_empty(),
14953            "and nothing more goes out for the same episode",
14954        );
14955    }
14956
14957    /// The episode ends when the reader looks: a later failure is a new one
14958    /// and gets its own continuation.
14959    #[tokio::test]
14960    async fn an_opened_seat_reopens_the_nudge_for_a_later_failure() {
14961        let dir = tempdir().expect("tempdir");
14962        let (ws, key, _rx) = nudge_seat(&dir, "reopened");
14963        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14964        ws.fire_auto_continue(&key);
14965        assert_eq!(dispatched_prompts(&ws).len(), 1, "precondition: the first failure was nudged");
14966
14967        ws.hold_seat(&key);
14968        ws.release_seat(&key);
14969        fold_event(&ws, &key, &failed_result(&["API Error: 400 ..."]));
14970        ws.fire_auto_continue(&key);
14971
14972        assert_eq!(
14973            dispatched_prompts(&ws).len(),
14974            1,
14975            "the reader looked, so the next failure is a new episode and is nudged",
14976        );
14977    }
14978
14979    /// The cron delivery path rides the helper: a cron fired into a lead
14980    /// draws its `CronPromptAppended` echo and lands, idle or mid-turn.
14981    #[test]
14982    fn a_cron_fire_into_a_lead_draws_its_echo() {
14983        let dir = tempdir().expect("tempdir");
14984        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
14985        ws.seed_test_project("cronlead", "/tmp/cron-lead-queued");
14986        let cwd = project_expanded_path(&ws, "cronlead");
14987        ws.record_connected_session(&cwd, "lead-uuid", None);
14988        let lead_key = SessionSlot::lead("TestOrg", "cronlead");
14989        let (handle, _agent_rx) = Workspace::testing_stub_handle();
14990        ws.pool.lock().insert(
14991            lead_key.clone(),
14992            PooledAgent {
14993                handle: Arc::new(handle),
14994                account: AccountKey("test".to_owned()),
14995                permission_mode: None,
14996                registration: None,
14997                session_id: "pooled-session".to_owned(),
14998            },
14999        );
15000        ws.mark_session_connected_for_test(&lead_key, "lead-uuid");
15001        ws.enable_test_dispatch_intercept();
15002
15003        let outcome =
15004            crate::spawn::deliver_cron_prompt(&ws, &test_cron("c1", "cronlead", "morning"), false);
15005        assert!(matches!(outcome, crate::spawn::CronFireOutcome::Delivered));
15006        assert!(
15007            drain_updates(&mut rx)
15008                .iter()
15009                .any(|u| matches!(u, SessionUpdate::CronPromptAppended { .. })),
15010            "an idle cron fire draws its echo",
15011        );
15012
15013        ws.domain_session_for(&lead_key).expect("domain").lock().turn_pending = true;
15014        let outcome =
15015            crate::spawn::deliver_cron_prompt(&ws, &test_cron("c2", "cronlead", "again"), false);
15016        assert!(matches!(outcome, crate::spawn::CronFireOutcome::Delivered));
15017        assert!(
15018            drain_updates(&mut rx)
15019                .iter()
15020                .any(|u| matches!(u, SessionUpdate::CronPromptAppended { .. })),
15021            "and so does one fired mid-turn",
15022        );
15023    }
15024
15025    /// The gotify running-lead delivery rides the helper too - a second
15026    /// family (after cron) through a different entry path, and its echo
15027    /// draws idle or mid-turn.
15028    #[test]
15029    fn a_gotify_delivery_into_a_lead_draws_its_echo() {
15030        let dir = tempdir().expect("tempdir");
15031        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
15032        ws.seed_test_project("glead", "/tmp/gotify-lead-queued");
15033        let cwd = project_expanded_path(&ws, "glead");
15034        ws.record_connected_session(&cwd, "lead-uuid", None);
15035        let lead_key = SessionSlot::lead("TestOrg", "glead");
15036        let (handle, _agent_rx) = Workspace::testing_stub_handle();
15037        ws.pool.lock().insert(
15038            lead_key.clone(),
15039            PooledAgent {
15040                handle: Arc::new(handle),
15041                account: AccountKey("test".to_owned()),
15042                permission_mode: None,
15043                registration: None,
15044                session_id: "pooled-session".to_owned(),
15045            },
15046        );
15047        ws.mark_session_connected_for_test(&lead_key, "lead-uuid");
15048        ws.enable_test_dispatch_intercept();
15049        let notif = gotify_notif("Backups", "Nightly backup", "done", 5);
15050
15051        crate::spawn::deliver_gotify_message(&ws, "glead", None, notif.clone());
15052        assert!(
15053            drain_updates(&mut rx)
15054                .iter()
15055                .any(|u| matches!(u, SessionUpdate::GotifyNotificationAppended { .. })),
15056            "an idle gotify fire draws its echo",
15057        );
15058
15059        ws.domain_session_for(&lead_key).expect("domain").lock().turn_pending = true;
15060        crate::spawn::deliver_gotify_message(&ws, "glead", None, notif);
15061        assert!(
15062            drain_updates(&mut rx)
15063                .iter()
15064                .any(|u| matches!(u, SessionUpdate::GotifyNotificationAppended { .. })),
15065            "and so does one delivered mid-turn",
15066        );
15067    }
15068
15069    fn slack_message_for(text: &str) -> forge_primitives::slack::SlackMessage {
15070        forge_primitives::slack::SlackMessage {
15071            workspace: "acme".to_owned(),
15072            conversation: "D1".to_owned(),
15073            conversation_label: "U9".to_owned(),
15074            ts: "100.000001".to_owned(),
15075            thread_ts: None,
15076            user: Some("U9".to_owned()),
15077            author: None,
15078            text: text.to_owned(),
15079            parent_user_id: None,
15080            latest_reply: None,
15081            edited: None,
15082            files: Vec::new(),
15083        }
15084    }
15085
15086    /// The lead-owned subscription's running lead receives the message as
15087    /// a prompt, and the asleep path never fires.
15088    #[test]
15089    fn slack_delivery_to_a_running_lead_dispatches_a_prompt() {
15090        let dir = tempdir().expect("tempdir");
15091        let (ws, mut update_rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
15092        ws.seed_test_project("glead", "/tmp/slack-lead-running");
15093        let cwd = project_expanded_path(&ws, "glead");
15094        ws.record_connected_session(&cwd, "lead-uuid", None);
15095        let lead_key = SessionSlot::lead("TestOrg", "glead");
15096        let (handle, _agent_rx) = Workspace::testing_stub_handle();
15097        ws.pool.lock().insert(
15098            lead_key.clone(),
15099            PooledAgent {
15100                handle: Arc::new(handle),
15101                account: AccountKey("test".to_owned()),
15102                permission_mode: None,
15103                registration: None,
15104                session_id: "pooled-session".to_owned(),
15105            },
15106        );
15107        ws.mark_session_connected_for_test(&lead_key, "lead-uuid");
15108        ws.enable_test_dispatch_intercept();
15109
15110        crate::spawn::deliver_slack_message(&ws, "glead", None, vec![slack_message_for("ping")]);
15111
15112        let dispatched = ws.drain_test_dispatch_buffer();
15113        assert!(
15114            dispatched.iter().any(|cmd| matches!(
15115                cmd,
15116                crate::protocol::Command::Prompt { key, .. }
15117                    | crate::protocol::Command::PromptUnder { key, .. }
15118                    if *key == lead_key
15119            )),
15120            "the running lead receives the message as a prompt: {dispatched:?}",
15121        );
15122        assert!(
15123            dispatched
15124                .iter()
15125                .all(|cmd| !matches!(cmd, crate::protocol::Command::SpawnProject { .. })),
15126            "a running lead must not fire the asleep spawn path",
15127        );
15128        assert_eq!(
15129            ws.parked_by_slot
15130                .lock()
15131                .get(&crate::SessionSlot::lead("TestOrg", "glead"))
15132                .map_or(0, |parked| parked.slack.len()),
15133            0,
15134            "a running lead's delivery is dispatched, not parked",
15135        );
15136
15137        // The delivery ALSO echoes the block into the lead's chat: the CLI
15138        // never echoes a stdin-injected prompt, so without it the user sees
15139        // nothing.
15140        let echoed = drain_updates(&mut update_rx).into_iter().any(|u| {
15141            matches!(
15142                u,
15143                crate::protocol::SessionUpdate::SlackMessageAppended { key, prose, .. }
15144                    if key == lead_key
15145                        && prose.starts_with("[Slack")
15146                        && prose.contains("ping")
15147            )
15148        });
15149        assert!(echoed, "a running-lead delivery emits a SlackMessageAppended echo with the body");
15150    }
15151
15152    /// The lead arm carries the stricter rule - a failed dispatch returns false
15153    /// so the sweep re-runs the message - and must not have echoed before it.
15154    /// The pooled lead stays in place so the delivery reaches the lead arm; the
15155    /// failure is a task channel whose receiver is gone, the teardown window a
15156    /// session close leaves behind.
15157    #[test]
15158    fn a_failed_lead_dispatch_leaves_no_echo_for_the_sweep_to_duplicate() {
15159        let dir = tempdir().expect("tempdir");
15160        let (ws, mut update_rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
15161        ws.seed_test_project("glead", "/tmp/slack-lead-doomed");
15162        let lead_key = SessionSlot::lead("TestOrg", "glead");
15163        let (handle, _agent_rx) = Workspace::testing_stub_handle();
15164        ws.pool.lock().insert(
15165            lead_key.clone(),
15166            PooledAgent {
15167                handle: Arc::new(handle),
15168                account: AccountKey("test".to_owned()),
15169                permission_mode: None,
15170                registration: None,
15171                session_id: "pooled-session".to_owned(),
15172            },
15173        );
15174        ws.mark_session_connected_for_test(&lead_key, "lead-uuid");
15175        // A sender whose receiver is dropped: `dispatch` takes the production
15176        // routing path and returns `SessionClosed`, instead of falling through
15177        // to the test-only path that would run it against the stub handle.
15178        let (tx, rx) = mpsc::unbounded_channel::<crate::protocol::Command>();
15179        drop(rx);
15180        ws.command_senders.lock().insert(lead_key.clone(), tx);
15181
15182        let delivered = crate::spawn::deliver_slack_message(
15183            &ws,
15184            "glead",
15185            None,
15186            vec![slack_message_for("ping")],
15187        );
15188
15189        assert!(!delivered, "a failed dispatch tells the sweep to re-run the message");
15190        let echoed = drain_updates(&mut update_rx)
15191            .into_iter()
15192            .any(|u| matches!(u, crate::protocol::SessionUpdate::SlackMessageAppended { .. }));
15193        assert!(!echoed, "a failed lead dispatch must not echo a SlackMessageAppended");
15194    }
15195
15196    /// With no running session, the message parks for the project
15197    /// lead's slot and the project's spawn is fired to pick it up.
15198    #[test]
15199    fn slack_delivery_to_an_asleep_lead_buffers_and_spawns_the_project() {
15200        let dir = tempdir().expect("tempdir");
15201        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
15202        ws.seed_test_project("glead", "/tmp/slack-lead-asleep");
15203        ws.enable_test_dispatch_intercept();
15204
15205        crate::spawn::deliver_slack_message(&ws, "glead", None, vec![slack_message_for("wake up")]);
15206
15207        let dispatched = ws.drain_test_dispatch_buffer();
15208        assert!(
15209            dispatched.iter().any(|cmd| matches!(
15210                cmd,
15211                crate::protocol::Command::SpawnProject { project_name, .. }
15212                    if project_name == "glead"
15213            )),
15214            "the asleep project is spawned: {dispatched:?}",
15215        );
15216        assert!(
15217            dispatched.iter().all(|cmd| !matches!(
15218                cmd,
15219                crate::protocol::Command::Prompt { .. }
15220                    | crate::protocol::Command::PromptUnder { .. }
15221            )),
15222            "no session is prompted directly",
15223        );
15224        assert_eq!(
15225            parked_slack_count(&ws, "glead", None),
15226            1,
15227            "the message waits for the spawned session to drain",
15228        );
15229    }
15230
15231    /// A sweep re-run of the same message must not double-prompt: the
15232    /// dedupe drops the second hand-off to the same destination, which is
15233    /// what makes a post-failure re-sweep idempotent.
15234    #[test]
15235    fn a_second_delivery_of_the_same_message_to_the_same_owner_is_dropped() {
15236        let dir = tempdir().expect("tempdir");
15237        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
15238        ws.seed_test_project("glead", "/tmp/slack-double");
15239        ws.enable_test_dispatch_intercept();
15240
15241        let message = slack_message_for("hello");
15242        assert!(
15243            crate::spawn::deliver_slack_message(&ws, "glead", None, vec![message.clone()]),
15244            "the first delivery lands",
15245        );
15246        assert!(
15247            crate::spawn::deliver_slack_message(&ws, "glead", None, vec![message]),
15248            "the re-run reads as already delivered, not as a failure",
15249        );
15250
15251        let dispatched = ws.drain_test_dispatch_buffer();
15252        assert!(
15253            dispatched.iter().all(|cmd| !matches!(
15254                cmd,
15255                crate::protocol::Command::Prompt { .. }
15256                    | crate::protocol::Command::PromptUnder { .. }
15257            )),
15258            "the asleep path buffers; the second delivery added no prompt",
15259        );
15260        assert_eq!(parked_slack_count(&ws, "glead", None), 1, "one buffered message, not two");
15261    }
15262
15263    /// How many Slack messages are parked for `(project, label)`, under the
15264    /// org the seeded project belongs to.
15265    fn parked_slack_count(ws: &Workspace, project: &str, label: Option<&str>) -> usize {
15266        let org =
15267            ws.list_projects().into_iter().find(|v| v.name == project).expect("seeded project").org;
15268        ws.parked_by_slot
15269            .lock()
15270            .get(&crate::SessionSlot::for_label(&org, project, label))
15271            .map_or(0, |parked| parked.slack.len())
15272    }
15273
15274    fn make_workspace_dir() -> tempfile::TempDir {
15275        let dir = tempdir().expect("tempdir");
15276        fs::write(
15277            forge_toml_path(dir.path()),
15278            r#"
15279[[orgs]]
15280name = "Default"
15281accounts = ["Stargate"]
15282
15283[[orgs.projects]]
15284name = "forge"
15285path = "~/Projects/forge"
15286model = "claude-sonnet-5"
15287
15288[[accounts]]
15289display_name = "Stargate"
15290token = "t"
15291models = ["claude-sonnet-5"]
15292provider = "anthropic"
15293"#,
15294        )
15295        .expect("write forge.toml");
15296        dir
15297    }
15298
15299    /// The project declares a model, so a respawn has a canonical name
15300    /// to stamp over the caller's pin.
15301    fn make_workspace_dir_declaring_model() -> tempfile::TempDir {
15302        let dir = tempdir().expect("tempdir");
15303        fs::write(
15304            forge_toml_path(dir.path()),
15305            r#"
15306[[orgs]]
15307name = "Default"
15308accounts = ["Personal", "OpenRouter-TM"]
15309
15310[[orgs.projects]]
15311name = "forge"
15312path = "~/Projects/forge"
15313model = "deepseek-v4.1-flash"
15314
15315[[accounts]]
15316display_name = "Personal"
15317token = "t"
15318models = ["claude-opus-5"]
15319provider = "anthropic"
15320
15321[[accounts]]
15322display_name = "OpenRouter-TM"
15323token = "t"
15324models = ["deepseek-v4.1-flash"]
15325provider = "openrouter"
15326base_url = "https://openrouter.ai/api"
15327"#,
15328        )
15329        .expect("write forge.toml");
15330        dir
15331    }
15332
15333    /// An unrelated reload keeps the live vault session: a settings save must
15334    /// not lock a vault and make the next tool call ask for the password.
15335    #[tokio::test]
15336    async fn an_unrelated_reload_keeps_the_vault_session() {
15337        let dir = make_workspace_dir_declaring_model();
15338        let path = forge_toml_path(dir.path());
15339        let raw = fs::read_to_string(&path).expect("read fixture");
15340        let raw = format!(
15341            "{raw}\n[vault]\nurl = \"https://bitwarden.hub\"\nemail = \"hi@vedhavyas.dev\"\n"
15342        );
15343        fs::write(&path, &raw).expect("write vault section");
15344        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15345        let before =
15346            Arc::clone(workspace.vault.read().as_ref().expect("the fixture configures a vault"));
15347
15348        let changed = raw.replace(
15349            "model = \"deepseek-v4.1-flash\"",
15350            "model = \"deepseek-v4.1-flash\"\nmax_workers = 2",
15351        );
15352        fs::write(&path, changed).expect("write the unrelated edit");
15353        workspace.reload_config().expect("the file still loads");
15354
15355        let after = Arc::clone(workspace.vault.read().as_ref().expect("still configured"));
15356        assert!(
15357            Arc::ptr_eq(&before, &after),
15358            "an unrelated reload must keep the session and its unlocked keys",
15359        );
15360    }
15361
15362    /// A valid reload replaces the whole config, and the live value equals
15363    /// what a boot on the same file would load.
15364    #[tokio::test]
15365    async fn a_valid_reload_applies_the_file_and_equals_a_fresh_load() {
15366        let dir = make_workspace_dir_declaring_model();
15367        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15368        assert_eq!(
15369            workspace.config_arc().projects[0].model.as_deref(),
15370            Some("deepseek-v4.1-flash"),
15371            "the fixture starts on its own model, or this test proves nothing",
15372        );
15373
15374        fs::write(
15375            forge_toml_path(dir.path()),
15376            r#"
15377[[orgs]]
15378name = "Default"
15379accounts = ["Personal"]
15380
15381[[orgs.projects]]
15382name = "forge"
15383path = "~/Projects/forge"
15384model = "claude-opus-5"
15385env = { RELOADED = "yes" }
15386
15387[[accounts]]
15388display_name = "Personal"
15389token = "t2"
15390models = ["claude-opus-5"]
15391provider = "anthropic"
15392"#,
15393        )
15394        .expect("write the new config");
15395
15396        let reload = workspace.reload_config().expect("the new file is valid");
15397        assert!(
15398            reload.changed.iter().any(|key| key.starts_with("accounts.")),
15399            "the account set moved: {reload:?}",
15400        );
15401        assert!(
15402            reload.changed.iter().any(|key| key.starts_with("orgs.")),
15403            "the project set moved: {reload:?}",
15404        );
15405
15406        let live = workspace.config_arc();
15407        assert_eq!(live.projects[0].model.as_deref(), Some("claude-opus-5"));
15408        assert_eq!(
15409            live.projects[0].env.get("RELOADED").map(String::as_str),
15410            Some("yes"),
15411            "the project's env is live for the next spawn",
15412        );
15413        assert_eq!(live.accounts[0].display_name, "Personal");
15414
15415        let fresh = crate::config::load_from_dir(dir.path()).expect("a fresh boot load");
15416        assert_eq!(live.projects.len(), fresh.projects.len());
15417        assert_eq!(live.projects[0].name, fresh.projects[0].name);
15418        assert_eq!(live.projects[0].model, fresh.projects[0].model);
15419        assert_eq!(live.projects[0].env, fresh.projects[0].env);
15420        assert_eq!(live.accounts.len(), fresh.accounts.len());
15421        assert_eq!(live.accounts[0].display_name, fresh.accounts[0].display_name);
15422        assert_eq!(
15423            live.accounts[0].env, fresh.accounts[0].env,
15424            "the live config equals what a restart on the same file loads",
15425        );
15426    }
15427
15428    /// An invalid file is rejected whole; the last good config stays live.
15429    #[tokio::test]
15430    async fn an_invalid_reload_keeps_the_last_good_config() {
15431        let dir = make_workspace_dir();
15432        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15433        fs::write(forge_toml_path(dir.path()), "this is not toml = [").expect("write invalid");
15434
15435        let error = workspace.reload_config().expect_err("an invalid file is rejected");
15436
15437        assert!(
15438            matches!(error, WorkspaceError::ConfigParse { .. }),
15439            "the parse failure is reported: {error}",
15440        );
15441        assert_eq!(
15442            workspace.config_arc().projects[0].name,
15443            "forge",
15444            "the previous config is still live after a rejected file",
15445        );
15446    }
15447
15448    fn settings_key(workspace: &Workspace, path: &str) -> (String, String, String) {
15449        let document =
15450            crate::settings::document(&workspace.config_arc(), "", &workspace.config_path());
15451        let key = document
15452            .areas
15453            .iter()
15454            .flat_map(|area| &area.keys)
15455            .find(|key| key.path == path)
15456            .unwrap_or_else(|| panic!("{path} has no row"));
15457        let area = document
15458            .areas
15459            .iter()
15460            .find(|area| area.keys.iter().any(|key| key.path == path))
15461            .map(|area| area.id.clone())
15462            .expect("the key has an area");
15463        (area, key.pattern.clone(), key.path.clone())
15464    }
15465
15466    fn settings_fingerprint(workspace: &Workspace) -> String {
15467        let raw = fs::read_to_string(workspace.config_path()).expect("read the config");
15468        crate::config::fingerprint_of(&raw)
15469    }
15470
15471    fn set_edit(
15472        workspace: &Workspace,
15473        path: &str,
15474        value: serde_json::Value,
15475    ) -> SettingsEditRequest {
15476        let (area, pattern, key_path) = settings_key(workspace, path);
15477        SettingsEditRequest { area, key_path, pattern, op: SettingsEditOp::Set, value: Some(value) }
15478    }
15479
15480    /// **A project's path is checked on a set too, and the refusal names the
15481    /// key.** The loader accepts any string, so a row pointing at nothing is
15482    /// a project that can never start.
15483    #[tokio::test]
15484    async fn a_project_path_set_that_is_not_a_directory_is_refused_at_its_key() {
15485        let dir = make_workspace_dir();
15486        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15487        let fingerprint = settings_fingerprint(&workspace);
15488        let edit = set_edit(
15489            &workspace,
15490            "orgs.Default.projects.forge.path",
15491            serde_json::json!("/nonexistent/forge"),
15492        );
15493        let error =
15494            workspace.save_settings(&fingerprint, &[edit]).expect_err("a missing directory");
15495        match error {
15496            SettingsSaveError::Refused { message, key_path } => {
15497                assert!(message.contains("directory"), "{message}");
15498                assert_eq!(key_path.as_deref(), Some("orgs.Default.projects.forge.path"));
15499            }
15500            other => panic!("expected a refusal, got {other:?}"),
15501        }
15502        let after = fs::read_to_string(workspace.config_path()).expect("read");
15503        assert!(after.contains("path = \"~/Projects/forge\""), "the file did not move: {after}");
15504    }
15505
15506    /// A save writes the candidate, keeps the file's comments, and the next
15507    /// load reads the new value.
15508    #[tokio::test]
15509    async fn a_settings_save_writes_the_file_and_keeps_its_comments() {
15510        let dir = make_workspace_dir();
15511        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15512        let path = workspace.config_path();
15513        let raw = fs::read_to_string(&path).expect("read");
15514        fs::write(&path, format!("# hand-written note\n{raw}")).expect("write the comment");
15515        workspace.reload_config().expect("the commented file loads");
15516        let fingerprint = settings_fingerprint(&workspace);
15517        let edit =
15518            set_edit(&workspace, "orgs.Default.projects.forge.max_workers", serde_json::json!(4));
15519
15520        workspace.save_settings(&fingerprint, &[edit]).expect("the save lands");
15521
15522        let saved = fs::read_to_string(&path).expect("read the saved file");
15523        assert!(saved.contains("# hand-written note"), "the comment stayed: {saved}");
15524        assert!(saved.contains("max_workers = 4"), "the value landed: {saved}");
15525        workspace.reload_config().expect("the saved file loads");
15526        assert_eq!(workspace.config_arc().projects[0].max_workers, Some(4));
15527    }
15528
15529    /// A fingerprint that is not the file's refuses the save and writes
15530    /// nothing.
15531    #[tokio::test]
15532    async fn a_stale_settings_save_writes_nothing() {
15533        let dir = make_workspace_dir();
15534        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15535        let path = workspace.config_path();
15536        let before = fs::read_to_string(&path).expect("read");
15537        let edit =
15538            set_edit(&workspace, "orgs.Default.projects.forge.max_workers", serde_json::json!(4));
15539        let error = workspace
15540            .save_settings("0000000000000000", &[edit])
15541            .expect_err("a stale save is refused");
15542        assert_eq!(error, SettingsSaveError::Stale);
15543        assert_eq!(fs::read_to_string(&path).expect("read"), before, "the file did not move");
15544    }
15545
15546    /// **A spawn refused before the pool gives back what it wrote.** The id
15547    /// it recorded returns to the store's own and a domain handle it created
15548    /// goes, or the project reads live for ever and a boot resumes a
15549    /// transcript that never existed.
15550    #[tokio::test]
15551    async fn a_refused_spawn_rolls_back_its_id_and_domain() {
15552        let dir = make_workspace_dir();
15553        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15554        let slot = SessionSlot::lead("Default", "forge");
15555        let previous = workspace
15556            .stored_session_id(slot.org(), slot.project(), slot.label())
15557            .expect("the store reads");
15558        let minted = workspace.record_spawn_id(&slot, previous.clone(), true);
15559        assert_ne!(previous.as_deref(), Some(minted.as_str()), "the spawn pinned a new id");
15560        workspace
15561            .domain_handles
15562            .lock()
15563            .insert(slot.clone(), Arc::new(Mutex::new(DomainSession::new(slot.clone(), None))));
15564        {
15565            let _rollback = SpawnRollback {
15566                workspace: Arc::clone(&workspace),
15567                slot: slot.clone(),
15568                stored: previous.clone(),
15569                prior_seat_state: None,
15570                domain_created: true,
15571                armed: true,
15572            };
15573        }
15574        assert_eq!(
15575            workspace.stored_session_id(slot.org(), slot.project(), slot.label()).expect("read"),
15576            previous,
15577            "the pinned id went back to the store's own",
15578        );
15579        assert!(
15580            !workspace.domain_handles.lock().contains_key(&slot),
15581            "the domain handle the spawn created went",
15582        );
15583    }
15584
15585    /// **A real spawn refused by the loader between its id record and the
15586    /// pool rolls all of it back.** The live config stays the valid one it
15587    /// loaded; the file moves under it and no longer parses, so the bind's
15588    /// own re-read refuses - the guarded return, not a hand-built guard.
15589    /// The seat state goes back too: a refusal must not turn a cut-off turn
15590    /// into a parked seat, or reopen one the reader shut.
15591    #[tokio::test]
15592    async fn a_spawn_refused_by_a_changed_config_rolls_back_its_id_and_domain() {
15593        use crate::store::sessions::SeatState;
15594
15595        let dir = make_workspace_dir();
15596        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15597        workspace.seed_test_ready_account("Stargate");
15598        let slot = SessionSlot::lead("Default", "forge");
15599        workspace.record_session_id(slot.org(), slot.project(), slot.label(), "previous-id");
15600        workspace.record_seat_state(&slot, SeatState::InTurn);
15601        let previous = workspace
15602            .stored_session_id(slot.org(), slot.project(), slot.label())
15603            .expect("the store reads");
15604        fs::write(forge_toml_path(dir.path()), "this is not toml [").expect("write the bad file");
15605        let Err(error) = workspace.get_agent_handle(
15606            SessionTarget::Default,
15607            SessionLaunchSettings::default(),
15608            &crate::protocol::SpawnRole::Lead,
15609        ) else {
15610            panic!("the bind's re-read refuses an unparseable file")
15611        };
15612        assert!(error.to_string().contains("failed to parse"), "the loader's words: {error}");
15613        assert_eq!(
15614            workspace.stored_session_id(slot.org(), slot.project(), slot.label()).expect("read"),
15615            previous,
15616            "the pinned id went back to the store's own",
15617        );
15618        assert_eq!(
15619            workspace.stored_seat_state(&slot),
15620            Some(SeatState::InTurn),
15621            "the interrupted turn is still what the next boot reads",
15622        );
15623        assert!(
15624            !workspace.domain_handles.lock().contains_key(&slot),
15625            "the domain handle the spawn created went",
15626        );
15627    }
15628
15629    /// **A close that lands after the spawn's own write is not undone by
15630    /// the rollback.** The restore only reverses the state the spawn wrote;
15631    /// a newer decision - the reader closing the project mid-spawn - stands,
15632    /// or the next heal attempt would reopen the project the reader shut.
15633    #[test]
15634    fn a_refused_spawn_does_not_undo_a_close_that_landed_after_its_write() {
15635        use crate::store::sessions::SeatState;
15636
15637        let dir = tempdir().expect("tempdir");
15638        let (workspace, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
15639        workspace.install_db_for_test(
15640            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
15641        );
15642        let key = SessionSlot::lead("TestOrg", "forge");
15643        workspace.record_session_id("TestOrg", "forge", "lead", "id-1");
15644        workspace.record_seat_state(&key, SeatState::InTurn);
15645
15646        {
15647            let _refused = SpawnRollback {
15648                workspace: Arc::clone(&workspace),
15649                slot: key.clone(),
15650                stored: Some("id-1".to_owned()),
15651                prior_seat_state: workspace.stored_seat_state(&key),
15652                domain_created: false,
15653                armed: true,
15654            };
15655            // The spawn's own write, then the reader's close landing while
15656            // the spawn is still building.
15657            workspace.write_seat_state(&key, SPAWNED_SEAT_STATE, true);
15658            workspace.record_seat_state(&key, SeatState::Closed);
15659        }
15660
15661        assert_eq!(
15662            workspace.stored_seat_state(&key),
15663            Some(SeatState::Closed),
15664            "the close is newer than the spawn's write, so the rollback leaves it",
15665        );
15666    }
15667
15668    /// A candidate the loader refuses writes nothing and carries the loader's
15669    /// own words, anchored at the key the save named.
15670    #[tokio::test]
15671    async fn an_invalid_settings_save_writes_nothing_and_names_the_key() {
15672        let dir = make_workspace_dir();
15673        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15674        let path = workspace.config_path();
15675        let before = fs::read_to_string(&path).expect("read");
15676        let fingerprint = settings_fingerprint(&workspace);
15677        let edit = set_edit(
15678            &workspace,
15679            "orgs.Default.projects.forge.model",
15680            serde_json::json!("no-such-model"),
15681        );
15682        let error = workspace
15683            .save_settings(&fingerprint, &[edit])
15684            .expect_err("an unserved model is refused");
15685        match error {
15686            SettingsSaveError::Invalid { message, key_path } => {
15687                assert!(message.contains("no-such-model"), "the loader named the model: {message}");
15688                assert_eq!(
15689                    key_path.as_deref(),
15690                    Some("orgs.Default.projects.forge.model"),
15691                    "the refusal is anchored at the edit",
15692                );
15693            }
15694            other => panic!("expected an invalid save, got {other:?}"),
15695        }
15696        assert_eq!(fs::read_to_string(&path).expect("read"), before, "the file did not move");
15697    }
15698
15699    /// Removing a project that has a live session is refused, and the refusal
15700    /// names the session.
15701    #[tokio::test]
15702    async fn a_project_removal_with_a_live_session_is_refused() {
15703        let dir = make_workspace_dir();
15704        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15705        workspace.register_domain_session(SessionSlot::lead("Default", "forge"), None);
15706        let fingerprint = settings_fingerprint(&workspace);
15707        let edit = SettingsEditRequest {
15708            area: "projects".to_owned(),
15709            key_path: "orgs.Default.projects.forge".to_owned(),
15710            pattern: String::new(),
15711            op: SettingsEditOp::Remove,
15712            value: None,
15713        };
15714        let error =
15715            workspace.save_settings(&fingerprint, &[edit]).expect_err("a live project is refused");
15716        match error {
15717            SettingsSaveError::Refused { message, .. } => {
15718                assert!(message.contains("Default/forge/lead"), "the refusal named it: {message}");
15719            }
15720            other => panic!("expected a refusal, got {other:?}"),
15721        }
15722    }
15723
15724    /// Removing an account whose usage probe has not settled is refused
15725    /// (#2681), and the refusal names the account.
15726    #[tokio::test]
15727    async fn an_account_removal_while_loading_is_refused() {
15728        let dir = make_workspace_dir();
15729        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15730        let path = workspace.config_path();
15731        let before = fs::read_to_string(&path).expect("read");
15732        let fingerprint = settings_fingerprint(&workspace);
15733        let edit = SettingsEditRequest {
15734            area: "accounts".to_owned(),
15735            key_path: "accounts.Stargate".to_owned(),
15736            pattern: String::new(),
15737            op: SettingsEditOp::Remove,
15738            value: None,
15739        };
15740        let error = workspace
15741            .save_settings(&fingerprint, &[edit])
15742            .expect_err("a loading account is refused");
15743        match error {
15744            SettingsSaveError::Refused { message, .. } => {
15745                assert!(message.contains("Stargate"), "the refusal named it: {message}");
15746                assert!(message.contains("loading"), "the refusal said why: {message}");
15747            }
15748            other => panic!("expected a refusal, got {other:?}"),
15749        }
15750        assert_eq!(fs::read_to_string(&path).expect("read"), before, "the file did not move");
15751    }
15752
15753    /// **A save keeps the file's mode.** `forge.toml` holds every account,
15754    /// Slack, Gotify and System One token, and the temp file must not land
15755    /// its own 0644 over the operator's 0600.
15756    #[cfg(unix)]
15757    #[test]
15758    fn a_settings_write_keeps_the_files_mode() {
15759        use std::os::unix::fs::PermissionsExt as _;
15760
15761        let dir = tempfile::tempdir().expect("tempdir");
15762        let path = dir.path().join("forge.toml");
15763        std::fs::write(&path, "a = 1\n").expect("write");
15764        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
15765        write_atomically(&path, "a = 2\n", None).expect("write atomically");
15766        let mode = std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
15767        assert_eq!(mode, 0o600, "the config keeps the mode the operator gave it");
15768        assert_eq!(std::fs::read_to_string(&path).expect("read"), "a = 2\n");
15769    }
15770
15771    /// **The file is re-read after the temp exists and before the rename.**
15772    /// A fingerprint that no longer matches refuses, and the file is
15773    /// untouched.
15774    #[test]
15775    fn a_write_refuses_a_file_that_moved_before_the_rename() {
15776        let dir = tempfile::tempdir().expect("tempdir");
15777        let path = dir.path().join("forge.toml");
15778        std::fs::write(&path, "a = 1\n").expect("write");
15779        let refusal = write_atomically(&path, "a = 2\n", Some("not-the-file"));
15780        assert!(matches!(refusal, Err(WriteRefusal::Stale)), "{refusal:?}");
15781        assert_eq!(
15782            std::fs::read_to_string(&path).expect("read"),
15783            "a = 1\n",
15784            "the file did not move",
15785        );
15786    }
15787
15788    /// **A symlinked config stays a link.** The write goes through to the
15789    /// target rather than replacing the link with a regular file.
15790    #[cfg(unix)]
15791    #[test]
15792    fn a_settings_write_follows_a_symlinked_config() {
15793        let dir = tempfile::tempdir().expect("tempdir");
15794        let target = dir.path().join("real.toml");
15795        let link = dir.path().join("forge.toml");
15796        std::fs::write(&target, "a = 1\n").expect("write");
15797        std::os::unix::fs::symlink(&target, &link).expect("symlink");
15798        write_atomically(&link, "a = 2\n", None).expect("write atomically");
15799        assert!(
15800            std::fs::symlink_metadata(&link).expect("metadata").file_type().is_symlink(),
15801            "the config is still a symlink",
15802        );
15803        assert_eq!(std::fs::read_to_string(&target).expect("read"), "a = 2\n");
15804    }
15805
15806    /// **The account guard refuses only an entry the save drops.** A save
15807    /// that keeps the loading account is the positive control the guard's
15808    /// own test needs: a guard that refused everything would pass that test
15809    /// and fail this one.
15810    #[tokio::test]
15811    async fn an_unrelated_save_while_an_account_loads_is_written() {
15812        let dir = make_workspace_dir();
15813        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15814        let fingerprint = settings_fingerprint(&workspace);
15815        let edit = set_edit(&workspace, "usage.poll_seconds", serde_json::json!(120));
15816        workspace
15817            .save_settings(&fingerprint, &[edit])
15818            .expect("a save that keeps the account writes");
15819        let saved = fs::read_to_string(workspace.config_path()).expect("read");
15820        assert!(saved.contains("poll_seconds = 120"), "the value landed: {saved}");
15821    }
15822
15823    /// **A rename is a removal the guard can see.** Renaming a project with
15824    /// a live session strands that session exactly as removing it would, so
15825    /// the before/after identity diff refuses it by name.
15826    #[tokio::test]
15827    async fn a_project_rename_with_a_live_session_is_refused() {
15828        let dir = make_workspace_dir();
15829        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15830        workspace.register_domain_session(SessionSlot::lead("Default", "forge"), None);
15831        let fingerprint = settings_fingerprint(&workspace);
15832        let edit =
15833            set_edit(&workspace, "orgs.Default.projects.forge.name", serde_json::json!("forge2"));
15834        let error = workspace
15835            .save_settings(&fingerprint, &[edit])
15836            .expect_err("a live project rename is refused");
15837        match error {
15838            SettingsSaveError::Refused { message, .. } => {
15839                assert!(message.contains("Default/forge/lead"), "the refusal named it: {message}");
15840            }
15841            other => panic!("expected a refusal, got {other:?}"),
15842        }
15843    }
15844
15845    /// **An account rename is a removal too.** The retired identity is the
15846    /// one still loading, so renaming it is refused until the probe settles.
15847    #[tokio::test]
15848    async fn an_account_rename_while_loading_is_refused() {
15849        let dir = make_workspace_dir();
15850        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15851        let fingerprint = settings_fingerprint(&workspace);
15852        let edit =
15853            set_edit(&workspace, "accounts.Stargate.display_name", serde_json::json!("Stargate2"));
15854        // The org must keep referencing a declared account, or the loader
15855        // refuses the candidate before the guard is reached.
15856        let reference =
15857            set_edit(&workspace, "orgs.Default.accounts", serde_json::json!(["Stargate2"]));
15858        let error = workspace
15859            .save_settings(&fingerprint, &[reference, edit])
15860            .expect_err("a loading rename is refused");
15861        match error {
15862            SettingsSaveError::Refused { message, .. } => {
15863                assert!(message.contains("Stargate"), "the refusal named it: {message}");
15864                assert!(message.contains("loading"), "the refusal said why: {message}");
15865            }
15866            other => panic!("expected a refusal, got {other:?}"),
15867        }
15868    }
15869
15870    /// A bound port is stored but named restart-only; the socket is never
15871    /// rebound on a reload.
15872    #[tokio::test]
15873    async fn a_changed_port_is_reported_restart_only_and_stored() {
15874        let dir = make_workspace_dir();
15875        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15876        let mut raw = fs::read_to_string(forge_toml_path(dir.path())).expect("read");
15877        raw.push_str("\n[server]\nport = 49321\n");
15878        fs::write(forge_toml_path(dir.path()), raw).expect("write with the new port");
15879
15880        let reload = workspace.reload_config().expect("the new file is valid");
15881
15882        assert!(
15883            reload.restart_only.iter().any(|key| key == "server.port"),
15884            "the new port needs a restart: {reload:?}",
15885        );
15886        assert!(
15887            !reload.changed.iter().any(|key| key.starts_with("server.")),
15888            "a port change is not a live server change: {reload:?}",
15889        );
15890        assert_eq!(
15891            workspace.server_config().port,
15892            49321,
15893            "the value is stored even though the bound socket keeps the old one",
15894        );
15895    }
15896
15897    /// A snapshot a reader took before the reload keeps the values it read;
15898    /// the next reader sees the new config.
15899    #[tokio::test]
15900    async fn a_config_snapshot_taken_before_a_reload_keeps_its_values() {
15901        let dir = make_workspace_dir_declaring_model();
15902        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15903        let before = workspace.config_arc();
15904
15905        fs::write(
15906            forge_toml_path(dir.path()),
15907            r#"
15908[[orgs]]
15909name = "Default"
15910accounts = ["Personal"]
15911
15912[[orgs.projects]]
15913name = "forge"
15914path = "~/Projects/forge"
15915model = "claude-opus-5"
15916
15917[[accounts]]
15918display_name = "Personal"
15919token = "t"
15920models = ["claude-opus-5"]
15921provider = "anthropic"
15922"#,
15923        )
15924        .expect("write the new config");
15925
15926        workspace.reload_config().expect("the new file is valid");
15927
15928        assert_eq!(
15929            before.projects[0].model.as_deref(),
15930            Some("deepseek-v4.1-flash"),
15931            "a spawn already reading the old config keeps what it read",
15932        );
15933        assert_eq!(
15934            workspace.config_arc().projects[0].model.as_deref(),
15935            Some("claude-opus-5"),
15936            "the next reader gets the new value",
15937        );
15938    }
15939
15940    /// `[dictate]`'s derived state is built once at boot, so an edit is
15941    /// stored and named restart-only rather than reported as live.
15942    #[tokio::test]
15943    async fn a_dictate_change_is_restart_only() {
15944        let dir = make_workspace_dir();
15945        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15946        let mut raw = fs::read_to_string(forge_toml_path(dir.path())).expect("read");
15947        raw.push_str("\n[dictate]\nenabled = true\n");
15948        fs::write(forge_toml_path(dir.path()), raw).expect("write");
15949
15950        let reload = workspace.reload_config().expect("the new file is valid");
15951
15952        assert!(reload.restart_only.iter().any(|key| key.starts_with("dictate.")), "{reload:?}");
15953        assert!(!reload.changed.iter().any(|key| key.starts_with("dictate.")), "{reload:?}");
15954    }
15955
15956    /// A reload that did not move the gateway's rotation numbers must not
15957    /// rebuild the rotation state: an unrelated edit cannot make a cooling
15958    /// account eligible again.
15959    #[tokio::test]
15960    async fn an_unrelated_reload_keeps_the_live_rotation_state() {
15961        let dir = make_workspace_dir();
15962        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
15963        let account = forge_gateway::AccountKey("Stargate".to_owned());
15964        let registration = forge_gateway::binding::Registration {
15965            org: "Default".to_owned(),
15966            project: "forge".to_owned(),
15967            session: "s".to_owned(),
15968            account: account.clone(),
15969            provider: forge_primitives::account::Provider::Anthropic,
15970        };
15971        workspace.gateway.bindings.register(
15972            &registration,
15973            "http://127.0.0.1:1",
15974            &std::collections::HashMap::new(),
15975        );
15976        let reset = std::time::SystemTime::now()
15977            .duration_since(std::time::UNIX_EPOCH)
15978            .expect("clock")
15979            .as_secs()
15980            + 3_600;
15981        workspace.gateway.report_rate_limit("Default", "forge", "s", Some(reset));
15982        assert!(workspace.gateway.is_cooling(&account), "the fixture cools the account");
15983
15984        let mut raw = fs::read_to_string(forge_toml_path(dir.path())).expect("read");
15985        raw.push_str("\n[documents]\nmax_size_mib = 11\n");
15986        fs::write(forge_toml_path(dir.path()), raw).expect("write");
15987        workspace.reload_config().expect("the new file is valid");
15988        assert!(
15989            workspace.gateway.is_cooling(&account),
15990            "a reload outside [gateway] leaves the live cooldown alone",
15991        );
15992
15993        let mut raw = fs::read_to_string(forge_toml_path(dir.path())).expect("read");
15994        raw.push_str("\n[gateway]\nport = 49322\n");
15995        fs::write(forge_toml_path(dir.path()), raw).expect("write");
15996        workspace.reload_config().expect("the new file is valid");
15997        assert!(
15998            workspace.gateway.is_cooling(&account),
15999            "a restart-only gateway port is not a move of the rotation numbers",
16000        );
16001
16002        let mut raw = fs::read_to_string(forge_toml_path(dir.path())).expect("read");
16003        raw = raw.replace("port = 49322", "port = 49322\nstreak_count = 7");
16004        fs::write(forge_toml_path(dir.path()), raw).expect("write");
16005        workspace.reload_config().expect("the new file is valid");
16006        assert!(
16007            !workspace.gateway.is_cooling(&account),
16008            "a reload that moved the numbers replaces the rotation state",
16009        );
16010    }
16011
16012    /// The parity check is what stops a spawn accepting a resolution that
16013    /// straddled a reload: an odd generation is a swap in progress, so an
16014    /// equal-but-odd pair is not a settled read.
16015    #[test]
16016    fn a_resolution_must_start_and_end_on_a_settled_generation() {
16017        assert!(Workspace::resolution_held(4, 4), "an even, unchanged pair held");
16018        assert!(!Workspace::resolution_held(4, 6), "a completed reload invalidates it");
16019        assert!(
16020            !Workspace::resolution_held(5, 5),
16021            "an odd generation is a swap in progress, not a settled read",
16022        );
16023        assert!(!Workspace::resolution_held(5, 6), "a start mid-swap is never settled");
16024    }
16025
16026    #[tokio::test]
16027    async fn new_for_test_opens_redb_under_the_tempdir() {
16028        let dir = make_workspace_dir();
16029        let _workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16030        // The test constructor redirects redb into the config dir's own
16031        // tempdir, so no test ever opens the real machine store (#392).
16032        let base = dir.path().join("app-support");
16033        let redirected = store_path(&base, dir.path());
16034        assert!(redirected.exists(), "new_for_test opens redb under the tempdir app-support base");
16035    }
16036
16037    #[tokio::test]
16038    async fn new_for_test_writes_the_lock_under_the_tempdir() {
16039        let dir = make_workspace_dir();
16040        let _workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16041        // Everything under the app-support base follows the same
16042        // redirect as redb, so a test run leaves the real directory
16043        // untouched.
16044        let base = dir.path().join("app-support");
16045        assert!(
16046            base.join("locks").is_dir(),
16047            "new_for_test takes the single-instance lock under the tempdir base",
16048        );
16049    }
16050
16051    #[tokio::test]
16052    async fn get_agent_handle_default_is_idempotent() {
16053        let dir = make_workspace_dir();
16054        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16055        workspace.seed_test_ready_account("Stargate");
16056        let settings = SessionLaunchSettings::default();
16057
16058        let handle1 = workspace
16059            .get_agent_handle(
16060                SessionTarget::Default,
16061                settings.clone(),
16062                &crate::protocol::SpawnRole::Lead,
16063            )
16064            .expect("first");
16065        let handle2 = workspace
16066            .get_agent_handle(SessionTarget::Default, settings, &crate::protocol::SpawnRole::Lead)
16067            .expect("second");
16068
16069        assert!(Arc::ptr_eq(&handle1, &handle2), "expected pool hit for repeated Default target");
16070        assert_eq!(workspace.pool.lock().len(), 1);
16071    }
16072
16073    #[tokio::test]
16074    async fn distinct_targets_pool_distinct_entries() {
16075        let dir = tempdir().expect("tempdir");
16076        fs::write(
16077            forge_toml_path(dir.path()),
16078            r#"
16079[[orgs]]
16080name = "Default"
16081accounts = ["Stargate"]
16082
16083[[orgs.projects]]
16084name = "forge"
16085path = "~/Projects/forge"
16086model = "claude-sonnet-5"
16087
16088[[orgs.projects]]
16089name = "dotfiles"
16090path = "~/Projects/dotfiles"
16091model = "claude-sonnet-5"
16092
16093[[accounts]]
16094display_name = "Stargate"
16095token = "t"
16096models = ["claude-sonnet-5"]
16097provider = "anthropic"
16098"#,
16099        )
16100        .expect("write forge.toml");
16101        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16102        workspace.seed_test_ready_account("Stargate");
16103        let settings = SessionLaunchSettings::default();
16104
16105        let _ = workspace
16106            .get_agent_handle(
16107                SessionTarget::Default,
16108                settings.clone(),
16109                &crate::protocol::SpawnRole::Lead,
16110            )
16111            .expect("default");
16112        let _ = workspace
16113            .get_agent_handle(
16114                SessionTarget::Default,
16115                settings.clone(),
16116                &crate::protocol::SpawnRole::Lead,
16117            )
16118            .expect("default again");
16119        assert_eq!(workspace.pool.lock().len(), 1, "Default is idempotent");
16120
16121        // `Default` is the alphabetically-first project, which is
16122        // `dotfiles`; this names the other one so the targets stay
16123        // distinct.
16124        let _ = workspace
16125            .get_agent_handle(
16126                SessionTarget::Named("forge".to_owned()),
16127                settings,
16128                &crate::protocol::SpawnRole::Lead,
16129            )
16130            .expect("named");
16131        assert_eq!(workspace.pool.lock().len(), 2, "a distinct target adds a pool entry");
16132    }
16133
16134    #[tokio::test]
16135    async fn shutdown_drains_pool() {
16136        let dir = make_workspace_dir();
16137        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16138        workspace.seed_test_ready_account("Stargate");
16139        let handle = workspace
16140            .get_agent_handle(
16141                SessionTarget::Default,
16142                SessionLaunchSettings::default(),
16143                &crate::protocol::SpawnRole::Lead,
16144            )
16145            .expect("default");
16146
16147        // Pool has one entry going in.
16148        assert_eq!(workspace.pool.lock().len(), 1);
16149
16150        // Shutdown consumes self and must return. Drops `command_senders`,
16151        // which closes each `SessionTask`'s command channel; the spawned
16152        // task then exits and drops its `handle` clone.
16153        workspace.shutdown().await;
16154
16155        // The spawned `SessionTask` exits asynchronously after its
16156        // command channel closes; yield to let it run to completion
16157        // so the final `handle` drop is observable in `strong_count`.
16158        for _ in 0..16 {
16159            tokio::task::yield_now().await;
16160            if Arc::strong_count(&handle) == 1 {
16161                break;
16162            }
16163        }
16164        assert_eq!(Arc::strong_count(&handle), 1);
16165    }
16166
16167    /// The wait reads every slot's own answer: one that fired counts as
16168    /// done however late in the budget it did, and only the slot that
16169    /// never answered is named - which is what makes a non-empty answer
16170    /// mean "a tree may be left", never "this one was slow".
16171    #[tokio::test]
16172    async fn shutdown_names_only_the_sessions_that_did_not_answer() {
16173        let dir = make_workspace_dir();
16174        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16175        let answered = SessionSlot::new("org", "proj", "answered");
16176        let silent = SessionSlot::new("org", "proj", "silent");
16177        let firing = workspace.mint_session_teardown(&answered).expect("a mint before shutdown");
16178        let _held = workspace.mint_session_teardown(&silent).expect("a second mint");
16179        tokio::spawn(async move {
16180            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
16181            let _ = firing.send_replace(true);
16182        });
16183
16184        let unready = workspace.shutdown_within(std::time::Duration::from_millis(400)).await;
16185
16186        assert_eq!(unready, vec![silent], "only the slot that never answered is named");
16187    }
16188
16189    /// A released session's entry survives the release: the task it
16190    /// belongs to is still exiting, and a shutdown that started after the
16191    /// gesture must still wait for that exit.
16192    #[tokio::test]
16193    async fn a_released_session_is_still_awaited_by_a_shutdown() {
16194        let dir = make_workspace_dir();
16195        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16196        let slot = SessionSlot::new("org", "proj", "released");
16197        let _held = workspace.mint_session_teardown(&slot).expect("a mint before shutdown");
16198        workspace.release_session(&slot);
16199
16200        let unready = workspace.shutdown_within(std::time::Duration::from_millis(200)).await;
16201
16202        assert_eq!(unready, vec![slot], "the released slot is still one the exit waits on");
16203    }
16204
16205    /// One deadline for the whole drain, not one per session: two silent
16206    /// sessions come back together inside a single budget. A fresh timeout
16207    /// per receiver would spend the budget twice and red the elapsed bound.
16208    #[tokio::test]
16209    async fn a_shutdown_spends_one_budget_across_every_session() {
16210        let dir = make_workspace_dir();
16211        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16212        let first = SessionSlot::new("org", "proj", "first");
16213        let second = SessionSlot::new("org", "proj", "second");
16214        let _first = workspace.mint_session_teardown(&first).expect("a mint before shutdown");
16215        let _second = workspace.mint_session_teardown(&second).expect("a second mint");
16216
16217        let started = tokio::time::Instant::now();
16218        let unready = workspace.shutdown_within(std::time::Duration::from_millis(300)).await;
16219        let elapsed = started.elapsed();
16220
16221        assert_eq!(unready.len(), 2, "both silent sessions are named: {unready:?}");
16222        assert!(
16223            elapsed < std::time::Duration::from_millis(450),
16224            "one budget for the drain, not one per session: it took {elapsed:?}"
16225        );
16226    }
16227
16228    /// Once a shutdown has begun, no session can mint its teardown signal -
16229    /// which is the door a spawn would have to get through to sit outside
16230    /// the drain.
16231    #[tokio::test]
16232    async fn no_session_can_mint_once_a_shutdown_began() {
16233        let dir = make_workspace_dir();
16234        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16235        assert!(workspace.shutdown_within(std::time::Duration::from_millis(50)).await.is_empty());
16236
16237        let after = workspace.mint_session_teardown(&SessionSlot::new("org", "proj", "late"));
16238
16239        assert!(after.is_none(), "a mint after the gate went up is refused");
16240        assert!(workspace.is_shutting_down(), "and the gate is what refuses it");
16241    }
16242
16243    #[tokio::test]
16244    async fn get_agent_handle_named_project_resolves() {
16245        let dir = tempdir().expect("tempdir");
16246        fs::write(
16247            forge_toml_path(dir.path()),
16248            r#"
16249[[orgs]]
16250name = "Default"
16251accounts = ["Stargate"]
16252
16253[[orgs.projects]]
16254name = "forge"
16255path = "~/Projects/forge"
16256model = "claude-sonnet-5"
16257
16258[[orgs.projects]]
16259name = "dotfiles"
16260path = "~/Projects/dotfiles"
16261model = "claude-sonnet-5"
16262
16263[[accounts]]
16264display_name = "Stargate"
16265token = "t"
16266models = ["claude-sonnet-5"]
16267provider = "anthropic"
16268"#,
16269        )
16270        .expect("write forge.toml");
16271
16272        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16273        workspace.seed_test_ready_account("Stargate");
16274        let _ = workspace
16275            .get_agent_handle(
16276                SessionTarget::Default,
16277                SessionLaunchSettings::default(),
16278                &crate::protocol::SpawnRole::Lead,
16279            )
16280            .expect("default");
16281        // `Default` resolves to `dotfiles` now that the fallback is the
16282        // alphabetically-first project, so the named one is `forge`.
16283        let _ = workspace
16284            .get_agent_handle(
16285                SessionTarget::Named("forge".to_owned()),
16286                SessionLaunchSettings::default(),
16287                &crate::protocol::SpawnRole::Lead,
16288            )
16289            .expect("named");
16290        assert_eq!(workspace.pool.lock().len(), 2);
16291    }
16292
16293    #[tokio::test]
16294    async fn get_agent_handle_named_unknown_errors() {
16295        let dir = tempdir().expect("tempdir");
16296        fs::write(
16297            forge_toml_path(dir.path()),
16298            r#"
16299[[orgs]]
16300name = "Default"
16301accounts = ["Stargate"]
16302
16303[[orgs.projects]]
16304name = "forge"
16305path = "~/Projects/forge"
16306
16307[[accounts]]
16308display_name = "Stargate"
16309token = "t"
16310models = ["claude-sonnet-5"]
16311provider = "anthropic"
16312"#,
16313        )
16314        .expect("write forge.toml");
16315
16316        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16317        let result = workspace.get_agent_handle(
16318            SessionTarget::Named("nonexistent".to_owned()),
16319            SessionLaunchSettings::default(),
16320            &crate::protocol::SpawnRole::Lead,
16321        );
16322        let Err(err) = result else { panic!("unknown project name should error") };
16323        let err_string = format!("{err}");
16324        assert!(
16325            err_string.contains("nonexistent"),
16326            "error should mention the project name; got: {err_string}"
16327        );
16328    }
16329
16330    fn make_workspace_dir_with_two_accounts() -> tempfile::TempDir {
16331        let dir = tempdir().expect("tempdir");
16332        fs::write(
16333            forge_toml_path(dir.path()),
16334            r#"
16335[[orgs]]
16336name = "Default"
16337accounts = ["Stargate", "Gateway"]
16338
16339[[orgs.projects]]
16340name = "forge"
16341path = "~/Projects/forge"
16342model = "claude-sonnet-5"
16343
16344[[accounts]]
16345display_name = "Stargate"
16346token = "t"
16347models = ["claude-sonnet-5"]
16348provider = "anthropic"
16349
16350[[accounts]]
16351display_name = "Gateway"
16352token = "t"
16353models = ["claude-sonnet-5"]
16354provider = "anthropic"
16355"#,
16356        )
16357        .expect("write forge.toml");
16358        dir
16359    }
16360
16361    /// The migration's other half: a boot has to run the copy, or the
16362    /// table stays empty and every persisted worker starts fresh on
16363    /// every boot.
16364    #[tokio::test]
16365    async fn booting_copies_the_persisted_workers_into_the_sessions_table() {
16366        let dir = make_workspace_dir_with_two_accounts();
16367        let app_support = dir.path().join("app-support");
16368        fs::create_dir_all(&app_support).expect("app-support dir");
16369        let db_path = store_path(&app_support, dir.path());
16370        fs::create_dir_all(db_path.parent().expect("the stores dir")).expect("stores dir");
16371        let db = crate::store::Db::open(&db_path).expect("open db");
16372        // The key the boot derives, so the seeded row is one it can match:
16373        // the config resolves the project path, so a literal from the
16374        // fixture is not the same string.
16375        let config = crate::config::load_from_dir(dir.path()).expect("load config");
16376        let project_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
16377            &config.projects[0].path.to_string_lossy(),
16378        ));
16379        crate::store::dynamic_workers::insert_for_test(
16380            &db,
16381            &crate::store::dynamic_workers::DynamicWorker {
16382                project_key,
16383                label: "steward".to_owned(),
16384                charter: "mind the queues".to_owned(),
16385                kick: None,
16386                resume_kick: None,
16387                interactive: false,
16388            },
16389        )
16390        .expect("seed the worker a previous build persisted");
16391        drop(db);
16392
16393        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
16394        let db = workspace.db.lock();
16395        let row =
16396            crate::store::sessions::get(db.as_ref().expect("db"), "Default", "forge", "steward")
16397                .expect("read")
16398                .expect("the boot moved the persisted worker over");
16399        assert_eq!(row.label, "steward");
16400        assert_eq!(row.session_id, None, "its id is derived when the row is read");
16401    }
16402
16403    /// The ordinary case the merge exists for: `sessions` already holds a
16404    /// row - every lead spawn writes one - while a worker's spawn args
16405    /// live only in the retired table, because the release before this one
16406    /// wrote the worker's id to `sessions` and its args to
16407    /// `dynamic_workers`. A sweep gated on "the sessions table is empty"
16408    /// skips this store entirely and then drops the only copy of the
16409    /// args, so the worker comes back on the next boot with an empty
16410    /// charter.
16411    ///
16412    /// The steward's own row is there too, because that is what the
16413    /// released build leaves: `record_session_id` wrote the worker's
16414    /// occupant id to `sessions` under the same `(org, project, label)`
16415    /// the retired table keys its args by. Every worker live at the
16416    /// moment of upgrade therefore arrives at the merge arm, and the id
16417    /// on that row is the thing it must not lose.
16418    #[tokio::test]
16419    async fn booting_merges_worker_args_when_the_sessions_table_already_has_rows() {
16420        let dir = make_workspace_dir_with_two_accounts();
16421        let app_support = dir.path().join("app-support");
16422        fs::create_dir_all(&app_support).expect("app-support dir");
16423        let db_path = store_path(&app_support, dir.path());
16424        fs::create_dir_all(db_path.parent().expect("the stores dir")).expect("stores dir");
16425        let db = crate::store::Db::open(&db_path).expect("open db");
16426        let config = crate::config::load_from_dir(dir.path()).expect("load config");
16427        let project_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
16428            &config.projects[0].path.to_string_lossy(),
16429        ));
16430        crate::store::sessions::put(
16431            &db,
16432            &crate::store::sessions::SessionRecord {
16433                org: "Default".to_owned(),
16434                project: "forge".to_owned(),
16435                label: forge_primitives::LEAD_LABEL.to_owned(),
16436                session_id: Some("lead-session-id".to_owned()),
16437                charter: None,
16438                kick: None,
16439                resume_kick: None,
16440                interactive: None,
16441                seat_state: None,
16442                is_git_repo: None,
16443                mcp_families: None,
16444                cwd: None,
16445            },
16446        )
16447        .expect("seed the lead row a spawn writes");
16448        crate::store::sessions::put(
16449            &db,
16450            &crate::store::sessions::SessionRecord {
16451                org: "Default".to_owned(),
16452                project: "forge".to_owned(),
16453                label: "steward".to_owned(),
16454                session_id: Some("steward-id".to_owned()),
16455                charter: None,
16456                kick: None,
16457                resume_kick: None,
16458                interactive: None,
16459                seat_state: None,
16460                is_git_repo: None,
16461                mcp_families: None,
16462                cwd: None,
16463            },
16464        )
16465        .expect("seed the steward's own row, as the released build leaves it");
16466        crate::store::dynamic_workers::insert_for_test(
16467            &db,
16468            &crate::store::dynamic_workers::DynamicWorker {
16469                project_key,
16470                label: "steward".to_owned(),
16471                charter: "mind the queues".to_owned(),
16472                kick: Some("begin".to_owned()),
16473                resume_kick: Some("re-read the notes".to_owned()),
16474                interactive: true,
16475            },
16476        )
16477        .expect("seed the worker only the retired table describes");
16478        drop(db);
16479
16480        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
16481
16482        let guard = workspace.db.lock();
16483        let db = guard.as_ref().expect("db");
16484        let steward = crate::store::sessions::get(db, "Default", "forge", "steward")
16485            .expect("read")
16486            .expect("the sweep runs even though the lead row is there");
16487        assert_eq!(
16488            steward.charter.as_deref(),
16489            Some("mind the queues"),
16490            "and carries the spawn args over from the retired table",
16491        );
16492        assert_eq!(steward.kick.as_deref(), Some("begin"));
16493        assert_eq!(steward.resume_kick.as_deref(), Some("re-read the notes"));
16494        assert_eq!(
16495            steward.session_id.as_deref(),
16496            Some("steward-id"),
16497            "onto the row it already had, keeping the occupant that row names - a plain write \
16498             here would blank the id, re-minting one and orphaning the transcript",
16499        );
16500        assert_eq!(
16501            crate::store::dynamic_workers::count(db).expect("count"),
16502            0,
16503            "the retired table is drained",
16504        );
16505        // The drop itself, which the count cannot show: an emptied table
16506        // and a dropped one both report zero rows, so ask whether there is
16507        // still a table to drop.
16508        assert!(
16509            !crate::store::dynamic_workers::drop_table(db).expect("drop"),
16510            "and the boot dropped it, rather than leaving an empty table behind",
16511        );
16512        assert_eq!(steward.interactive, Some(true));
16513        let lead =
16514            crate::store::sessions::get(db, "Default", "forge", forge_primitives::LEAD_LABEL)
16515                .expect("read")
16516                .expect("the row that was already there survives");
16517        assert_eq!(lead.session_id.as_deref(), Some("lead-session-id"), "with its id untouched");
16518    }
16519
16520    /// The fired-prompt pairings are swept by the boot itself: a sweep wired
16521    /// nowhere is the unbounded table the issue names.
16522    #[tokio::test]
16523    async fn booting_sweeps_fired_prompt_pairings_past_the_window() {
16524        let dir = make_workspace_dir_with_two_accounts();
16525        let app_support = dir.path().join("app-support");
16526        fs::create_dir_all(&app_support).expect("app-support dir");
16527        let db_path = store_path(&app_support, dir.path());
16528        fs::create_dir_all(db_path.parent().expect("the stores dir")).expect("stores dir");
16529        let db = crate::store::Db::open(&db_path).expect("open db");
16530        let name = forge_primitives::cron::CronName {
16531            cron_id: "c1".to_owned(),
16532            description: Some("Morning summary".to_owned()),
16533        };
16534        let stale = std::time::SystemTime::now()
16535            .checked_sub(std::time::Duration::from_secs(31 * 24 * 60 * 60))
16536            .expect("a window ago");
16537        crate::store::cron_names::put(&db, "fired", &name, stale).expect("seed a stale pairing");
16538        // A row nine tenths of the way through the window: a seconds/minutes
16539        // mix-up in the constant would drop this one, so it pins the magnitude
16540        // from below as the stale row pins it from above.
16541        let inside = std::time::SystemTime::now()
16542            .checked_sub(std::time::Duration::from_secs(29 * 24 * 60 * 60))
16543            .expect("inside the window");
16544        crate::store::cron_names::put(&db, "inside", &name, inside).expect("seed a young pairing");
16545        crate::store::cron_names::put(&db, "fresh", &name, std::time::SystemTime::now())
16546            .expect("seed a fresh pairing");
16547        drop(db);
16548
16549        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
16550
16551        let guard = workspace.db.lock();
16552        let db = guard.as_ref().expect("db");
16553        let kept =
16554            crate::store::cron_names::for_uuids(db, &["fired", "inside", "fresh"]).expect("read");
16555        assert_eq!(
16556            kept.keys().collect::<Vec<_>>(),
16557            vec!["fresh", "inside"],
16558            "the boot drops the pairing past the window and keeps the ones inside it"
16559        );
16560    }
16561
16562    /// The retired table is dropped only once every row it held has been
16563    /// copied. A row no configured project can key stays where it is, so
16564    /// the table has to stay with it: dropped, that worker exists nowhere
16565    /// else and its label silently stops re-spawning, with nothing on
16566    /// screen to say which one went.
16567    #[tokio::test]
16568    async fn booting_keeps_the_retired_table_when_a_row_could_not_be_keyed() {
16569        let dir = make_workspace_dir_with_two_accounts();
16570        let app_support = dir.path().join("app-support");
16571        fs::create_dir_all(&app_support).expect("app-support dir");
16572        let db_path = store_path(&app_support, dir.path());
16573        fs::create_dir_all(db_path.parent().expect("the stores dir")).expect("stores dir");
16574        let db = crate::store::Db::open(&db_path).expect("open db");
16575        crate::store::dynamic_workers::insert_for_test(
16576            &db,
16577            &crate::store::dynamic_workers::DynamicWorker {
16578                project_key: "a-project-the-config-no-longer-names".to_owned(),
16579                label: "steward".to_owned(),
16580                charter: "mind the queues".to_owned(),
16581                kick: None,
16582                resume_kick: None,
16583                interactive: false,
16584            },
16585        )
16586        .expect("seed a worker whose project is gone");
16587        drop(db);
16588
16589        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
16590
16591        let guard = workspace.db.lock();
16592        let left = crate::store::dynamic_workers::list_all(guard.as_ref().expect("db"))
16593            .expect("read the retired table");
16594        assert_eq!(
16595            left.len(),
16596            1,
16597            "the row could not be keyed, so it is still only there and the table must survive",
16598        );
16599        assert_eq!(left[0].label, "steward");
16600    }
16601
16602    /// A project with no lead anywhere spawns under an id forge mints,
16603    /// and the row holds it: the second start re-enters that session
16604    /// instead of minting a third id for a project that had none.
16605    #[tokio::test]
16606    async fn a_lead_spawn_mints_an_id_and_records_it() {
16607        let dir = make_workspace_dir_with_two_accounts();
16608        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16609        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
16610        workspace.seed_test_ready_account("Stargate");
16611
16612        let _handle = workspace
16613            .get_agent_handle(
16614                SessionTarget::Default,
16615                SessionLaunchSettings::default(),
16616                &crate::protocol::SpawnRole::Lead,
16617            )
16618            .expect("spawn");
16619
16620        let id = workspace
16621            .stored_session_id("Default", "forge", forge_primitives::LEAD_LABEL)
16622            .expect("the store is readable")
16623            .expect("the lead's id reaches the store");
16624        assert!(
16625            uuid::Uuid::parse_str(&id).is_ok(),
16626            "the id is a uuid, which is what the catalog's validators accept: {id}",
16627        );
16628        assert_eq!(
16629            workspace
16630                .stored_resume_id(&SessionSlot::lead("Default", "forge"), false)
16631                .expect("the store is readable"),
16632            Some(id.clone()),
16633            "a later start re-enters the recorded session",
16634        );
16635    }
16636
16637    /// The row outlives the process: a second workspace on the same store
16638    /// reads the id the first one recorded rather than starting another
16639    /// session under a new one.
16640    #[tokio::test]
16641    async fn a_lead_row_survives_a_restart() {
16642        let dir = make_workspace_dir_with_two_accounts();
16643        let id = {
16644            let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16645            workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
16646            workspace.seed_test_ready_account("Stargate");
16647            let _handle = workspace
16648                .get_agent_handle(
16649                    SessionTarget::Default,
16650                    SessionLaunchSettings::default(),
16651                    &crate::protocol::SpawnRole::Lead,
16652                )
16653                .expect("spawn");
16654            workspace
16655                .stored_session_id("Default", "forge", forge_primitives::LEAD_LABEL)
16656                .expect("the store is readable")
16657                .expect("the lead's id reaches the store")
16658        };
16659
16660        let restarted = Workspace::new_for_test(dir.path().to_owned()).expect("second boot");
16661        let project = restarted.config_arc().default_project().clone();
16662        assert_eq!(
16663            restarted
16664                .stored_resume_id(&SessionSlot::lead(&project.org, &project.name), false)
16665                .expect("the store is readable"),
16666            Some(id.clone()),
16667            "the second boot re-enters the session the first one recorded",
16668        );
16669    }
16670
16671    /// `--new` rewrites the lead's row rather than reusing what it holds:
16672    /// the flag is what makes the boot wave's leads come up fresh, and a
16673    /// `force_new` that fell through to the store would resume the very
16674    /// session it was given to replace.
16675    #[tokio::test]
16676    async fn force_new_rewrites_the_lead_row_rather_than_reusing_it() {
16677        let dir = make_workspace_dir_with_two_accounts();
16678        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16679        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
16680        workspace.seed_test_ready_account("Stargate");
16681        let project = workspace.config_arc().default_project().clone();
16682        workspace.record_session_id(
16683            &project.org,
16684            &project.name,
16685            forge_primitives::LEAD_LABEL,
16686            "stored-lead-id",
16687        );
16688
16689        let _handle = workspace
16690            .get_agent_handle(
16691                SessionTarget::Default,
16692                SessionLaunchSettings { force_new: true, ..SessionLaunchSettings::default() },
16693                &crate::protocol::SpawnRole::Lead,
16694            )
16695            .expect("spawn");
16696
16697        let id = workspace
16698            .stored_session_id(&project.org, &project.name, forge_primitives::LEAD_LABEL)
16699            .expect("the store is readable")
16700            .expect("the row still holds an id");
16701        assert_ne!(id, "stored-lead-id", "`--new` must not reuse the id the store held");
16702        assert!(
16703            uuid::Uuid::parse_str(&id).is_ok(),
16704            "the row is rewritten with the id this spawn minted: {id}",
16705        );
16706    }
16707
16708    /// A row with no id, as the first boot after the store gained ids
16709    /// leaves it.
16710    fn seed_session_row(workspace: &Workspace, org: &str, project: &str, label: &str) {
16711        let db = workspace.db.lock();
16712        let db = db.as_ref().expect("db");
16713        crate::store::sessions::put(
16714            db,
16715            &crate::store::sessions::SessionRecord {
16716                org: org.to_owned(),
16717                project: project.to_owned(),
16718                label: label.to_owned(),
16719                session_id: None,
16720                charter: None,
16721                kick: None,
16722                resume_kick: None,
16723                interactive: None,
16724                seat_state: None,
16725                is_git_repo: None,
16726                mcp_families: None,
16727                cwd: None,
16728            },
16729        )
16730        .expect("seed the row");
16731    }
16732
16733    fn project_key_for(project: &LoadedProject) -> crate::target::ProjectKey {
16734        ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
16735            &project.path.to_string_lossy(),
16736        )))
16737    }
16738
16739    /// The store is the only source for a lead's resume. A row with no id
16740    /// is a session that has never run, so the spawn mints a fresh one -
16741    /// even with a lead transcript sitting in the catalog, which is what
16742    /// every boot read before the store held ids.
16743    #[tokio::test]
16744    async fn a_lead_row_with_no_id_starts_fresh() {
16745        let dir = make_workspace_dir_with_two_accounts();
16746        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16747        let project = workspace.config_arc().default_project().clone();
16748        workspace.catalog.lock().insert(
16749            project_key_for(&project),
16750            vec![forge_primitives::SDKSessionInfo {
16751                session_id: "catalog-lead-id".to_owned(),
16752                summary: "lead".to_owned(),
16753                last_modified: 0,
16754                file_size: None,
16755                custom_title: None,
16756                first_prompt: None,
16757                git_branch: None,
16758                cwd: None,
16759                storage_key: String::new(),
16760                tag: None,
16761                created_at: None,
16762            }],
16763        );
16764        seed_session_row(&workspace, &project.org, &project.name, forge_primitives::LEAD_LABEL);
16765
16766        assert_eq!(
16767            workspace
16768                .stored_resume_id(&SessionSlot::lead(&project.org, &project.name), false)
16769                .expect("the store is readable"),
16770            None,
16771            "a row with no id has nothing to resume, whatever the catalog holds",
16772        );
16773    }
16774
16775    /// A row that cannot be read is not a row without an id. Minting on a
16776    /// read error would fork the session and write the new id over the
16777    /// row it failed to read, so the spawn is refused instead.
16778    #[tokio::test]
16779    async fn an_unreadable_session_row_refuses_the_spawn_rather_than_forking() {
16780        let dir = make_workspace_dir_with_two_accounts();
16781        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16782        let project = workspace.config_arc().default_project().clone();
16783        {
16784            let db = workspace.db.lock();
16785            crate::store::sessions::put_raw_for_test(
16786                db.as_ref().expect("test store"),
16787                &project.org,
16788                &project.name,
16789                forge_primitives::LEAD_LABEL,
16790                b"not a session record",
16791            )
16792            .expect("plant the undecodable row");
16793        }
16794
16795        assert!(
16796            matches!(
16797                workspace.stored_resume_id(&SessionSlot::lead(&project.org, &project.name), false),
16798                Err(crate::error::WorkspaceError::SessionStoreUnreadable { .. })
16799            ),
16800            "a row that cannot be read is refused, not treated as absent",
16801        );
16802        assert!(
16803            workspace.resolve_slot(&SessionTarget::Named(project.name.clone())).is_err()
16804                || workspace
16805                    .stored_resume_id(&SessionSlot::lead(&project.org, &project.name), false)
16806                    .is_err(),
16807            "and the refusal reaches the spawn's target resolution, so nothing mints over it",
16808        );
16809        assert!(
16810            crate::store::sessions::get(
16811                workspace.db.lock().as_ref().expect("test store"),
16812                &project.org,
16813                &project.name,
16814                forge_primitives::LEAD_LABEL,
16815            )
16816            .is_err(),
16817            "the refused spawn left the unreadable row exactly as it found it",
16818        );
16819    }
16820
16821    /// The store follows the id the CLI is actually running. An
16822    /// in-session `/resume`, a `/clear`, a login or a logout move a
16823    /// session's id without forge choosing it, and a boot resolves a
16824    /// session from this row: without this the next boot resumes the
16825    /// stale id and the registration keeps naming a session that is no
16826    /// longer running.
16827    #[tokio::test]
16828    async fn the_store_follows_the_id_the_cli_adopted() {
16829        let dir = make_workspace_dir_with_two_accounts();
16830        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16831        let key = SessionSlot::lead("Default", "forge");
16832        workspace.seed_test_bound_session(&key, "Stargate");
16833        seed_session_row(&workspace, "Default", "forge", forge_primitives::LEAD_LABEL);
16834
16835        workspace.note_running_session_id(&key, "adopted-id");
16836        assert_eq!(
16837            workspace
16838                .stored_session_id("Default", "forge", forge_primitives::LEAD_LABEL)
16839                .expect("the store is readable")
16840                .as_deref(),
16841            Some("adopted-id"),
16842            "the row holds the id the CLI adopted, not a stale one",
16843        );
16844
16845        workspace.note_running_session_id(&key, "adopted-id");
16846        assert_eq!(
16847            workspace
16848                .stored_session_id("Default", "forge", forge_primitives::LEAD_LABEL)
16849                .expect("the store is readable")
16850                .as_deref(),
16851            Some("adopted-id"),
16852            "and re-noting the same id leaves it alone",
16853        );
16854    }
16855
16856    #[tokio::test]
16857    async fn a_fresh_spawn_stamps_the_gateway_base_url_and_dummy_credential() {
16858        let dir = make_workspace_dir_with_two_accounts();
16859        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16860        // The listener's own tests cover the socket; this one pins what
16861        // the child is stamped with, so the boot gate is opened by hand.
16862        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
16863        workspace.seed_test_ready_account("Stargate");
16864
16865        let handle = workspace
16866            .get_agent_handle(
16867                SessionTarget::Default,
16868                SessionLaunchSettings::default(),
16869                &crate::protocol::SpawnRole::Lead,
16870            )
16871            .expect("spawn");
16872        let env = handle.env();
16873        let base = env
16874            .get("ANTHROPIC_BASE_URL")
16875            .expect("the gateway stamps a base URL on every registered spawn");
16876        assert!(
16877            base.starts_with("http://127.0.0.1:") && base.contains("/Default/forge/"),
16878            "the base URL names the listener and the three routing segments: {base}",
16879        );
16880        assert_eq!(
16881            env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str),
16882            Some(forge_gateway::binding::DUMMY_CREDENTIAL),
16883            "the child's credential variable carries the dummy, not the real token",
16884        );
16885        assert_eq!(
16886            env.get("ANTHROPIC_API_KEY").map(String::as_str),
16887            Some(""),
16888            "an API-key source is forced empty so the CLI cannot ship a foreign credential",
16889        );
16890
16891        // The binding is keyed by the session's own id - the third
16892        // routing segment, and the tail of the base URL above - not by
16893        // the slot, which is the routing key rather than the occupant.
16894        let session_key = workspace.resolve_slot(&SessionTarget::Default).expect("resolves");
16895        let session_id = {
16896            let pool = workspace.pool.lock();
16897            pool.get(&session_key).expect("the spawn pooled the lead").session_id.clone()
16898        };
16899        assert_eq!(
16900            workspace.gateway.bindings.binding_for("Default", "forge", &session_id),
16901            Some(AccountKey("Stargate".to_owned())),
16902            "the spawn registered the session with the gateway",
16903        );
16904    }
16905
16906    #[tokio::test]
16907    async fn a_project_env_cannot_unstamp_the_gateway_base_url() {
16908        let dir = make_workspace_dir_with_two_accounts();
16909        // The project layer carries a base-url key pointing elsewhere:
16910        // exactly the silent-bypass hazard the stamp closes.
16911        fs::write(
16912            forge_toml_path(dir.path()),
16913            r#"
16914[[orgs]]
16915name = "Default"
16916accounts = ["Stargate", "Gateway"]
16917
16918[[orgs.projects]]
16919name = "forge"
16920path = "~/Projects/forge"
16921model = "claude-sonnet-5"
16922
16923[orgs.projects.env]
16924ANTHROPIC_BASE_URL = "http://169.254.10.10:9999"
16925CLAUDE_CODE_API_BASE_URL = "http://169.254.10.10:9999"
16926
16927[[accounts]]
16928display_name = "Stargate"
16929token = "t"
16930models = ["claude-sonnet-5"]
16931provider = "anthropic"
16932
16933[[accounts]]
16934display_name = "Gateway"
16935token = "t"
16936models = ["claude-sonnet-5"]
16937provider = "anthropic"
16938"#,
16939        )
16940        .expect("write forge.toml");
16941        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16942        workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
16943        workspace.seed_test_ready_account("Stargate");
16944
16945        let handle = workspace
16946            .get_agent_handle(
16947                SessionTarget::Default,
16948                SessionLaunchSettings::default(),
16949                &crate::protocol::SpawnRole::Lead,
16950            )
16951            .expect("spawn");
16952        let env = handle.env();
16953        let base = env.get("ANTHROPIC_BASE_URL").expect("base url stamped");
16954        assert!(
16955            base.starts_with("http://127.0.0.1:") && base.contains("/Default/forge/"),
16956            "the project layer must not point the child away from the listener: {base}",
16957        );
16958        assert_eq!(
16959            env.get("CLAUDE_CODE_API_BASE_URL").map(String::as_str),
16960            Some(base.as_str()),
16961            "the alt base-url variable is stamped to the listener too",
16962        );
16963        assert!(
16964            !env.values().any(|v| v.contains("169.254.10.10")),
16965            "the foreign host never reaches the child: {env:?}",
16966        );
16967    }
16968
16969    #[tokio::test]
16970    async fn a_closed_boot_gate_refuses_spawns_naming_the_port() {
16971        let dir = make_workspace_dir_with_two_accounts();
16972        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16973        workspace.gateway_ready.store(false, std::sync::atomic::Ordering::Release);
16974
16975        let message = match workspace.get_agent_handle(
16976            SessionTarget::Default,
16977            SessionLaunchSettings::default(),
16978            &crate::protocol::SpawnRole::Lead,
16979        ) {
16980            Err(error) => error.to_string(),
16981            Ok(_) => panic!("a shut boot gate refuses spawns"),
16982        };
16983        assert!(
16984            message.contains("8787") || message.contains("port"),
16985            "the refusal names the port, got: {message}",
16986        );
16987        assert!(
16988            message.contains("not ready") || message.contains("boot gate"),
16989            "the refusal says the gateway is the cause, got: {message}",
16990        );
16991    }
16992
16993    #[tokio::test]
16994    async fn pool_records_picked_account() {
16995        let dir = make_workspace_dir_with_two_accounts();
16996        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
16997        workspace.seed_test_ready_account("Stargate");
16998        workspace.seed_test_ready_account("Gateway");
16999        let _ = workspace
17000            .get_agent_handle(
17001                SessionTarget::Default,
17002                SessionLaunchSettings::default(),
17003                &crate::protocol::SpawnRole::Lead,
17004            )
17005            .expect("default");
17006        let bound = workspace.pool.lock().values().map(|p| p.account.0.clone()).collect::<Vec<_>>();
17007        assert_eq!(bound.len(), 1);
17008        // Every spawn walks, so the pick is the first ready account in
17009        // the org's own order - there is no per-spawn spread.
17010        assert_eq!(bound[0], "Stargate");
17011    }
17012
17013    #[tokio::test]
17014    async fn project_account_pin_excludes_unpinned_account() {
17015        // Three accounts globally, all Ready; the default org pins only
17016        // {Stargate, Gateway}, so a spawn under the default project
17017        // walks the pinned pair in its own order and never reaches
17018        // Personal.
17019        let dir = tempdir().expect("tempdir");
17020        fs::write(
17021            forge_toml_path(dir.path()),
17022            r#"
17023[[orgs]]
17024name = "Default"
17025accounts = ["Stargate", "Gateway"]
17026
17027[[orgs.projects]]
17028name = "forge"
17029path = "~/Projects/forge"
17030model = "claude-sonnet-5"
17031
17032[[accounts]]
17033display_name = "Stargate"
17034token = "t"
17035models = ["claude-sonnet-5"]
17036provider = "anthropic"
17037
17038[[accounts]]
17039display_name = "Gateway"
17040token = "t"
17041models = ["claude-sonnet-5"]
17042provider = "anthropic"
17043
17044[[accounts]]
17045display_name = "Personal"
17046token = "t"
17047models = ["claude-sonnet-5"]
17048provider = "anthropic"
17049"#,
17050        )
17051        .expect("write forge.toml");
17052
17053        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
17054        for account in ["Stargate", "Gateway", "Personal"] {
17055            workspace.seed_test_ready_account(account);
17056        }
17057        let _ = workspace
17058            .get_agent_handle(
17059                SessionTarget::Default,
17060                SessionLaunchSettings::default(),
17061                &crate::protocol::SpawnRole::Lead,
17062            )
17063            .expect("default spawn");
17064
17065        let bound = workspace.pool.lock().values().map(|p| p.account.0.clone()).collect::<Vec<_>>();
17066        assert_eq!(bound.len(), 1);
17067        assert_eq!(
17068            bound[0], "Stargate",
17069            "the walk takes the first pinned account; Personal is never reached",
17070        );
17071    }
17072
17073    // ---- Refresh + facade tests ----
17074    //
17075    // Use a synthetically-registered `DomainSession` so the test
17076    // doesn't need a real subprocess. The stub handle's command
17077    // dispatcher captures whatever the workspace pushes through it,
17078    // which is what we assert on.
17079
17080    /// Wire one `DomainSession` for `key` against a fresh testing
17081    /// stub. Returns the workspace, the matching primitives command
17082    /// receiver, and the registered key.
17083    fn ws_with_stub_session()
17084    -> (Arc<Workspace>, mpsc::UnboundedReceiver<forge_primitives::AgentCommand>, SessionSlot) {
17085        let (workspace, _update_rx) = Workspace::testing_stub();
17086        let key = SessionSlot::from_str_for_test("refresh-test");
17087        let rx = workspace.install_testing_stub(&key);
17088        // Stamp a session_id so refresh paths don't bail on
17089        // "no session_id yet".
17090        if let Some(domain) = workspace.domain_session_for(&key) {
17091            domain.lock().session_id = Some(forge_primitives::SessionId::new(key.display()));
17092        }
17093        (workspace, rx, key)
17094    }
17095
17096    #[test]
17097    fn refresh_context_usage_dispatches_get_context_usage() {
17098        let (workspace, mut rx, key) = ws_with_stub_session();
17099        workspace.refresh_context_usage(&key).expect("dispatch");
17100        let cmd = rx.try_recv().expect("queued");
17101        assert!(matches!(cmd, forge_primitives::AgentCommand::GetContextUsage { .. }));
17102    }
17103
17104    #[test]
17105    fn refresh_mcp_snapshot_dispatches() {
17106        let (workspace, mut rx, key) = ws_with_stub_session();
17107        workspace.refresh_mcp_snapshot(&key).expect("dispatch");
17108        let cmd = rx.try_recv().expect("queued");
17109        assert!(matches!(cmd, forge_primitives::AgentCommand::GetMcpSnapshot { .. }));
17110    }
17111
17112    /// The OS walk's answer lands on the session that produced it, and a
17113    /// second session's read does not see it: the walk describes one
17114    /// subprocess tree, so a read that answered another slot's would
17115    /// report a process tree that is not running there.
17116    #[test]
17117    fn a_stored_process_snapshot_answers_only_its_own_session() {
17118        let (workspace, _update_rx) = Workspace::testing_stub();
17119        let walked = key_with_stub_handle(&workspace, "walked");
17120        let other = key_with_stub_handle(&workspace, "other");
17121        let walked_at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(60);
17122
17123        workspace.store_process_snapshot(&walked, Some(snapshot_walked_at(walked_at)));
17124
17125        assert_eq!(
17126            workspace.process_snapshot(&walked).map(|snapshot| snapshot.scanned_at),
17127            Some(walked_at),
17128            "the session that was walked reads its own snapshot back",
17129        );
17130        assert!(
17131            workspace.process_snapshot(&other).is_none(),
17132            "and its neighbour reads none, rather than the walk of another tree",
17133        );
17134    }
17135
17136    /// The walk described a subprocess tree that is gone, so the read
17137    /// must not keep serving it.
17138    #[test]
17139    fn a_cleared_process_snapshot_reads_as_none() {
17140        let (workspace, _update_rx) = Workspace::testing_stub();
17141        let key = key_with_stub_handle(&workspace, "walked");
17142        workspace.store_process_snapshot(
17143            &key,
17144            Some(snapshot_walked_at(std::time::SystemTime::UNIX_EPOCH)),
17145        );
17146
17147        workspace.store_process_snapshot(&key, None);
17148
17149        assert!(workspace.process_snapshot(&key).is_none(), "a cleared snapshot reads as none");
17150    }
17151
17152    /// A slot with no session at all answers rather than panicking, and
17153    /// holds nothing: a store that minted a domain for whoever asked
17154    /// would leave the workspace routing to a session nobody runs.
17155    #[test]
17156    fn a_process_snapshot_for_an_unregistered_slot_reads_as_none() {
17157        let (workspace, _update_rx) = Workspace::testing_stub();
17158        let stranger = SessionSlot::from_str_for_test("never-registered");
17159
17160        workspace.store_process_snapshot(
17161            &stranger,
17162            Some(snapshot_walked_at(std::time::SystemTime::UNIX_EPOCH)),
17163        );
17164
17165        assert!(
17166            workspace.process_snapshot(&stranger).is_none(),
17167            "an unregistered slot holds nothing rather than creating one",
17168        );
17169    }
17170
17171    fn snapshot_walked_at(
17172        scanned_at: std::time::SystemTime,
17173    ) -> forge_agent::env::processes::ProcessSnapshot {
17174        forge_agent::env::processes::ProcessSnapshot { processes: Vec::new(), scanned_at }
17175    }
17176
17177    fn key_with_stub_handle(workspace: &Arc<Workspace>, label: &str) -> SessionSlot {
17178        let key = SessionSlot::from_str_for_test(label);
17179        drop(workspace.install_testing_stub(&key));
17180        key
17181    }
17182
17183    #[test]
17184    fn refresh_context_usage_unknown_session_errors() {
17185        let (workspace, _update_rx) = Workspace::testing_stub();
17186        let key = SessionSlot::from_str_for_test("never-registered");
17187        let err = workspace.refresh_context_usage(&key).expect_err("unknown session");
17188        assert!(matches!(err, DispatchError::UnknownSession(_)));
17189    }
17190
17191    /// `dispatch(Command::Cancel)` falls back to synchronous direct
17192    /// dispatch when no SessionTask is registered. This is the path
17193    /// a view's unit tests rely on - the stub-handle wiring installs a
17194    /// stub handle but never spawns a task, and tests need to observe
17195    /// the primitive command on the rx.
17196    #[test]
17197    fn dispatch_falls_back_to_direct_when_no_session_task() {
17198        let (workspace, mut rx, key) = ws_with_stub_session();
17199        workspace.dispatch(Command::Cancel { key }).expect("dispatch");
17200        let cmd = rx.try_recv().expect("queued");
17201        assert!(matches!(cmd, forge_primitives::AgentCommand::Cancel { .. }));
17202    }
17203
17204    /// The two facts the failed-turn mark reads from the routing seam, on
17205    /// the production path a registered sender puts a dispatch on: Cancel
17206    /// arms the exempt stamp only while a turn is in flight, and a
17207    /// committed prompt spends the previous failure.
17208    #[test]
17209    fn routing_arms_cancels_in_flight_and_spends_the_failure_mark_on_a_committed_prompt() {
17210        let (workspace, _update_rx) = Workspace::testing_stub();
17211        let key = SessionSlot::from_str_for_test("routing-seam");
17212        workspace.register_domain_session(key.clone(), None);
17213        // Registers the sender, so dispatch takes the routed path rather
17214        // than the test-only synchronous fallback.
17215        workspace.mark_test_session_live(&key);
17216        let domain = workspace.domain_session_for(&key).expect("registered domain");
17217        domain.lock().session_id = Some(forge_primitives::SessionId::new(key.display()));
17218
17219        // An idle Cancel arms nothing: there is no turn to exempt.
17220        workspace.dispatch(Command::Cancel { key: key.clone() }).expect("dispatch");
17221        assert!(!domain.lock().pending_cancel, "an idle cancel arms nothing");
17222
17223        domain.lock().failed_turn_at = Some(std::time::SystemTime::now());
17224        domain.lock().turn_pending = true;
17225        workspace.dispatch(Command::Cancel { key: key.clone() }).expect("dispatch");
17226        assert!(domain.lock().pending_cancel, "a cancel over a live turn arms the stamp");
17227        assert!(
17228            workspace.session_failed_turn(&key).is_some(),
17229            "and leaves the standing mark in place",
17230        );
17231
17232        workspace
17233            .dispatch(Command::Prompt {
17234                key: key.clone(),
17235                text: "go".to_owned(),
17236                attachments: Vec::new(),
17237            })
17238            .expect("dispatch");
17239        assert!(
17240            workspace.session_failed_turn(&key).is_some(),
17241            "a prompt that only queues behind the busy turn keeps the failure mark - \
17242             the running turn is still the one that failed",
17243        );
17244        assert!(
17245            domain.lock().pending_cancel,
17246            "a prompt that only queues behind the busy turn keeps the cancel stamp - \
17247             its error Result still has to read as the reader's own cancel",
17248        );
17249
17250        // The busy turn ends, and a prompt committed with nothing in flight
17251        // starts a new one: the stamp and the failure mark expire there, or
17252        // this turn's genuine failure would go unmarked.
17253        domain.lock().turn_pending = false;
17254        domain.lock().pending_cancel = true;
17255        workspace
17256            .dispatch(Command::Prompt {
17257                key: key.clone(),
17258                text: "go".to_owned(),
17259                attachments: Vec::new(),
17260            })
17261            .expect("dispatch");
17262        assert!(
17263            !domain.lock().pending_cancel,
17264            "a prompt committed with no turn in flight expires the stamp",
17265        );
17266        assert!(
17267            workspace.session_failed_turn(&key).is_none(),
17268            "and spends the previous turn's failure mark: the newest turn is this one",
17269        );
17270    }
17271
17272    /// A committed prompt is the newest turn, so it spends the pending
17273    /// continuation left by the failure before it - left standing, it would
17274    /// resume a turn that is no longer the seat's newest.
17275    #[test]
17276    fn routing_drops_the_pending_nudge_on_a_prompt() {
17277        let (workspace, _update_rx) = Workspace::testing_stub();
17278        let key = SessionSlot::from_str_for_test("routing-nudge");
17279        workspace.register_domain_session(key.clone(), None);
17280        // Registers the sender, so dispatch takes the routed path rather
17281        // than the test-only synchronous fallback.
17282        workspace.mark_test_session_live(&key);
17283        let domain = workspace.domain_session_for(&key).expect("registered domain");
17284        domain.lock().session_id = Some(forge_primitives::SessionId::new(key.display()));
17285        domain.lock().auto_continue =
17286            Some(crate::domain_session::PendingAutoContinue { reason: "server_error".to_owned() });
17287
17288        workspace
17289            .dispatch(Command::Prompt {
17290                key: key.clone(),
17291                text: "go".to_owned(),
17292                attachments: Vec::new(),
17293            })
17294            .expect("dispatch");
17295
17296        assert!(
17297            domain.lock().auto_continue.is_none(),
17298            "a committed prompt is the newest turn, so the continuation for the old one goes",
17299        );
17300    }
17301
17302    /// The review store write routes through the command bus: an
17303    /// `UpsertReviewThread` dispatch lands in the redb store (observable
17304    /// via the query-side load) and carries its confirmation back on the
17305    /// responder - the overlay's at-risk flag depends on it.
17306    #[test]
17307    fn dispatch_buses_the_review_and_spinner_writes() {
17308        use forge_primitives::review::{ReviewAnchor, ReviewSide, ReviewStatus, ReviewThread};
17309        let (workspace, _update_rx) = Workspace::testing_stub();
17310        let db_dir = tempfile::tempdir().expect("tempdir");
17311        workspace.install_db_for_test(
17312            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("db"),
17313        );
17314        let thread = ReviewThread {
17315            id: "t1".to_owned(),
17316            anchor: ReviewAnchor {
17317                path: "src/x.rs".to_owned(),
17318                side: ReviewSide::New,
17319                line: 1,
17320                content_hash: 1,
17321                context: vec!["ctx".to_owned()],
17322                base_ref: "main".to_owned(),
17323            },
17324            comments: Vec::new(),
17325            status: ReviewStatus::Open,
17326            created_at: "t".to_owned(),
17327            updated_at: "t".to_owned(),
17328            commit: None,
17329        };
17330
17331        let (respond_tx, mut respond_rx) = tokio::sync::oneshot::channel();
17332        workspace
17333            .dispatch(Command::UpsertReviewThread {
17334                project: "forge".to_owned(),
17335                branch: "feat".to_owned(),
17336                thread: thread.clone(),
17337                origin: None,
17338                sent_words: None,
17339                respond: Some(respond_tx),
17340            })
17341            .expect("dispatch");
17342        assert!(
17343            respond_rx.try_recv().expect("response present"),
17344            "an open store confirms the upsert on the responder"
17345        );
17346        let loaded = workspace.load_review_threads("forge", "feat").expect("load");
17347        assert_eq!(loaded.len(), 1, "the bus-routed upsert landed in the store");
17348    }
17349
17350    /// The same for the retraction: a `DeleteReviewThread` dispatch removes
17351    /// the thread from the store and confirms on the responder, and a
17352    /// second delete of the same id confirms `false` rather than lying.
17353    #[test]
17354    fn dispatch_buses_the_review_delete() {
17355        use forge_primitives::review::{ReviewAnchor, ReviewSide, ReviewStatus, ReviewThread};
17356        let (workspace, _update_rx) = Workspace::testing_stub();
17357        let db_dir = tempfile::tempdir().expect("tempdir");
17358        workspace.install_db_for_test(
17359            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("db"),
17360        );
17361        let thread = ReviewThread {
17362            id: "t1".to_owned(),
17363            anchor: ReviewAnchor {
17364                path: "src/x.rs".to_owned(),
17365                side: ReviewSide::New,
17366                line: 1,
17367                content_hash: 1,
17368                context: vec!["ctx".to_owned()],
17369                base_ref: "main".to_owned(),
17370            },
17371            comments: Vec::new(),
17372            status: ReviewStatus::Open,
17373            created_at: "t".to_owned(),
17374            updated_at: "t".to_owned(),
17375            commit: None,
17376        };
17377        workspace.upsert_review_thread("forge", "feat", thread);
17378
17379        let (respond_tx, mut respond_rx) = tokio::sync::oneshot::channel();
17380        workspace
17381            .dispatch(Command::DeleteReviewThread {
17382                project: "forge".to_owned(),
17383                branch: "feat".to_owned(),
17384                thread_id: "t1".to_owned(),
17385                respond: Some(respond_tx),
17386            })
17387            .expect("dispatch");
17388        assert!(
17389            respond_rx.try_recv().expect("response present"),
17390            "an open store confirms the delete on the responder"
17391        );
17392        let loaded = workspace.load_review_threads("forge", "feat").expect("load");
17393        assert!(loaded.is_empty(), "the bus-routed delete left the store empty");
17394
17395        let (again_tx, mut again_rx) = tokio::sync::oneshot::channel();
17396        workspace
17397            .dispatch(Command::DeleteReviewThread {
17398                project: "forge".to_owned(),
17399                branch: "feat".to_owned(),
17400                thread_id: "t1".to_owned(),
17401                respond: Some(again_tx),
17402            })
17403            .expect("dispatch");
17404        assert!(!again_rx.try_recv().expect("response present"), "an absent id deletes nothing");
17405    }
17406
17407    /// `/new` and `/resume` re-spawn on the already-pooled handle, where
17408    /// the spawn-path stamp in `get_agent_handle_at_key` never
17409    /// runs - the launch settings must pick the pooled session's mode up
17410    /// at dispatch instead.
17411    #[test]
17412    fn respawn_commands_on_a_pooled_session_carry_its_mode() {
17413        use forge_primitives::permission::PermissionMode;
17414        let (workspace, _update_rx) = Workspace::testing_stub();
17415        // The slot is the routing key; the id it is pooled under is the
17416        // occupant, and the registration's third segment is that id.
17417        let session_id = "respawn-mode-test";
17418        let key = SessionSlot::from_str_for_test(session_id);
17419        let (handle, mut agent_rx) = Workspace::testing_stub_handle();
17420        workspace.pool.lock().insert(
17421            key.clone(),
17422            PooledAgent {
17423                handle: Arc::new(handle),
17424                account: AccountKey("Openrouter".to_owned()),
17425                permission_mode: Some(PermissionMode::BypassPermissions),
17426                registration: Some(forge_gateway::binding::Registration {
17427                    org: "Busytools".to_owned(),
17428                    project: "forge".to_owned(),
17429                    session: session_id.to_owned(),
17430                    account: AccountKey("Openrouter".to_owned()),
17431                    provider: forge_primitives::account::Provider::Openrouter,
17432                }),
17433                session_id: session_id.to_owned(),
17434            },
17435        );
17436
17437        let auto_settings = || SessionLaunchSettings {
17438            settings: Some(serde_json::json!({ "permissions": { "defaultMode": "auto" } })),
17439            ..SessionLaunchSettings::default()
17440        };
17441        workspace
17442            .dispatch(Command::NewSession {
17443                key: key.clone(),
17444                cwd: "/tmp".to_owned(),
17445                launch_settings: auto_settings(),
17446                session_id: None,
17447            })
17448            .expect("dispatch new");
17449        workspace
17450            .dispatch(Command::ResumeSession {
17451                key: key.clone(),
17452                session_id: "old-uuid".to_owned(),
17453                cwd: "/tmp".to_owned(),
17454                launch_settings: auto_settings(),
17455            })
17456            .expect("dispatch resume");
17457
17458        let carried_mode = |value: &serde_json::Value| -> Option<String> {
17459            value
17460                .get("settings")
17461                .and_then(|s| s.get(SessionLaunchSettings::PERMISSIONS_KEY))
17462                .and_then(|p| p.get(SessionLaunchSettings::PERMISSIONS_DEFAULT_MODE_KEY))
17463                .and_then(serde_json::Value::as_str)
17464                .map(str::to_owned)
17465        };
17466        let first = agent_rx.try_recv().expect("new session agent command");
17467        let second = agent_rx.try_recv().expect("resume agent command");
17468        let forge_primitives::AgentCommand::NewSession {
17469            session_id: new_id,
17470            launch_settings: new,
17471            ..
17472        } = first
17473        else {
17474            panic!("expected a NewSession agent command");
17475        };
17476        let forge_primitives::AgentCommand::ResumeSession { launch_settings: resume, .. } = second
17477        else {
17478            panic!("expected a ResumeSession agent command");
17479        };
17480        assert_eq!(
17481            carried_mode(&new).as_deref(),
17482            Some("bypassPermissions"),
17483            "/new must carry the pooled session's mode, not the session default",
17484        );
17485        assert_eq!(
17486            carried_mode(&resume).as_deref(),
17487            Some("bypassPermissions"),
17488            "/resume must carry the pooled session's mode, not the session default",
17489        );
17490        // The respawn also re-registers with the gateway: the fresh env
17491        // set rides the launch settings as overrides, so the respawned
17492        // child is pointed at this generation's listener - under the id
17493        // it will run as, not the one it replaces.
17494        let base_url = |value: &serde_json::Value| -> Option<String> {
17495            value
17496                .get("env_overrides")
17497                .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
17498                .and_then(serde_json::Value::as_str)
17499                .map(str::to_owned)
17500        };
17501        let minted = new_id.expect("`/new` starts under a minted id");
17502        assert_eq!(
17503            base_url(&new).as_deref(),
17504            Some(
17505                format!("http://127.0.0.1:{}/Busytools/forge/{minted}", workspace.gateway_port)
17506                    .as_str()
17507            ),
17508            "`/new` stamps the base URL with the id it mints for the child",
17509        );
17510        assert_eq!(
17511            base_url(&resume).as_deref(),
17512            Some(
17513                format!("http://127.0.0.1:{}/Busytools/forge/old-uuid", workspace.gateway_port)
17514                    .as_str()
17515            ),
17516            "`/resume` stamps the base URL with the transcript it re-enters",
17517        );
17518        assert_eq!(
17519            workspace.gateway.bindings.binding_for("Busytools", "forge", "old-uuid"),
17520            Some(AccountKey("Openrouter".to_owned())),
17521            "the binding follows the occupant the resume leaves running",
17522        );
17523        assert_eq!(
17524            workspace.gateway.bindings.binding_for("Busytools", "forge", &minted),
17525            None,
17526            "and the segment the new session left behind is dropped",
17527        );
17528        // The overrides carry ONLY the four gateway-owned keys. Carrying
17529        // the whole env would let a key declared in both layers revert
17530        // to its account value on respawn.
17531        let overrides =
17532            new.get("env_overrides").and_then(|e| e.as_object()).expect("env overrides present");
17533        assert_eq!(overrides.len(), 4, "exactly the four gateway-owned keys");
17534    }
17535
17536    /// `/new` and `/resume` re-spawn on the already-pooled handle, where
17537    /// the spawn-path stamp never runs - the launch settings must pick
17538    /// the project's canonical model up at dispatch instead, the way
17539    /// they pick up the mode.
17540    #[test]
17541    fn respawn_commands_on_a_pooled_session_carry_the_project_model() {
17542        let dir = make_workspace_dir_declaring_model();
17543        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
17544        let key = SessionSlot::from_str_for_test("respawn-model-test");
17545        let (handle, mut agent_rx) = Workspace::testing_stub_handle();
17546        workspace.pool.lock().insert(
17547            key.clone(),
17548            PooledAgent {
17549                handle: Arc::new(handle),
17550                account: AccountKey("OpenRouter-TM".to_owned()),
17551                permission_mode: None,
17552                registration: Some(forge_gateway::binding::Registration {
17553                    org: "Default".to_owned(),
17554                    project: "forge".to_owned(),
17555                    session: key.display(),
17556                    account: AccountKey("OpenRouter-TM".to_owned()),
17557                    provider: forge_primitives::account::Provider::Openrouter,
17558                }),
17559                session_id: key.display(),
17560            },
17561        );
17562
17563        // The caller's pin is the literal opus - exactly the string the
17564        // respawn must replace with the project's canonical model.
17565        let caller_settings = || SessionLaunchSettings {
17566            settings: Some(serde_json::json!({ "model": "opus" })),
17567            ..SessionLaunchSettings::default()
17568        };
17569        workspace
17570            .dispatch(Command::NewSession {
17571                key: key.clone(),
17572                cwd: "/tmp".to_owned(),
17573                launch_settings: caller_settings(),
17574                session_id: None,
17575            })
17576            .expect("dispatch new");
17577        workspace
17578            .dispatch(Command::ResumeSession {
17579                key: key.clone(),
17580                session_id: "old-uuid".to_owned(),
17581                cwd: "/tmp".to_owned(),
17582                launch_settings: caller_settings(),
17583            })
17584            .expect("dispatch resume");
17585
17586        let carried_model = |value: &serde_json::Value| -> Option<String> {
17587            value
17588                .get("settings")
17589                .and_then(|settings| settings.get("model"))
17590                .and_then(serde_json::Value::as_str)
17591                .map(str::to_owned)
17592        };
17593        let first = agent_rx.try_recv().expect("new session agent command");
17594        let second = agent_rx.try_recv().expect("resume agent command");
17595        let forge_primitives::AgentCommand::NewSession { launch_settings: new, .. } = first else {
17596            panic!("expected a NewSession agent command");
17597        };
17598        let forge_primitives::AgentCommand::ResumeSession { launch_settings: resume, .. } = second
17599        else {
17600            panic!("expected a ResumeSession agent command");
17601        };
17602        assert_eq!(
17603            carried_model(&new).as_deref(),
17604            Some("deepseek-v4.1-flash"),
17605            "/new must carry the project's canonical model, not the caller's pin",
17606        );
17607        assert_eq!(
17608            carried_model(&resume).as_deref(),
17609            Some("deepseek-v4.1-flash"),
17610            "/resume must carry the project's canonical model, not the caller's pin",
17611        );
17612    }
17613
17614    /// A session that spawned with no project mode must not gain a
17615    /// fallback mode at dispatch: the launcher's own session default
17616    /// survives a respawn.
17617    #[test]
17618    fn respawn_commands_on_a_session_with_no_mode_keep_the_launcher_default() {
17619        let (workspace, _update_rx) = Workspace::testing_stub();
17620        let key = SessionSlot::from_str_for_test("respawn-modeless-test");
17621        let (handle, mut agent_rx) = Workspace::testing_stub_handle();
17622        workspace.pool.lock().insert(
17623            key.clone(),
17624            PooledAgent {
17625                handle: Arc::new(handle),
17626                account: AccountKey("Plain".to_owned()),
17627                permission_mode: None,
17628                registration: None,
17629                session_id: "pooled-session".to_owned(),
17630            },
17631        );
17632
17633        workspace
17634            .dispatch(Command::NewSession {
17635                key: key.clone(),
17636                cwd: "/tmp".to_owned(),
17637                launch_settings: SessionLaunchSettings {
17638                    // "plan", not the real launcher default "auto": an Auto
17639                    // fallback mutant would survive an auto seed.
17640                    settings: Some(serde_json::json!({ "permissions": { "defaultMode": "plan" } })),
17641                    ..SessionLaunchSettings::default()
17642                },
17643                session_id: None,
17644            })
17645            .expect("dispatch new");
17646
17647        let forge_primitives::AgentCommand::NewSession { launch_settings, .. } =
17648            agent_rx.try_recv().expect("new session agent command")
17649        else {
17650            panic!("expected a NewSession agent command");
17651        };
17652        let carried = launch_settings
17653            .get("settings")
17654            .and_then(|s| s.get(SessionLaunchSettings::PERMISSIONS_KEY))
17655            .and_then(|p| p.get(SessionLaunchSettings::PERMISSIONS_DEFAULT_MODE_KEY))
17656            .and_then(serde_json::Value::as_str);
17657        assert_eq!(
17658            carried,
17659            Some("plan"),
17660            "a session with no project mode must keep the launcher's session default",
17661        );
17662        // The pool entry carries no registration, so the respawn stays
17663        // direct: no gateway-owned key rides the overrides, and the
17664        // child keeps whatever credential its launch settings compose.
17665        let overrides_empty = launch_settings
17666            .get("env_overrides")
17667            .and_then(|e| e.as_object())
17668            .is_none_or(serde_json::Map::is_empty);
17669        assert!(
17670            overrides_empty,
17671            "a None-registration respawn applies no gateway overrides: {launch_settings}",
17672        );
17673    }
17674
17675    // ---- Session-task routing tests ----
17676    //
17677    // A routed command names the slot, and the `SessionTask` is
17678    // registered under that same slot, so nothing has to be migrated
17679    // when the occupant changes. What these tests pin is what still
17680    // moves on its own: pool membership when a task exits, and the
17681    // binding a slot's occupant reads.
17682
17683    /// Seed `command_senders`, `pool`, and `domain_handles` at `key`
17684    /// against a fresh stub handle so `Workspace::dispatch` will
17685    /// route through the `SessionTask`-style fast path (rather than
17686    /// the test-only direct fallback). Returns the routed-command
17687    /// receiver so the test can assert on what flows through.
17688    fn install_fake_session_task(
17689        workspace: &Arc<Workspace>,
17690        key: &SessionSlot,
17691    ) -> mpsc::UnboundedReceiver<Command> {
17692        let (handle, _agent_rx) = Workspace::testing_stub_handle();
17693        let arc = Arc::new(handle);
17694        workspace.pool.lock().insert(
17695            key.clone(),
17696            PooledAgent {
17697                handle: Arc::clone(&arc),
17698                account: AccountKey("test".to_owned()),
17699                permission_mode: None,
17700                registration: None,
17701                session_id: "pooled-session".to_owned(),
17702            },
17703        );
17704        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Command>();
17705        workspace.command_senders.lock().insert(key.clone(), cmd_tx);
17706        let domain = workspace.register_domain_session(key.clone(), Some(arc));
17707        domain.lock().session_id = Some(forge_primitives::SessionId::new(key.display()));
17708        cmd_rx
17709    }
17710
17711    /// **A prompt a dead task never receives spends the turn it committed.**
17712    /// The send is refused, so no Result will ever serve it; leaving
17713    /// `turn_pending` set draws a turn in flight on the header that nothing
17714    /// can close.
17715    #[test]
17716    fn a_prompt_a_dead_task_never_receives_leaves_no_turn_in_flight() {
17717        use crate::store::sessions::SeatState;
17718
17719        let dir = tempdir().expect("tempdir");
17720        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
17721        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
17722        let key = SessionSlot::lead("TestOrg", "forge");
17723        ws.record_session_id("TestOrg", "forge", "lead", "id-1");
17724        ws.record_seat_state(&key, SeatState::InTurn);
17725        let receiver = install_fake_session_task(&ws, &key);
17726        drop(receiver);
17727
17728        let refused = ws.dispatch(Command::Prompt {
17729            key: key.clone(),
17730            text: "hello".to_owned(),
17731            attachments: Vec::new(),
17732        });
17733
17734        assert!(refused.is_err(), "the dead task refuses the prompt");
17735        let domain = ws.domain_session_for(&key).expect("the fixture registers the seat");
17736        assert!(
17737            !domain.lock().turn_in_flight(),
17738            "a refused prompt leaves no turn in flight for the header",
17739        );
17740        assert_eq!(
17741            ws.stored_seat_state(&key),
17742            Some(SeatState::Idle),
17743            "and the row must not say a turn is running for a prompt no task ever saw",
17744        );
17745    }
17746
17747    /// **The turn is on the row before the CLI hears of it.** A boot that
17748    /// finds the seat mid-turn hands it the restart note, so a crash
17749    /// between this send and the turn's Result leaves the interrupted turn
17750    /// standing rather than reading as a seat that was parked. Both prompt
17751    /// arms mark it: a send the reader typed, and the `PromptUnder` a
17752    /// handoff's note and a peer's message arrive as.
17753    #[test]
17754    fn a_committed_turn_marks_the_seat_in_turn() {
17755        use crate::store::sessions::SeatState;
17756
17757        let dir = tempdir().expect("tempdir");
17758        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
17759        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
17760        let key = SessionSlot::lead("TestOrg", "forge");
17761        let _commands = install_fake_session_task(&ws, &key);
17762        ws.record_session_id("TestOrg", "forge", "lead", "id-1");
17763
17764        for (label, command) in [
17765            (
17766                "a typed prompt",
17767                Command::Prompt {
17768                    key: key.clone(),
17769                    text: "start the turn".to_owned(),
17770                    attachments: Vec::new(),
17771                },
17772            ),
17773            (
17774                "a handoff's note",
17775                Command::PromptUnder {
17776                    key: key.clone(),
17777                    text: "continue from the brief".to_owned(),
17778                    attachments: Vec::new(),
17779                    uuid: "note-id".to_owned(),
17780                    source: crate::protocol::PromptSource::Forge,
17781                    dispatch_id: None,
17782                },
17783            ),
17784        ] {
17785            ws.record_seat_state(&key, SeatState::Idle);
17786            ws.domain_session_for(&key)
17787                .expect("the fixture registers the seat")
17788                .lock()
17789                .reset_turns();
17790
17791            ws.dispatch(command).expect("the live task takes the prompt");
17792
17793            assert_eq!(
17794                ws.stored_seat_state(&key),
17795                Some(SeatState::InTurn),
17796                "{label} starts a turn, so the row says one is in flight",
17797            );
17798        }
17799    }
17800
17801    /// The close door is the one close that outlives the process: a bare
17802    /// boot leaves the project shut, while opening it again resumes the
17803    /// stored occupant.
17804    #[test]
17805    fn the_close_door_closes_the_lead_row() {
17806        use crate::store::sessions::SeatState;
17807
17808        let dir = tempdir().expect("tempdir");
17809        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
17810        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
17811        let key = SessionSlot::lead("TestOrg", "forge");
17812        ws.record_session_id("TestOrg", "forge", "lead", "id-1");
17813        ws.record_seat_state(&key, SeatState::InTurn);
17814
17815        ws.dispatch(Command::CloseSession { session_key: key.clone() })
17816            .expect("the close dispatches");
17817
17818        assert_eq!(
17819            ws.stored_seat_state(&key),
17820            Some(SeatState::Closed),
17821            "a closed lead is not reopened by a bare boot",
17822        );
17823
17824        // **The task a close tore down can still land a write.** A Result
17825        // being translated and a buffered swap both write through this door
17826        // after the close; neither may reopen the project the user shut.
17827        for late in [SeatState::Idle, SeatState::InTurn] {
17828            ws.record_seat_state(&key, late);
17829            assert_eq!(
17830                ws.stored_seat_state(&key),
17831                Some(SeatState::Closed),
17832                "a late {late:?} write leaves the close as the last word",
17833            );
17834        }
17835    }
17836
17837    /// **A `/new` a dying task drains after the close does not reopen the
17838    /// row.** The mint is a swap, not an opening door: it records the id
17839    /// the successor runs under and leaves the reader's close standing.
17840    #[test]
17841    fn a_fresh_id_for_a_closed_seat_leaves_it_closed() {
17842        use crate::store::sessions::SeatState;
17843
17844        let dir = tempdir().expect("tempdir");
17845        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
17846        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
17847        let key = SessionSlot::lead("TestOrg", "forge");
17848        ws.record_session_id("TestOrg", "forge", "lead", "id-1");
17849        ws.record_seat_state(&key, SeatState::Closed);
17850
17851        let minted = ws.fresh_session_id_for(&key);
17852
17853        assert_eq!(
17854            ws.stored_seat_state(&key),
17855            Some(SeatState::Closed),
17856            "a swap leaves the close as the last word",
17857        );
17858        assert_eq!(
17859            ws.stored_session_id(key.org(), key.project(), key.label()).expect("read"),
17860            Some(minted),
17861            "and the row still names the occupant the swap minted",
17862        );
17863    }
17864
17865    /// **Opening a closed project is the reader's act, and it reopens the
17866    /// row.** Closing is not forgetting: the named spawn resumes the stored
17867    /// occupant and marks the seat open, or the project could never be
17868    /// opened again.
17869    #[tokio::test]
17870    async fn a_named_spawn_reopens_a_closed_lead() {
17871        use crate::store::sessions::SeatState;
17872
17873        let dir = tempdir().expect("tempdir");
17874        let project = tempdir().expect("project");
17875        let workspace = boot_lead_workspace(dir.path(), project.path());
17876        let key = SessionSlot::lead("Default", "forge");
17877        workspace.record_session_id(
17878            "Default",
17879            "forge",
17880            "lead",
17881            "11111111-2222-3333-4444-555555555555",
17882        );
17883        workspace.record_seat_state(&key, SeatState::Closed);
17884
17885        let mut updates = workspace.subscribe_mirror();
17886        spawn_lead(&workspace, false, None);
17887        let (connected, _note) =
17888            lead_boot_frames(&mut updates, &key, Duration::from_millis(200)).await;
17889        assert!(connected, "the lead connects");
17890
17891        assert_eq!(
17892            workspace.stored_seat_state(&key),
17893            Some(SeatState::Idle),
17894            "a named spawn is an opening door",
17895        );
17896        forge_agent::set_test_sdk_binary(None);
17897    }
17898
17899    /// **A close that refuses the heal's spawn is the stop, not a spawn
17900    /// failure.** It leaves no failure record and no Failed row for the
17901    /// project the reader just shut.
17902    #[tokio::test]
17903    async fn a_close_refusing_a_heal_spawn_records_no_failure() {
17904        use crate::store::sessions::SeatState;
17905
17906        let dir = tempdir().expect("tempdir");
17907        let project = tempdir().expect("project");
17908        let workspace = boot_lead_workspace(dir.path(), project.path());
17909        let key = SessionSlot::lead("Default", "forge");
17910        workspace.record_session_id(
17911            "Default",
17912            "forge",
17913            "lead",
17914            "11111111-2222-3333-4444-555555555555",
17915        );
17916        workspace.record_seat_state(&key, SeatState::Closed);
17917
17918        let outcome = crate::spawn::handle_heal_project_spawn(
17919            &workspace,
17920            "forge",
17921            SessionLaunchSettings::default(),
17922        );
17923
17924        assert!(outcome.is_ok(), "the close ends the heal quietly: {outcome:?}");
17925        assert_eq!(workspace.stored_seat_state(&key), Some(SeatState::Closed), "the close stands");
17926        assert_eq!(workspace.spawn_failure(&key), None, "a close is not a spawn failure");
17927        forge_agent::set_test_sdk_binary(None);
17928    }
17929
17930    /// `/new` mints the successor's id, and a swap leaves the seat parked:
17931    /// the successor starts with no turn in flight even though the seat it
17932    /// replaced may have been mid-turn.
17933    #[test]
17934    fn an_occupant_swap_leaves_the_seat_idle() {
17935        use crate::store::sessions::SeatState;
17936
17937        let dir = tempdir().expect("tempdir");
17938        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
17939        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
17940        let key = SessionSlot::lead("TestOrg", "forge");
17941        ws.record_session_id("TestOrg", "forge", "lead", "old-id");
17942        ws.record_seat_state(&key, SeatState::InTurn);
17943
17944        let fresh = ws.fresh_session_id_for(&key);
17945        assert_ne!(fresh, "old-id", "the swap mints a new occupant");
17946
17947        assert_eq!(
17948            ws.stored_seat_state(&key),
17949            Some(SeatState::Idle),
17950            "the successor starts parked",
17951        );
17952    }
17953
17954    /// A session's `SessionTask` exiting (agent event channel closed -
17955    /// the subprocess died, e.g. after a cron turn) must RELEASE the
17956    /// session from the pool + command_senders. Without this the entry
17957    /// lingers as a dead-but-pooled zombie: the next cron fire's
17958    /// `running_lead` check still finds it "open" and dispatches a
17959    /// `Command::Prompt` to the closed channel, which fails with
17960    /// `SessionClosed` and is silently dropped - so durable crons quietly
17961    /// stop firing for that project.
17962    #[tokio::test]
17963    async fn session_task_exit_releases_dead_pooled_session() {
17964        let (workspace, _update_rx) = Workspace::testing_stub();
17965        let key = SessionSlot::from_str_for_test("lead-uuid");
17966        // Register the session as a connected lead (pool + command_senders
17967        // + domain_handles).
17968        let command_rx = install_fake_session_task(&workspace, &key);
17969        assert!(workspace.pool.lock().contains_key(&key), "precondition: session pooled");
17970
17971        // In production the SessionTask holds the SAME `Arc<AgentHandle>`
17972        // that sits in the pool, so the exit-cleanup identity guard
17973        // (`release_session_if_current`) recognises this task as the
17974        // current owner. Reuse the pooled handle here rather than a
17975        // fresh one; its testing-stub event channel is already closed,
17976        // so `run()` takes the "agent event channel closed" exit path
17977        // immediately - exactly the post-cron-turn subprocess exit.
17978        let domain = workspace.domain_session_for(&key).expect("domain registered");
17979        let pooled_handle = domain.lock().conn.clone().expect("pooled handle on domain");
17980        let update_tx = UpdateFanout::default();
17981        let _task_update_rx = update_tx.subscribe(SubscriberRole::Answering);
17982        let task = crate::session_task::SessionTask {
17983            key: key.clone(),
17984            handle: pooled_handle,
17985            command_rx,
17986            domain,
17987            update_tx,
17988            connected_once: true,
17989            workspace: Arc::downgrade(&workspace),
17990            compaction_count: 0,
17991            successor: None,
17992            teardown_done: None,
17993        };
17994        task.run().await;
17995
17996        assert!(
17997            !workspace.pool.lock().contains_key(&key),
17998            "pool entry must be released when the SessionTask exits",
17999        );
18000        assert!(
18001            !workspace.command_senders.lock().contains_key(&key),
18002            "command sender must be released when the SessionTask exits",
18003        );
18004    }
18005
18006    /// A binding is keyed by the segment the child's base URL was
18007    /// stamped with, which is the id the slot's occupant runs under:
18008    /// the read follows the live binding rather than the account the
18009    /// spawn picked.
18010    #[test]
18011    fn the_bound_account_follows_the_live_binding_for_the_slot() {
18012        let (workspace, _update_rx) = Workspace::testing_stub();
18013        let key = SessionSlot::from_str_for_test("first-uuid");
18014        install_fake_session_task(&workspace, &key);
18015        let segment = key.display();
18016        workspace.pool.lock().get_mut(&key).expect("pooled").registration =
18017            Some(forge_gateway::binding::Registration {
18018                org: "Org".to_owned(),
18019                project: "forge".to_owned(),
18020                session: segment.clone(),
18021                account: AccountKey("A".to_owned()),
18022                provider: forge_primitives::account::Provider::Anthropic,
18023            });
18024        workspace.gateway.bindings.bind("Org", "forge", &segment, AccountKey("A".to_owned()));
18025
18026        assert_eq!(
18027            workspace.bound_account_for(&key),
18028            Some(AccountKey("A".to_owned())),
18029            "the stamped segment is what the binding is keyed by",
18030        );
18031
18032        // The gateway re-selecting on the next request is the whole
18033        // point: the registration's own account is the spawn-time pick
18034        // and stays "A" here.
18035        workspace.gateway.bindings.bind("Org", "forge", &segment, AccountKey("B".to_owned()));
18036        assert_eq!(
18037            workspace.bound_account_for(&key),
18038            Some(AccountKey("B".to_owned())),
18039            "the read follows the live binding, not the spawn-time pick",
18040        );
18041    }
18042
18043    /// Stub carrying one project, `companies`, whose lead session is
18044    /// already up: catalogued, pooled, and - because
18045    /// `install_fake_session_task` stamps it - carrying the
18046    /// `session_id` the retire gate reads. The returned `TempDir`
18047    /// guards the config dir for the caller's lifetime.
18048    fn stub_with_connected_lead()
18049    -> (Arc<Workspace>, mpsc::UnboundedReceiver<SeatUpdate>, SessionSlot, tempfile::TempDir) {
18050        let dir = tempdir().expect("tempdir");
18051        let root = dir.path().join("companies");
18052        fs::create_dir_all(&root).expect("project root");
18053        let forge_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge dir");
18054        fs::write(
18055            forge_dir.join("forge.toml"),
18056            format!(
18057                r#"
18058[[orgs]]
18059name = "Personal"
18060accounts = ["Stargate"]
18061[[orgs.projects]]
18062name = "companies"
18063path = "{root}"
18064[[accounts]]
18065display_name = "Stargate"
18066token = "t"
18067models = ["claude-sonnet-5"]
18068provider = "anthropic"
18069"#,
18070                root = root.display()
18071            ),
18072        )
18073        .expect("write forge.toml");
18074        let config = crate::config::load_from_dir(dir.path()).expect("load config");
18075        let (ws, update_rx) = Workspace::testing_stub_with_config(dir.path().to_owned(), config)
18076            .expect("the stub config's [[slack]] entries are well-formed");
18077
18078        let lead = SessionSlot::lead("Personal", "companies");
18079        ws.install_db_for_test(
18080            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
18081        );
18082        // The store is what a project-rooted spawn resumes from, so the
18083        // fixture records the lead's id there the way a boot would.
18084        ws.record_session_id(
18085            "Personal",
18086            "companies",
18087            forge_primitives::LEAD_LABEL,
18088            &lead.display(),
18089        );
18090        ws.record_connected_session(&root.to_string_lossy(), &lead.display(), None);
18091        let _cmd_rx = install_fake_session_task(&ws, &lead);
18092        assert_eq!(
18093            ws.resolve_slot(&SessionTarget::Named("companies".to_owned())).expect("resolves"),
18094            lead,
18095            "precondition: the project resolves to the lead this fixture pooled",
18096        );
18097        (ws, update_rx, lead, dir)
18098    }
18099
18100    /// Every `Spawning` key an update stream carried, in order.
18101    fn spawning_keys(update_rx: &mut mpsc::UnboundedReceiver<SeatUpdate>) -> Vec<SessionSlot> {
18102        let mut keys: Vec<SessionSlot> = Vec::new();
18103        while let Ok(frame) = update_rx.try_recv() {
18104            if let SessionUpdate::Spawning { key, .. } =
18105                std::sync::Arc::unwrap_or_clone(frame.update)
18106            {
18107                keys.push(key);
18108            }
18109        }
18110        keys
18111    }
18112
18113    /// Any second wake of a live project reaches the fast path: a cron,
18114    /// a peer prompt, a gotify message, a slack delivery or the boot
18115    /// wave landing after one of them. It announces the same
18116    /// key the live session already holds, so a view has one bucket
18117    /// rather than a second one to retire.
18118    #[test]
18119    fn respawn_of_connected_project_announces_the_live_key() {
18120        let (ws, mut update_rx, lead, _dir) = stub_with_connected_lead();
18121
18122        let _ =
18123            crate::spawn::handle_spawn_project(&ws, "companies", SessionLaunchSettings::default());
18124
18125        let announced = spawning_keys(&mut update_rx);
18126        assert_eq!(
18127            announced,
18128            vec![lead],
18129            "a re-spawn announces the key the live session already holds, not a second one",
18130        );
18131    }
18132
18133    /// A background rate-limit clears `DomainSession.session_id` while
18134    /// the pool entry lives. The emit must not read that mirror: the key
18135    /// it announces is the session's own, whatever the mirror says.
18136    #[test]
18137    fn a_rate_limited_session_still_announces_its_own_key() {
18138        let (ws, mut update_rx, lead, _dir) = stub_with_connected_lead();
18139        // What `apply_session_update_connection_failed` does to a
18140        // rate-limited background session: clear the mirror, leave the
18141        // pool entry alone.
18142        ws.set_session_id_in_domain(&lead, None);
18143
18144        let _ =
18145            crate::spawn::handle_spawn_project(&ws, "companies", SessionLaunchSettings::default());
18146
18147        assert_eq!(
18148            spawning_keys(&mut update_rx),
18149            vec![lead],
18150            "a cleared session_id must not change the key announced",
18151        );
18152    }
18153
18154    /// `classify_oauth_usage_error` must distinguish HTTP 429 from
18155    /// auth-related failures, keeping the `rate-limited` class (the
18156    /// common case under multiple forge instances) rather than
18157    /// collapsing every failure to a single generic bucket.
18158    #[test]
18159    fn classify_oauth_usage_error_buckets_known_variants() {
18160        use forge_gateway::ProbeError;
18161        use forge_gateway::UsageFetchStatus;
18162        use forge_primitives::usage::oauth::OauthUsageError;
18163
18164        let fetch = |err| ProbeError::Fetch(err);
18165        assert_eq!(
18166            classify_oauth_usage_error(&fetch(OauthUsageError::HttpStatus(429, String::new()))),
18167            UsageFetchStatus::RateLimited,
18168        );
18169        assert_eq!(
18170            classify_oauth_usage_error(&fetch(OauthUsageError::RateLimited {
18171                retry_after: Some(std::time::Duration::from_secs(60)),
18172                snapshot: None,
18173            })),
18174            UsageFetchStatus::RateLimited,
18175            "new dedicated 429 variant also maps to RateLimited",
18176        );
18177        assert_eq!(
18178            classify_oauth_usage_error(&fetch(OauthUsageError::RateLimited {
18179                retry_after: None,
18180                snapshot: None,
18181            })),
18182            UsageFetchStatus::RateLimited,
18183        );
18184        assert_eq!(
18185            classify_oauth_usage_error(&fetch(OauthUsageError::Unauthorized(401))),
18186            UsageFetchStatus::Unauthorized,
18187        );
18188        assert_eq!(
18189            classify_oauth_usage_error(&fetch(OauthUsageError::Expired)),
18190            UsageFetchStatus::Expired,
18191        );
18192        assert_eq!(
18193            classify_oauth_usage_error(&fetch(OauthUsageError::NoCredentials)),
18194            UsageFetchStatus::Expired,
18195        );
18196        assert_eq!(
18197            classify_oauth_usage_error(&fetch(OauthUsageError::Network("dns".to_owned()))),
18198            UsageFetchStatus::NetworkFailed,
18199        );
18200        // Non-429 HTTP errors and decode failures fall through to the
18201        // generic `Other` bucket, so an unmapped failure keeps its own
18202        // class instead of being read as a named cause.
18203        assert_eq!(
18204            classify_oauth_usage_error(&fetch(OauthUsageError::HttpStatus(500, String::new()))),
18205            UsageFetchStatus::Other,
18206        );
18207        assert_eq!(
18208            classify_oauth_usage_error(&fetch(OauthUsageError::Decode("bad json".to_owned()))),
18209            UsageFetchStatus::Other,
18210        );
18211        // A failed `claude --version` shell-out is a local exec problem,
18212        // not a reachability verdict, so it must not land in
18213        // NetworkFailed.
18214        assert_eq!(
18215            classify_oauth_usage_error(&fetch(OauthUsageError::UaProbe("no binary".to_owned()))),
18216            UsageFetchStatus::Other,
18217        );
18218        // A scope refusal is anomalous (OAuth tokens carry user:profile)
18219        // and must not render as an auth failure.
18220        assert_eq!(
18221            classify_oauth_usage_error(&fetch(OauthUsageError::ScopeInsufficient)),
18222            UsageFetchStatus::Other,
18223        );
18224        // The backend's own credential miss rides the same Expired
18225        // bucket as the wire class, and the unmappable 200 - which the
18226        // callers handle before classifying - keeps the match total.
18227        assert_eq!(
18228            classify_oauth_usage_error(&ProbeError::NoCredentials),
18229            UsageFetchStatus::Expired,
18230        );
18231        assert_eq!(
18232            classify_oauth_usage_error(&ProbeError::Unmappable("no window".to_owned())),
18233            UsageFetchStatus::Other,
18234        );
18235    }
18236
18237    /// An Anthropic account (setup token on its account block) derives
18238    /// the token auth class, whose bailed-row repair copy names the
18239    /// token and never a re-authentication of the shared config dir.
18240    #[tokio::test]
18241    async fn a_token_mode_account_derives_the_token_auth_class() {
18242        let dir = tempdir().expect("tempdir");
18243        fs::write(
18244            forge_toml_path(dir.path()),
18245            r#"
18246[[orgs]]
18247name = "Default"
18248accounts = ["TokenAcct"]
18249
18250[[orgs.projects]]
18251name = "forge"
18252path = "~/Projects/forge"
18253
18254[[accounts]]
18255display_name = "TokenAcct"
18256token = "setup-token"
18257models = ["claude-sonnet-5"]
18258provider = "anthropic"
18259"#,
18260        )
18261        .expect("write forge.toml");
18262        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
18263
18264        let rows = workspace.account_loading_snapshot();
18265        assert_eq!(rows.len(), 1, "the fixture has one account; got {rows:?}");
18266        assert_eq!(
18267            rows[0].auth,
18268            crate::views::AccountAuth::Token,
18269            "a setup-token account is the token auth class; got {:?}",
18270            rows[0].auth,
18271        );
18272    }
18273
18274    // ─────────────────────────────────────────────────────────────────
18275    // I3 - peer-MCP lifecycle tests
18276    // ─────────────────────────────────────────────────────────────────
18277
18278    /// Resolve a project's expanded path from the workspace's view.
18279    /// Catalog keys derive from `project_key_for_directory(expanded_path)`,
18280    /// not the literal `~/`-prefixed forge.toml string, so callers that
18281    /// want `record_connected_session` to populate the right project's
18282    /// session list need this lookup.
18283    fn project_expanded_path(workspace: &Workspace, name: &str) -> String {
18284        workspace.list_projects().into_iter().find(|p| p.name == name).map_or_else(
18285            || panic!("project '{name}' missing from workspace"),
18286            |p| p.path.to_string_lossy().into_owned(),
18287        )
18288    }
18289
18290    /// A cron entry carrying what a delivery routes and reads: the id, the
18291    /// project, the prompt and a description.
18292    fn test_cron(id: &str, project: &str, prompt: &str) -> forge_primitives::cron::CronEntry {
18293        use forge_primitives::cron::{CronEntry, CronId, CronKind};
18294        CronEntry {
18295            id: CronId::from(id),
18296            project_name: project.to_owned(),
18297            kind: CronKind::Recurring("0 9 * * *".to_owned()),
18298            prompt: prompt.to_owned(),
18299            created_at: std::time::SystemTime::UNIX_EPOCH,
18300            description: Some(format!("{id} summary")),
18301            last_fire: None,
18302            next_fire: std::time::SystemTime::UNIX_EPOCH,
18303            team_role: None,
18304            wake: true,
18305        }
18306    }
18307
18308    /// A message parked for a seat that never came up is acknowledged to
18309    /// its sender: the notice lands on the sender's slot and names the
18310    /// seat that did not take it.
18311    ///
18312    /// This is the delivery-ack path, and the parked entry's sender is the
18313    /// only thing that knows where the notice goes - the ask registry that
18314    /// used to carry it is gone.
18315    #[tokio::test]
18316    async fn notice_undelivered_message_names_the_target_and_reaches_the_sender() {
18317        use crate::mcp::peers::types::PeerFailureReason;
18318
18319        let (ws, mut rx) = Workspace::testing_stub();
18320        ws.enable_test_dispatch_intercept();
18321
18322        let sender = SessionSlot::from_str_for_test("sender-proj");
18323        let target = SessionSlot::lead("Default", "gateway-backend");
18324        ws.notice_undelivered_message(&sender, &target, PeerFailureReason::TargetConnectionFailed);
18325
18326        let mut echo = None;
18327        while let Ok(frame) = rx.try_recv() {
18328            if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
18329                std::sync::Arc::unwrap_or_clone(frame.update)
18330            {
18331                echo = Some((key, wrapped));
18332            }
18333        }
18334        let (key, notice) = echo.expect("the notice is painted for the sender");
18335        assert_eq!(key, sender, "the notice lands on the sender, not the target");
18336        assert_eq!(
18337            notice.sender_name, "gateway-backend",
18338            "and names the seat that did not take it"
18339        );
18340        assert_eq!(notice.sender_org, "Default", "with that seat's org");
18341        assert!(
18342            notice.body.contains("connection lost"),
18343            "the reason reaches the reader: {}",
18344            notice.body
18345        );
18346
18347        let dispatched = ws.drain_test_dispatch_buffer();
18348        assert_eq!(dispatched.len(), 1, "the notice is also dispatched as a turn");
18349        match &dispatched[0] {
18350            Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } => {
18351                assert_eq!(*key, sender, "the dispatched turn goes to the sender");
18352                assert!(text.contains("failed to deliver"), "carrying the failure prose: {text}");
18353            }
18354            other => panic!("expected a prompt command, got {other:?}"),
18355        }
18356    }
18357
18358    /// **Two seat captures never overlap.** The generation ranks whole
18359    /// captures, so a capture held between its mint and its reads must hold
18360    /// the next one off: a capture that ran there would carry the higher
18361    /// number over the older seat set, and the fleet would drop the capture
18362    /// that actually read the newer one.
18363    #[test]
18364    fn a_live_seats_capture_cannot_overlap_another() {
18365        let (workspace, _rx) = Workspace::testing_stub();
18366        let project = ProjectKey::new("forge");
18367        let dir = tempdir().expect("a temp dir");
18368        let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("the store");
18369        workspace.attach_store_for_test(db);
18370        workspace.seed_test_project("forge", "/tmp/forge-capture-overlap");
18371        workspace.insert_live_worker(
18372            &project,
18373            crate::mcp::workers::types::WorkerEntry {
18374                label: "w".to_owned(),
18375                charter: "c".to_owned(),
18376                slot: SessionSlot::worker("Default", "forge", "w"),
18377                session_id: None,
18378                status: forge_primitives::WorkerLiveness::Running,
18379                spawned_at: std::time::SystemTime::UNIX_EPOCH,
18380                spawned_by: SessionSlot::lead("Default", "forge"),
18381                needs_tag: false,
18382                is_git_repo_at_spawn: false,
18383                cwd: None,
18384                diagnostic: None,
18385                kick: None,
18386            },
18387        );
18388        let (reached, resume) = workspace.pause_next_live_seats_capture();
18389
18390        let held = {
18391            let workspace = Arc::clone(&workspace);
18392            std::thread::spawn(move || workspace.live_seats())
18393        };
18394        reached
18395            .recv_timeout(std::time::Duration::from_secs(10))
18396            .expect("the first capture is held between its mint and its reads");
18397
18398        let (done_tx, done_rx) = std::sync::mpsc::channel();
18399        let second = {
18400            let workspace = Arc::clone(&workspace);
18401            std::thread::spawn(move || {
18402                let snapshot = workspace.live_seats();
18403                let _ = done_tx.send(());
18404                snapshot
18405            })
18406        };
18407        // A capture that could run between the first one's mint and its reads
18408        // finishes here; one that cannot reports nothing.
18409        let overtook = done_rx.recv_timeout(std::time::Duration::from_millis(500)).is_ok();
18410
18411        // The seat goes while the first capture is open.
18412        let drained = workspace.drain_live_workers(&project);
18413        assert_eq!(drained.len(), 1, "the worker was live before the drain");
18414        let _ = resume.send(());
18415
18416        let held = held.join().expect("the held capture returns").expect("the store is readable");
18417        let second =
18418            second.join().expect("the second capture returns").expect("the store is readable");
18419
18420        assert!(
18421            !overtook,
18422            "a second capture must not run while the first is between its mint and its reads",
18423        );
18424        assert!(held.generation < second.generation, "the held capture keeps the earlier number");
18425        assert!(
18426            !held.seats.iter().any(|seat| seat.label() == "w"),
18427            "the held capture read after the drain",
18428        );
18429        assert!(
18430            second.seats.iter().all(|seat| held.seats.contains(seat)),
18431            "the newer number must not carry an older seat set: second={:?} held={:?}",
18432            second.seats,
18433            held.seats,
18434        );
18435    }
18436}
18437
18438#[cfg(test)]
18439#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
18440mod workers_state_tests {
18441    use super::*;
18442    use crate::mcp::workers::types::WorkerEntry;
18443    use forge_primitives::WorkerLiveness;
18444    use std::time::SystemTime;
18445
18446    fn fake_entry(label: &str, key: &str) -> WorkerEntry {
18447        WorkerEntry {
18448            label: label.into(),
18449            charter: "test charter".into(),
18450            slot: SessionSlot::from_str_for_test(key),
18451            session_id: None,
18452            status: WorkerLiveness::Running,
18453            spawned_at: SystemTime::UNIX_EPOCH,
18454            spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
18455            needs_tag: false,
18456            is_git_repo_at_spawn: false,
18457            cwd: None,
18458            diagnostic: None,
18459            kick: None,
18460        }
18461    }
18462
18463    #[test]
18464    fn live_workers_starts_empty() {
18465        let (ws, _rx) = Workspace::testing_stub();
18466        let project = ProjectKey::new("forge");
18467        assert!(ws.list_live_workers(&project).is_empty());
18468    }
18469
18470    /// A store that is not open is not "no rows": the collision guard
18471    /// refuses on the unknown rather than admitting another project's
18472    /// path.
18473    #[test]
18474    fn a_collision_refuses_when_a_projects_rows_cannot_be_read() {
18475        let (ws, _rx) = Workspace::testing_stub();
18476        ws.seed_test_project("proj-a", "/tmp/proj-a-unreadable");
18477        ws.seed_test_project("proj-b", "/tmp/proj-b-unreadable");
18478        let own = ws.project_key_for_name("proj-a").expect("proj-a");
18479
18480        let collision = ws.worktree_collision(
18481            std::path::Path::new("/tmp/proj-b-unreadable/.claude/worktrees/shared"),
18482            &own,
18483            "shared",
18484            WorktreeOp::Spawn,
18485        );
18486
18487        let reason = collision.expect("an unreadable store refuses the path");
18488        assert!(
18489            reason.contains("could not read the worker rows"),
18490            "the refusal names the unknown it is refusing on: {reason}",
18491        );
18492    }
18493
18494    /// The claim on a composed path is repository-wide and atomic: one
18495    /// owner at a time, re-entrant only for the same project and label.
18496    #[test]
18497    fn a_worktree_claim_admits_one_owner_across_projects() {
18498        let (ws, _rx) = Workspace::testing_stub();
18499        let path = std::path::Path::new("/tmp/claimed-worktree");
18500        let proj_a = ProjectKey::new("proj-a");
18501        let proj_b = ProjectKey::new("proj-b");
18502
18503        let first =
18504            ws.claim_worktree(path, &proj_a, "shared", WorktreeOp::Spawn).expect("the first claim");
18505        let reentrant = ws
18506            .claim_worktree(path, &proj_a, "shared", WorktreeOp::Spawn)
18507            .expect("the same owner may claim twice: a resume's ensure and the core's insert");
18508        let refused = ws
18509            .claim_worktree(path, &proj_b, "shared", WorktreeOp::Spawn)
18510            .expect_err("another project cannot claim the same path");
18511        assert!(refused.contains("worker 'shared'"), "the refusal names the holder: {refused}");
18512        assert!(refused.contains("proj-a"), "and its project: {refused}");
18513        let despawn = ws
18514            .claim_worktree(path, &proj_a, "shared", WorktreeOp::Despawn)
18515            .expect_err("the same owner and label under another operation cannot enter");
18516        assert!(
18517            despawn.contains("is in use by the worker 'shared'"),
18518            "the refusal says the spawn holds it: {despawn}",
18519        );
18520
18521        drop(reentrant);
18522        ws.claim_worktree(path, &proj_b, "shared", WorktreeOp::Spawn)
18523            .expect_err("the first claim is still held");
18524        drop(first);
18525        ws.claim_worktree(path, &proj_b, "shared", WorktreeOp::Spawn)
18526            .expect("free once every claim is released");
18527    }
18528
18529    #[test]
18530    fn insert_then_list_returns_entry() {
18531        let (ws, _rx) = Workspace::testing_stub();
18532        let project = ProjectKey::new("forge");
18533        ws.insert_live_worker(&project, fake_entry("reviewer", "abc"));
18534        let entries = ws.list_live_workers(&project);
18535        assert_eq!(entries.len(), 1);
18536        assert_eq!(entries[0].label, "reviewer");
18537    }
18538
18539    #[test]
18540    fn remove_latest_by_label_picks_most_recent_duplicate() {
18541        let (ws, _rx) = Workspace::testing_stub();
18542        let project = ProjectKey::new("forge");
18543        ws.insert_live_worker(&project, fake_entry("dup", "old"));
18544        ws.insert_live_worker(&project, fake_entry("dup", "new"));
18545        let removed = ws.remove_latest_worker(&project, "dup");
18546        assert_eq!(removed.unwrap().slot.label(), "new");
18547        assert_eq!(ws.list_live_workers(&project).len(), 1);
18548    }
18549
18550    #[test]
18551    fn remove_returns_none_when_missing() {
18552        let (ws, _rx) = Workspace::testing_stub();
18553        let project = ProjectKey::new("forge");
18554        assert!(ws.remove_latest_worker(&project, "missing").is_none());
18555    }
18556
18557    /// The atomic guard rejects a second insert for a live label (holding
18558    /// the lock across the check + push closes the concurrent-dispatch
18559    /// TOCTOU) and hands back the existing worker.
18560    #[test]
18561    fn insert_if_label_absent_rejects_duplicate_label() {
18562        let (ws, _rx) = Workspace::testing_stub();
18563        let project = ProjectKey::new("forge");
18564        assert!(
18565            ws.insert_live_worker_if_label_absent(&project, fake_entry("reviewer", "first"), None)
18566                .is_ok(),
18567            "the first insert for a label wins",
18568        );
18569        let existing = ws
18570            .insert_live_worker_if_label_absent(&project, fake_entry("reviewer", "second"), None)
18571            .expect_err("a second live worker for the same label is rejected");
18572        let existing = match existing {
18573            LiveWorkerRefusal::LabelLive(session_key) => session_key,
18574            LiveWorkerRefusal::CleanupPending => panic!("no despawn cleanup is in flight"),
18575            LiveWorkerRefusal::AtCap { .. } => panic!("no cap was supplied"),
18576        };
18577        assert_eq!(existing.label(), "first", "the live holder is returned");
18578        assert_eq!(ws.list_live_workers(&project).len(), 1, "no duplicate is inserted");
18579    }
18580
18581    /// A `Failed` entry does not hold its label - the atomic guard lets
18582    /// its label be re-spawned (parity with `live_worker_with_label`).
18583    #[test]
18584    fn insert_if_label_absent_allows_reinsert_over_failed() {
18585        let (ws, _rx) = Workspace::testing_stub();
18586        let project = ProjectKey::new("forge");
18587        let mut failed = fake_entry("reviewer", "dead");
18588        failed.status = WorkerLiveness::Failed;
18589        ws.insert_live_worker(&project, failed);
18590        assert!(
18591            ws.insert_live_worker_if_label_absent(&project, fake_entry("reviewer", "fresh"), None)
18592                .is_ok(),
18593            "a Failed entry does not block a re-spawn of its label",
18594        );
18595        assert_eq!(ws.list_live_workers(&project).len(), 2);
18596    }
18597
18598    /// **A spawn supersedes a heal's tombstone rather than sitting beside
18599    /// it.** A same-label spawn is admitted because a `Restarting` entry is
18600    /// not live while the cap still counts it; leaving the tombstone would
18601    /// count the slot twice, and the heal's timer aborts on the live label,
18602    /// so it would hold a cap slot for the rest of the run.
18603    #[test]
18604    fn insert_if_label_absent_supersedes_a_restarting_tombstone() {
18605        let (ws, _rx) = Workspace::testing_stub();
18606        let project = ProjectKey::new("forge");
18607        let mut tombstone = fake_entry("reviewer", "w1");
18608        tombstone.status = WorkerLiveness::Restarting;
18609        ws.insert_live_worker(&project, tombstone);
18610
18611        ws.insert_live_worker_if_label_absent(&project, fake_entry("reviewer", "w1"), None)
18612            .expect("a Restarting tombstone does not hold the label");
18613
18614        let entries = ws.list_live_workers(&project);
18615        assert_eq!(entries.len(), 1, "the tombstone went with the spawn that superseded it");
18616        assert_eq!(
18617            entries[0].status,
18618            WorkerLiveness::Running,
18619            "the spawn's own entry is what remains",
18620        );
18621        assert_eq!(ws.count_live_workers(&project), 1, "and the cap counts the slot once");
18622    }
18623
18624    #[test]
18625    fn drain_for_project_clears_and_returns_all() {
18626        let (ws, _rx) = Workspace::testing_stub();
18627        let project = ProjectKey::new("forge");
18628        ws.insert_live_worker(&project, fake_entry("a", "k1"));
18629        ws.insert_live_worker(&project, fake_entry("b", "k2"));
18630        let drained = ws.drain_live_workers(&project);
18631        assert_eq!(drained.len(), 2);
18632        assert!(ws.list_live_workers(&project).is_empty());
18633    }
18634}
18635
18636#[cfg(test)]
18637mod worker_activity_tests {
18638    use super::*;
18639    use crate::mcp::workers::types::WorkerEntry;
18640    use crate::protocol::PendingInteractionSlot;
18641    use forge_primitives::{SessionLifecycleState as L, WorkerLiveness};
18642    use std::time::SystemTime;
18643
18644    fn entry(key: &str, status: WorkerLiveness) -> WorkerEntry {
18645        WorkerEntry {
18646            label: "implementer".into(),
18647            charter: "test charter".into(),
18648            slot: SessionSlot::from_str_for_test(key),
18649            session_id: None,
18650            status,
18651            spawned_at: SystemTime::UNIX_EPOCH,
18652            spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
18653            needs_tag: false,
18654            is_git_repo_at_spawn: false,
18655            cwd: None,
18656            diagnostic: None,
18657            kick: None,
18658        }
18659    }
18660
18661    /// The same derivation has to be reachable by slot: a project lead has
18662    /// no `WorkerEntry` to hand `worker_activity`, and a view drawing the
18663    /// lead's row needs its state from somewhere.
18664    #[test]
18665    fn session_activity_reads_a_slot_with_no_worker_entry() {
18666        let (ws, _rx) = Workspace::testing_stub();
18667
18668        assert_eq!(
18669            ws.session_activity(&SessionSlot::from_str_for_test("s-gone")),
18670            L::Sleeping,
18671            "a slot with no domain session is asleep, not idle",
18672        );
18673
18674        let blocked = SessionSlot::from_str_for_test("s-blocked");
18675        let domain = ws.register_domain_session(blocked.clone(), None);
18676        {
18677            let mut guard = domain.lock();
18678            guard.turn_pending = true;
18679            let (tx, _rx) = tokio::sync::oneshot::channel();
18680            guard.pending_interactions.insert("tool-1".to_owned(), testing::test_permission(tx));
18681        }
18682        assert_eq!(
18683            ws.session_activity(&blocked),
18684            L::Attention,
18685            "a turn in flight holding a pending interaction needs a person",
18686        );
18687    }
18688
18689    /// Two states the lifecycle derivation has to reach or a view cannot
18690    /// draw them: a session held on `/login`, and one whose last attempt
18691    /// to start failed and has not been retried. Both are facts about the
18692    /// session, so the core answers them rather than each view folding
18693    /// them out of the events it happened to see.
18694    #[test]
18695    fn session_activity_reaches_login_and_failure() {
18696        let (ws, _rx) = Workspace::testing_stub();
18697        let held = SessionSlot::from_str_for_test("s-login");
18698        let domain = ws.register_domain_session(held.clone(), None);
18699        assert_eq!(ws.session_activity(&held), L::Idle, "a fresh domain is idle");
18700
18701        {
18702            let mut guard = domain.lock();
18703            guard.awaiting_login = true;
18704            // A login wait is not a turn, so a state that claims one is in
18705            // flight must not outrank it.
18706            guard.turn_pending = true;
18707        }
18708        assert_eq!(
18709            ws.session_activity(&held),
18710            L::AuthRequired,
18711            "a session waiting on /login cannot proceed, whatever its turn state says",
18712        );
18713
18714        // The signal that actually arrives: a turn that died on a missing
18715        // credential. Without it the state is unreachable and the row draws
18716        // a calm idle for a session held on `/login`.
18717        let by_error = SessionSlot::from_str_for_test("s-login-error");
18718        let domain = ws.register_domain_session(by_error.clone(), None);
18719        let auth_message = "authentication failed: please log in";
18720        crate::session_task::apply_event_to_domain(
18721            &mut domain.lock(),
18722            &forge_agent::client::AgentEvent::TurnError {
18723                session_id: "uuid".to_owned(),
18724                message: auth_message.to_owned(),
18725                class: forge_agent::translate::error_handling::classify_turn_error(auth_message),
18726            },
18727        );
18728        assert_eq!(
18729            ws.session_activity(&by_error),
18730            L::AuthRequired,
18731            "a turn error the classifier calls auth-required holds the session on /login",
18732        );
18733
18734        // A failed attempt is recorded where the failure releases the
18735        // session, so it survives the release.
18736        let dead = SessionSlot::from_str_for_test("s-dead");
18737        ws.record_spawn_failure(&dead, "OAuth token expired");
18738        assert_eq!(ws.session_activity(&dead), L::Failed, "an unrecovered failure is failed");
18739        assert_eq!(
18740            ws.spawn_failure(&dead).as_deref(),
18741            Some("OAuth token expired"),
18742            "and carries why",
18743        );
18744
18745        // Registering a domain is not what clears it. Production hoists a
18746        // domain itself rather than registering one, so the clear lives in
18747        // the `Connected` arm - see `a_connection_failure_is_recorded_and_
18748        // a_connected_clears_it`, which drives a real task through both.
18749        ws.register_domain_session(dead.clone(), None);
18750        assert_eq!(
18751            ws.spawn_failure(&dead).as_deref(),
18752            Some("OAuth token expired"),
18753            "registering a domain does not clear the record, because production never registers one",
18754        );
18755        assert_eq!(
18756            ws.session_activity(&dead),
18757            L::Failed,
18758            "so the slot still reads failed until something connects",
18759        );
18760    }
18761
18762    /// What a session is waiting on a person for, which is what a
18763    /// needs-you row has to name. A question outranks a permission prompt,
18764    /// so a slot holding both reads as the question.
18765    #[test]
18766    fn pending_interaction_answers_the_kind_a_slot_is_waiting_on() {
18767        let (ws, _rx) = Workspace::testing_stub();
18768        let held = |name: &str, slots: Vec<PendingInteractionSlot>| {
18769            let key = SessionSlot::from_str_for_test(name);
18770            let domain = ws.register_domain_session(key.clone(), None);
18771            {
18772                let mut guard = domain.lock();
18773                for (index, slot) in slots.into_iter().enumerate() {
18774                    guard.pending_interactions.insert(format!("{name}-{index}"), slot);
18775                }
18776            }
18777            key
18778        };
18779        let permission = || {
18780            let (tx, _rx) = tokio::sync::oneshot::channel();
18781            testing::test_permission(tx)
18782        };
18783        let question = || {
18784            let (tx, _rx) = tokio::sync::oneshot::channel();
18785            testing::test_question(tx)
18786        };
18787
18788        assert_eq!(
18789            ws.pending_interaction(&SessionSlot::from_str_for_test("p-none")),
18790            None,
18791            "a slot with no domain session is waiting on nothing",
18792        );
18793
18794        let asked = held("p-question", vec![question()]);
18795        assert_eq!(
18796            ws.pending_interaction(&asked),
18797            Some(PendingInteractionKind::Question),
18798            "a held question is what the slot is waiting on",
18799        );
18800
18801        let prompted = held("p-permission", vec![permission()]);
18802        assert_eq!(
18803            ws.pending_interaction(&prompted),
18804            Some(PendingInteractionKind::Permission),
18805            "a permission prompt alone is what the slot is waiting on",
18806        );
18807
18808        let both = held("p-both", vec![permission(), question()]);
18809        assert_eq!(
18810            ws.pending_interaction(&both),
18811            Some(PendingInteractionKind::Question),
18812            "a question outranks the permission prompt beside it",
18813        );
18814    }
18815
18816    /// One list, expanded twice: the exhaustive match naming an ask's
18817    /// needs-you kind, and the fixture list the guard test parks.
18818    ///
18819    /// **A new `PendingAsk` variant cannot be named without being parked.**
18820    /// The generated match is exhaustive, so the variant must be added to
18821    /// this one list; the list is also what the test iterates, so its
18822    /// fixture is constructed, read back and asserted. A variant mapped onto
18823    /// a kind another variant already names leaves a duplicate in
18824    /// `ASK_KINDS`, which the test refuses - so every ask kind is exercised
18825    /// on its own.
18826    macro_rules! ask_census {
18827        ($($kind:ident => $variant:ident),* $(,)?) => {
18828            ask_census!(@match; [] $($kind $variant,)*);
18829
18830            /// Every ask kind the list names, one entry per `PendingAsk`
18831            /// variant.
18832            const ASK_KINDS: &[PendingInteractionKind] = &[ $( PendingInteractionKind::$kind, )* ];
18833        };
18834        (@match; [$($arms:tt)*]) => {
18835            /// The kind a needs-you row draws for an ask - exhaustive, and
18836            /// generated from the same list as `ASK_KINDS`.
18837            fn needs_you_kind(ask: &crate::protocol::PendingAsk) -> PendingInteractionKind {
18838                match ask {
18839                    $($arms)*
18840                }
18841            }
18842        };
18843        (@match; [$($arms:tt)*] $kind:ident $variant:ident, $($rest:tt)*) => {
18844            ask_census!(@match;
18845                [$($arms)* crate::protocol::PendingAsk::$variant(_) => PendingInteractionKind::$kind,]
18846                $($rest)*);
18847        };
18848    }
18849
18850    ask_census! {
18851        Question => Question,
18852        Permission => Permission,
18853        SlackDraft => SlackDraft,
18854        BrowserHandOff => BrowserHandOff,
18855        TerminalHandOff => TerminalHandOff,
18856        DocumentHandOff => DocumentHandOff,
18857        SettingsWrite => SettingsWrite,
18858        Vault => Vault,
18859    }
18860
18861    /// Park one ask of `kind` on a seat of its own and answer the seat.
18862    ///
18863    /// A prompt is inserted into the session's pending set the way
18864    /// `session_task` parks one, taking the ask's number; every registry kind
18865    /// goes through its own registration. Both are the app's path.
18866    ///
18867    /// The terminal takes the seeded project's lead slot because its PTY
18868    /// resolves a cwd; every other kind gets a fresh seat, so nothing parked
18869    /// here competes with another kind for the same row.
18870    async fn park_one(
18871        ws: &std::sync::Arc<Workspace>,
18872        name: &str,
18873        kind: PendingInteractionKind,
18874    ) -> SessionSlot {
18875        let seat = if kind == PendingInteractionKind::TerminalHandOff {
18876            SessionSlot::lead("TestOrg", "guard")
18877        } else {
18878            SessionSlot::from_str_for_test(name)
18879        };
18880        let domain = ws.register_domain_session(seat.clone(), None);
18881        domain.lock().turn_pending = true;
18882        match kind {
18883            PendingInteractionKind::Question => {
18884                let (tx, _rx) = tokio::sync::oneshot::channel();
18885                ws.domain_session_for(&seat)
18886                    .expect("the seat's own domain")
18887                    .lock()
18888                    .pending_interactions
18889                    .insert(format!("{name}-0"), testing::test_question(tx));
18890                // The arrival is what numbers a prompt; the direct insert
18891                // stands in for it.
18892                ws.note_ask(&seat);
18893            }
18894            PendingInteractionKind::Permission => {
18895                let (tx, _rx) = tokio::sync::oneshot::channel();
18896                ws.domain_session_for(&seat)
18897                    .expect("the seat's own domain")
18898                    .lock()
18899                    .pending_interactions
18900                    .insert(format!("{name}-0"), testing::test_permission(tx));
18901                ws.note_ask(&seat);
18902            }
18903            PendingInteractionKind::SlackDraft => {
18904                let (_id, _decision) = ws.register_slack_draft(
18905                    &seat,
18906                    forge_primitives::slack::SlackDraft {
18907                        id: uuid::Uuid::new_v4(),
18908                        workspace: "acme".to_owned(),
18909                        conversation: "C1".to_owned(),
18910                        conversation_label: "acme".to_owned(),
18911                        thread_ts: None,
18912                        text: "hello".to_owned(),
18913                        operation: forge_primitives::slack::SlackDraftOperation::Post,
18914                        thread: None,
18915                        tagged: Vec::new(),
18916                    },
18917                );
18918            }
18919            PendingInteractionKind::BrowserHandOff => {
18920                let (_id, _answer) = ws
18921                    .register_browser_hand_off(&seat, "solve the CAPTCHA", "T2")
18922                    .await
18923                    .expect("the browser hand-off parks");
18924            }
18925            PendingInteractionKind::TerminalHandOff => {
18926                let entry = ws.terminal_for(&seat).expect("a terminal");
18927                let (_id, _answer) = ws
18928                    .register_terminal_hand_off(&seat, entry.id, "the sudo password")
18929                    .expect("the terminal hand-off parks");
18930            }
18931            PendingInteractionKind::DocumentHandOff => {
18932                ws.note_client_attached();
18933                let (_id, _ending) = ws
18934                    .register_document_hand_off(&seat, "/proj/docs/plan.md", true)
18935                    .expect("the document hand-off parks");
18936            }
18937            PendingInteractionKind::SettingsWrite => {
18938                let (_id, _answer, _) = ws.register_settings_write(
18939                    &seat,
18940                    forge_primitives::settings::SettingsWriteAsk {
18941                        id: uuid::Uuid::new_v4(),
18942                        edits: vec![forge_primitives::settings::SettingsWriteChange {
18943                            key: "sessions.heal_attempts".to_owned(),
18944                            current: serde_json::json!(2),
18945                            proposed: serde_json::json!(4),
18946                            unset: false,
18947                        }],
18948                    },
18949                );
18950            }
18951            PendingInteractionKind::Vault => {
18952                ws.note_client_attached();
18953                let (_id, _answer, _) = ws.register_vault_ask(
18954                    &seat,
18955                    forge_primitives::vault::VaultDraft::new(
18956                        forge_primitives::vault::VaultTool::Search,
18957                        "search for 'bank'",
18958                    ),
18959                    false,
18960                );
18961            }
18962        }
18963        seat
18964    }
18965
18966    /// **Every ask kind is a needs-you kind.** Each kind the census names is
18967    /// parked on a seat of its own and must read the kind back, say
18968    /// `Attention`, count exactly one, and carry its own number - the last
18969    /// what proves a registry registration announced its ask, and for a
18970    /// prompt what proves its arrival was numbered. The terminal is here
18971    /// because it drew in the dock and marked nothing (#2954).
18972    #[tokio::test]
18973    async fn every_ask_kind_marks_its_seat() {
18974        let (ws, _rx) = Workspace::testing_stub();
18975        ws.seed_test_project("guard", "/tmp");
18976        let (calls, _heard) = tokio::sync::mpsc::unbounded_channel();
18977        ws.install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
18978
18979        let mut named: Vec<PendingInteractionKind> = Vec::new();
18980        for (index, kind) in ASK_KINDS.iter().enumerate() {
18981            assert!(
18982                !named.contains(kind),
18983                "{kind:?} is named by two ask variants, so one of them is never parked",
18984            );
18985            named.push(*kind);
18986            let seat = park_one(&ws, &format!("ask-{index}"), *kind).await;
18987            let asks = ws.pending_asks(&seat);
18988            assert_eq!(asks.len(), 1, "one ask parked on {}", seat.display());
18989            assert_eq!(
18990                needs_you_kind(&asks[0]),
18991                *kind,
18992                "the parked ask names the kind {} draws for it",
18993                seat.display(),
18994            );
18995            assert_eq!(
18996                ws.pending_interaction(&seat),
18997                Some(*kind),
18998                "the needs-you kind for {}",
18999                seat.display(),
19000            );
19001            assert_eq!(
19002                ws.session_activity(&seat),
19003                L::Attention,
19004                "the lifecycle for {} is what every needs mark draws",
19005                seat.display(),
19006            );
19007            assert_eq!(
19008                ws.pending_interaction_depth(&seat),
19009                1,
19010                "the depth counts exactly the ask on {}",
19011                seat.display(),
19012            );
19013            assert_eq!(
19014                ws.pending_request_seq(&seat),
19015                1,
19016                "and the ask has its own identity on {}",
19017                seat.display(),
19018            );
19019        }
19020
19021        // A seat holding nothing is the control: every assertion above reads
19022        // a parked ask, so a predicate that answered every seat would still
19023        // have to answer this one not at all.
19024        let quiet = SessionSlot::from_str_for_test("ask-quiet");
19025        let domain = ws.register_domain_session(quiet.clone(), None);
19026        domain.lock().turn_pending = true;
19027        assert_eq!(ws.pending_interaction(&quiet), None);
19028        assert_eq!(ws.pending_interaction_depth(&quiet), 0);
19029        assert_eq!(ws.session_activity(&quiet), L::Running);
19030
19031        ws.close_terminal(&SessionSlot::lead("TestOrg", "guard"));
19032    }
19033
19034    /// **The row names the strongest ask; the dock fronts its own queue.**
19035    /// The adjacencies pinned here are hand-off over question, hand-off over
19036    /// draft, and draft over settings write - one per run of the fixed order
19037    /// in [`Workspace::pending_interaction`]; the ends of each run stand for
19038    /// the rest. `pending_asks` still leads with the draft, because a card
19039    /// is what a person reads first.
19040    #[tokio::test]
19041    async fn the_row_names_the_strongest_ask_and_the_dock_fronts_the_draft() {
19042        let (ws, _rx) = Workspace::testing_stub();
19043        ws.seed_test_project("guard", "/tmp");
19044
19045        let seat = SessionSlot::lead("TestOrg", "guard");
19046        let entry = ws.terminal_for(&seat).expect("a terminal");
19047        let (_id, _answer) = ws
19048            .register_terminal_hand_off(&seat, entry.id, "the sudo password")
19049            .expect("the terminal hand-off parks");
19050        ws.seed_test_pending_interaction(&seat, PendingInteractionKind::Question);
19051        assert_eq!(
19052            ws.pending_interaction(&seat),
19053            Some(PendingInteractionKind::TerminalHandOff),
19054            "the terminal hand-off outranks the question beside it",
19055        );
19056
19057        let other = SessionSlot::from_str_for_test("precedence-other");
19058        ws.register_domain_session(other.clone(), None).lock().turn_pending = true;
19059        ws.note_client_attached();
19060        let (_id, _ending) = ws
19061            .register_document_hand_off(&other, "/proj/docs/plan.md", true)
19062            .expect("the document hand-off parks");
19063        ws.seed_test_pending_interaction(&other, PendingInteractionKind::SlackDraft);
19064        assert_eq!(
19065            ws.pending_interaction(&other),
19066            Some(PendingInteractionKind::DocumentHandOff),
19067            "the document hand-off outranks the Slack draft beside it",
19068        );
19069        assert!(
19070            matches!(
19071                ws.pending_asks(&other).first(),
19072                Some(crate::protocol::PendingAsk::SlackDraft(_))
19073            ),
19074            "the dock still fronts the draft",
19075        );
19076
19077        let third = SessionSlot::from_str_for_test("precedence-third");
19078        ws.seed_test_pending_interaction(&third, PendingInteractionKind::SettingsWrite);
19079        ws.seed_test_pending_interaction(&third, PendingInteractionKind::SlackDraft);
19080        assert_eq!(
19081            ws.pending_interaction(&third),
19082            Some(PendingInteractionKind::SlackDraft),
19083            "the Slack draft outranks the settings write beside it",
19084        );
19085
19086        ws.close_terminal(&seat);
19087    }
19088
19089    /// **A second ask of the same kind is a second number.** The registries
19090    /// that park a draft, a settings write and a vault call each note the ask
19091    /// they hold, so a second one on the same seat is news to a client rather
19092    /// than reusing the first one's identity.
19093    #[test]
19094    fn every_registry_ask_gets_its_own_number() {
19095        let (ws, _rx) = Workspace::testing_stub();
19096        let seat = SessionSlot::from_str_for_test("w-registry-numbers");
19097        let draft = |text: &str| forge_primitives::slack::SlackDraft {
19098            id: uuid::Uuid::new_v4(),
19099            workspace: "acme".to_owned(),
19100            conversation: "C1".to_owned(),
19101            conversation_label: "acme".to_owned(),
19102            thread_ts: None,
19103            text: text.to_owned(),
19104            operation: forge_primitives::slack::SlackDraftOperation::Post,
19105            thread: None,
19106            tagged: Vec::new(),
19107        };
19108
19109        let (_id, _decision) = ws.register_slack_draft(&seat, draft("first"));
19110        assert_eq!(ws.pending_request_seq(&seat), 1, "the first draft is an ask");
19111        // A seat with no domain still holds a registry ask, and the kind
19112        // answers through the ledger's no-domain arm.
19113        assert_eq!(
19114            ws.pending_interaction(&seat),
19115            Some(PendingInteractionKind::SlackDraft),
19116            "the draft is the seat's needs-you kind with no domain at all",
19117        );
19118        let (_id, _decision) = ws.register_slack_draft(&seat, draft("second"));
19119        assert_eq!(ws.pending_request_seq(&seat), 2, "a second draft is a second ask");
19120
19121        let (_id, _answer, _) = ws.register_settings_write(
19122            &seat,
19123            forge_primitives::settings::SettingsWriteAsk {
19124                id: uuid::Uuid::new_v4(),
19125                edits: vec![forge_primitives::settings::SettingsWriteChange {
19126                    key: "sessions.heal_attempts".to_owned(),
19127                    current: serde_json::json!(2),
19128                    proposed: serde_json::json!(4),
19129                    unset: false,
19130                }],
19131            },
19132        );
19133        assert_eq!(ws.pending_request_seq(&seat), 3, "a settings write is an ask");
19134        let (_id, _answer, _) = ws.register_vault_ask(
19135            &seat,
19136            forge_primitives::vault::VaultDraft::new(
19137                forge_primitives::vault::VaultTool::Search,
19138                "search for 'bank'",
19139            ),
19140            false,
19141        );
19142        assert_eq!(ws.pending_request_seq(&seat), 4, "a vault call is an ask");
19143        assert_eq!(
19144            ws.pending_interaction_depth(&seat),
19145            4,
19146            "and the depth sums all four rather than any one of them",
19147        );
19148    }
19149
19150    /// **The sequence is the ask's identity, and a read never moves it.**
19151    /// Two asks on one seat are two numbers; reading the roster is not an
19152    /// ask, so a client can tell the first from the second without seeing
19153    /// the depth move and be fooled by an answer that shrinks the batch.
19154    #[test]
19155    fn every_ask_gets_its_own_number() {
19156        let (ws, _rx) = Workspace::testing_stub();
19157        let seat = SessionSlot::from_str_for_test("w-numbered");
19158        assert_eq!(ws.pending_request_seq(&seat), 0, "a seat nothing asked is zero");
19159
19160        ws.note_ask(&seat);
19161        assert_eq!(ws.pending_request_seq(&seat), 1);
19162        ws.note_ask(&seat);
19163        assert_eq!(ws.pending_request_seq(&seat), 2, "a second ask is a second number");
19164        assert_eq!(
19165            ws.pending_request_seq(&SessionSlot::from_str_for_test("w-quiet")),
19166            0,
19167            "another seat's asks are not this seat's",
19168        );
19169    }
19170
19171    /// **A parked browser hand-off is in the READ as well as on the stream**:
19172    /// a view that attached after it landed has nothing else to draw the dock
19173    /// from, and the dock is the only place its answer can come from.
19174    #[tokio::test]
19175    async fn a_parked_hand_off_is_in_the_pending_asks_read() {
19176        let (ws, _rx) = Workspace::testing_stub();
19177        let key = SessionSlot::from_str_for_test("caller-uuid");
19178        let (calls, _heard) = tokio::sync::mpsc::unbounded_channel();
19179        ws.install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
19180        let (id, _answer) =
19181            ws.register_browser_hand_off(&key, "solve the CAPTCHA", "T2").await.expect("parks");
19182
19183        assert!(
19184            ws.pending_asks(&key)
19185                .iter()
19186                .any(|ask| matches!(ask, crate::protocol::PendingAsk::BrowserHandOff(held)
19187                    if held.id == id)),
19188            "the hand-off is in the read a late view makes",
19189        );
19190        let other = SessionSlot::from_str_for_test("other-uuid");
19191        assert!(
19192            !ws.pending_asks(&other)
19193                .iter()
19194                .any(|ask| matches!(ask, crate::protocol::PendingAsk::BrowserHandOff(_))),
19195            "and another seat's read does not carry it",
19196        );
19197    }
19198
19199    /// What a view that attached after the prompt landed reads: the requests
19200    /// the core kept beside the answer's oneshots, which is the only place
19201    /// they survive a stream that carries each once.
19202    ///
19203    /// **The list in arrival order**, because a parallel batch parks several
19204    /// at once and the read is all a mid-batch attach has - and the dock
19205    /// draws the front, which is the oldest, so the order is the read's own
19206    /// contract and not a reader's choice.
19207    #[test]
19208    fn pending_asks_reads_the_requests_the_core_kept_in_order() {
19209        let (ws, _rx) = Workspace::testing_stub();
19210        let held = |name: &str, slots: Vec<PendingInteractionSlot>| {
19211            let key = SessionSlot::from_str_for_test(name);
19212            let domain = ws.register_domain_session(key.clone(), None);
19213            {
19214                let mut guard = domain.lock();
19215                for (index, slot) in slots.into_iter().enumerate() {
19216                    guard.pending_interactions.insert(format!("{name}-{index}"), slot);
19217                }
19218            }
19219            key
19220        };
19221        let permission = || {
19222            let (tx, _rx) = tokio::sync::oneshot::channel();
19223            testing::test_permission(tx)
19224        };
19225        let question = || {
19226            let (tx, _rx) = tokio::sync::oneshot::channel();
19227            testing::test_question(tx)
19228        };
19229
19230        assert!(
19231            ws.pending_asks(&SessionSlot::from_str_for_test("a-none")).is_empty(),
19232            "a slot holding nothing kept nothing to read back",
19233        );
19234
19235        let prompted = held("a-permission", vec![permission()]);
19236        let asks = ws.pending_asks(&prompted);
19237        assert_eq!(asks.len(), 1, "a held permission prompt reads back");
19238        assert!(
19239            matches!(asks[0], crate::protocol::PendingAsk::Permission(_)),
19240            "and reads back as the kind it is",
19241        );
19242        assert_eq!(
19243            asks[0].tool_id(),
19244            Some(testing::TEST_TOOL_ID),
19245            "naming the call an answer addresses"
19246        );
19247
19248        let asked = held("a-question", vec![question()]);
19249        assert!(
19250            matches!(ws.pending_asks(&asked)[0], crate::protocol::PendingAsk::Question(_)),
19251            "a held question reads back as the kind it is",
19252        );
19253
19254        // A parallel batch, in the order the requests parked: the read hands
19255        // both back with the permission prompt leading, because it parked
19256        // first - the view's own queue rule.
19257        let both = held("a-both", vec![permission(), question()]);
19258        let asks = ws.pending_asks(&both);
19259        assert_eq!(asks.len(), 2, "both asks of a parallel batch read back");
19260        assert!(
19261            matches!(asks[0], crate::protocol::PendingAsk::Permission(_))
19262                && matches!(asks[1], crate::protocol::PendingAsk::Question(_)),
19263            "oldest first, which is the front a dock draws",
19264        );
19265
19266        // A repeated call id replaces in place rather than parking twice: the
19267        // set is keyed by the call an answer addresses.
19268        let repeated = SessionSlot::from_str_for_test("a-repeat");
19269        let domain = ws.register_domain_session(repeated.clone(), None);
19270        {
19271            let mut guard = domain.lock();
19272            guard.pending_interactions.insert("a-call".to_owned(), permission());
19273            guard.pending_interactions.insert("a-call".to_owned(), question());
19274        }
19275        let asks = ws.pending_asks(&repeated);
19276        assert_eq!(asks.len(), 1, "a repeated call id does not park twice");
19277        assert!(
19278            matches!(asks[0], crate::protocol::PendingAsk::Question(_)),
19279            "and the newer slot is the one held",
19280        );
19281    }
19282
19283    /// The statuspage answer is the same for every viewer, and it has to
19284    /// outlive any one of them: the probe lives in the core, so a client
19285    /// that attaches later still sees a service status at all. The answer
19286    /// is held as well as announced, so a view that attached after the one
19287    /// update that carried it can still read it.
19288    #[tokio::test]
19289    async fn the_service_status_is_held_and_announced() {
19290        use forge_primitives::cloud::service_status::{ServiceIssue, ServiceSeverity};
19291
19292        let (ws, mut rx) = Workspace::testing_stub();
19293        let found = ServiceIssue {
19294            severity: ServiceSeverity::Warning,
19295            message: "Claude Code status: degraded.".to_owned(),
19296        };
19297        let prober: ServiceStatusProber = {
19298            let found = found.clone();
19299            Box::new(move || {
19300                let found = found.clone();
19301                Box::pin(async move { Some(found) })
19302            })
19303        };
19304
19305        assert!(ws.service_status().is_none(), "nothing has been probed yet");
19306
19307        run_service_status_probe(
19308            std::sync::Arc::clone(&ws.service_status),
19309            ws.update_tx().clone(),
19310            prober,
19311        )
19312        .await;
19313
19314        assert_eq!(
19315            ws.service_status(),
19316            Some(found),
19317            "the answer the probe found is the answer the read hands back",
19318        );
19319        assert!(
19320            matches!(
19321                rx.try_recv(),
19322                Ok(frame) if matches!(&*frame.update, SessionUpdate::ServiceStatus { .. })
19323            ),
19324            "and the one update that carried it still goes out",
19325        );
19326    }
19327
19328    /// A seat can be held on a Slack draft, which the registry parks on a
19329    /// reply exactly the way a permission or a question is parked - the
19330    /// same shape, the same awaited answer, a different kind.
19331    ///
19332    /// The record had no room for it, so a view that attached after the
19333    /// draft landed could not read that one was waiting: a record named
19334    /// for a category has to carry every member of it, or the name says
19335    /// it is complete when it is not. A draft leads the list, which is the
19336    /// precedence the read has always given it - it is held in its own
19337    /// registry rather than in the session's pending set.
19338    #[test]
19339    fn a_parked_slack_draft_reads_back_as_the_third_kind() {
19340        let (ws, _rx) = Workspace::testing_stub();
19341        let seat = SessionSlot::from_str_for_test("a-draft");
19342        let draft = forge_primitives::slack::SlackDraft {
19343            id: uuid::Uuid::new_v4(),
19344            workspace: "acme".to_owned(),
19345            conversation: "C1".to_owned(),
19346            conversation_label: "acme".to_owned(),
19347            thread_ts: None,
19348            text: "hello".to_owned(),
19349            operation: forge_primitives::slack::SlackDraftOperation::Post,
19350            thread: None,
19351            tagged: Vec::new(),
19352        };
19353        let (_id, _decision) = ws.register_slack_draft(&seat, draft);
19354
19355        let asks = ws.pending_asks(&seat);
19356        assert_eq!(asks.len(), 1, "a parked draft reads back");
19357        assert!(
19358            matches!(asks[0], crate::protocol::PendingAsk::SlackDraft(_)),
19359            "and reads back as the kind it is",
19360        );
19361        assert!(
19362            asks[0].tool_id().is_none(),
19363            "a draft is answered by its own id, so it names no tool call",
19364        );
19365        assert!(
19366            ws.pending_asks(&SessionSlot::from_str_for_test("a-bystander")).is_empty(),
19367            "and a seat holding nothing is not handed another seat's draft",
19368        );
19369
19370        // The draft leads a question parked beside it, which is what the
19371        // single read did before the list existed.
19372        let domain = ws.register_domain_session(seat.clone(), None);
19373        let (tx, _rx) = tokio::sync::oneshot::channel();
19374        domain.lock().pending_interactions.insert("a-call".to_owned(), testing::test_question(tx));
19375        assert!(
19376            matches!(ws.pending_asks(&seat)[0], crate::protocol::PendingAsk::SlackDraft(_)),
19377            "a draft answers first, ahead of the question waiting behind it",
19378        );
19379    }
19380
19381    /// Background work is a fact about the session rather than about who
19382    /// is looking, so the read is slot-shaped and answers from the slot's
19383    /// own domain: a lead has background work too.
19384    #[test]
19385    fn background_work_is_read_from_the_slots_own_domain() {
19386        let (ws, _rx) = Workspace::testing_stub();
19387        let absent = SessionSlot::from_str_for_test("bg-absent");
19388        assert!(
19389            !ws.has_background_work(&absent),
19390            "a slot with no domain session has no live background work",
19391        );
19392
19393        let live = SessionSlot::from_str_for_test("bg-live");
19394        let domain = ws.register_domain_session(live.clone(), None);
19395        assert!(!ws.has_background_work(&live), "a fresh domain has none");
19396
19397        domain.lock().background_work = true;
19398        assert!(ws.has_background_work(&live), "the read answers the slot's own registry");
19399        assert!(
19400            !ws.has_background_work(&absent),
19401            "one slot's background work must not answer for another",
19402        );
19403    }
19404
19405    /// The whole point of the field: a worker that finished its turn is
19406    /// still `WorkerLiveness::Running`, so a lead polling `agents__list`
19407    /// used to have no way to tell it from one mid-turn.
19408    #[test]
19409    fn connected_worker_with_no_turn_in_flight_reports_idle() {
19410        let (ws, _rx) = Workspace::testing_stub();
19411        let key = SessionSlot::from_str_for_test("w-idle");
19412        ws.register_domain_session(key.clone(), None);
19413        let worker = entry("w-idle", WorkerLiveness::Running);
19414
19415        assert_eq!(
19416            ws.worker_activity(&worker),
19417            L::Idle,
19418            "liveness Running with no turn in flight is idle, not working",
19419        );
19420    }
19421
19422    /// **A turn that opens with the model's own output reads in flight.**
19423    ///
19424    /// The wire header went idle while a worker was demonstrably working:
19425    /// `turn_pending` is spent by the previous `Result` and
19426    /// `session_state_changed` no longer arrives, so thinking and prose left
19427    /// it false for 41 seconds (measured on the seat, 2026-10-09) while the
19428    /// terminal showed the turn running. The model's own frames are the proof
19429    /// a view reads; this is that reading on the wire's side.
19430    #[test]
19431    fn a_turn_opening_with_the_models_own_output_reads_in_flight() {
19432        let (ws, _rx) = Workspace::testing_stub();
19433        let key = SessionSlot::from_str_for_test("w-opening");
19434        let domain = ws.register_domain_session(key, None);
19435        let mut guard = domain.lock();
19436
19437        // The previous turn's Result has landed, which is the state the header
19438        // was stuck in: the routed-prompt stamp is spent and nothing else has
19439        // said a turn is open.
19440        guard.turn_pending = false;
19441        assert!(!guard.turn_in_flight(), "precondition: nothing yet says a turn is open");
19442
19443        guard.note_liveness(crate::domain_session::TurnLiveness::Working);
19444        assert!(guard.turn_in_flight(), "the model is producing output, so a turn is open");
19445    }
19446
19447    /// **The mapping itself, not just the setter.**
19448    ///
19449    /// The two tests above drive `note_liveness` directly, so they pin the
19450    /// predicate and stop short of the fold that feeds it: deleting the
19451    /// `ThinkingTokens` arm here leaves them both green while a turn that
19452    /// opens with thinking regresses to the 41-second idle window the mirror
19453    /// exists to close. This is the arm, so it is the one pinned.
19454    #[test]
19455    fn a_frame_that_proves_a_turn_maps_to_working() {
19456        let counter = forge_primitives::Message::ThinkingTokens {
19457            estimated_tokens: 40,
19458            estimated_tokens_delta: 40,
19459            uuid: "tokens-1".to_owned(),
19460            session_id: "s".to_owned(),
19461            extras: serde_json::Map::new(),
19462        };
19463        assert_eq!(
19464            crate::domain_session::liveness_of(&counter),
19465            Some(crate::domain_session::TurnLiveness::Working),
19466            "the model's own output opens a turn",
19467        );
19468
19469        // A report ABOUT the turn is not a liveness signal, and
19470        // `session_state_changed` is the subtype to pin: it is the one the fold
19471        // already parses, so an arm reading it as "running" would otherwise
19472        // survive every test there is.
19473        let report = forge_primitives::Message::System {
19474            subtype: "session_state_changed".to_owned(),
19475            session_id: None,
19476            data: serde_json::Value::Null,
19477        };
19478        assert_eq!(
19479            crate::domain_session::liveness_of(&report),
19480            None,
19481            "a system report is not a liveness signal",
19482        );
19483    }
19484
19485    /// The same mirror closes: a `Result` ends the turn the frames opened.
19486    #[test]
19487    fn a_result_closes_the_turn_the_frames_opened() {
19488        let (ws, _rx) = Workspace::testing_stub();
19489        let key = SessionSlot::from_str_for_test("w-closing");
19490        let domain = ws.register_domain_session(key, None);
19491        let mut guard = domain.lock();
19492
19493        guard.note_liveness(crate::domain_session::TurnLiveness::Working);
19494        assert!(guard.turn_in_flight(), "precondition: the frames opened a turn");
19495
19496        guard.note_liveness(crate::domain_session::TurnLiveness::Ended);
19497        assert!(!guard.turn_in_flight(), "the Result ended it");
19498    }
19499
19500    /// Every state the derivation can land on, including the precedence
19501    /// that matters: a pending interaction outranks the in-flight turn it
19502    /// is blocking, otherwise the deadlock stays invisible.
19503    #[test]
19504    fn worker_activity_covers_every_derived_state() {
19505        let (ws, _rx) = Workspace::testing_stub();
19506
19507        let with_domain = |name: &str, f: &dyn Fn(&mut DomainSession)| {
19508            let key = SessionSlot::from_str_for_test(name);
19509            let domain = ws.register_domain_session(key, None);
19510            f(&mut domain.lock());
19511            ws.worker_activity(&entry(name, WorkerLiveness::Running))
19512        };
19513
19514        assert_eq!(
19515            with_domain("w-pending", &|d| d.turn_pending = true),
19516            L::Running,
19517            "turn_pending is the synchronous turn-start marker",
19518        );
19519        assert_eq!(
19520            with_domain("w-wire-running", &|d| {
19521                d.runtime_state = Some(forge_primitives::RuntimeSessionState::Running);
19522            }),
19523            L::Running,
19524            "the wire-confirmed Running state counts even before turn_pending",
19525        );
19526        // Unreachable today rather than merely untested: nothing in the
19527        // tree emits `requires_action`, and `session_state_changed`
19528        // itself is in no wire-conformance baseline. Pinned so the
19529        // mapping is already right if the CLI ever sends it.
19530        assert_eq!(
19531            with_domain("w-wire-action", &|d| {
19532                d.runtime_state = Some(forge_primitives::RuntimeSessionState::RequiresAction);
19533            }),
19534            L::Attention,
19535            "RequiresAction is the CLI asking for a human, not a turn making progress",
19536        );
19537        assert_eq!(
19538            with_domain("w-blocked", &|d| {
19539                d.turn_pending = true;
19540                let (tx, _rx) = tokio::sync::oneshot::channel();
19541                d.pending_interactions.insert("tool-1".to_owned(), testing::test_permission(tx));
19542            }),
19543            L::Attention,
19544            "a pending interaction outranks the turn it is blocking",
19545        );
19546
19547        // No DomainSession at all: the subprocess is gone even though the
19548        // registry still lists the worker.
19549        assert_eq!(ws.worker_activity(&entry("w-gone", WorkerLiveness::Running)), L::Sleeping);
19550
19551        // Liveness that already answers the question passes straight
19552        // through - neither has a connected session to interrogate.
19553        assert_eq!(ws.worker_activity(&entry("w-spawning", WorkerLiveness::Spawning)), L::Spawning);
19554        assert_eq!(ws.worker_activity(&entry("w-failed", WorkerLiveness::Failed)), L::Failed);
19555    }
19556
19557    /// Contract, not a reproduction: `Attention` is only ever truthful
19558    /// while a turn is in flight, so a held slot without one reads
19559    /// `Idle`. No production route is known to reach this state - it
19560    /// pins the invariant, so a slot that outlives its turn can never
19561    /// pin a worker at `Attention` for the life of the session.
19562    #[test]
19563    fn stranded_interaction_slot_on_an_idle_session_reports_idle() {
19564        let (ws, _rx) = Workspace::testing_stub();
19565        let key = SessionSlot::from_str_for_test("w-stranded");
19566        let domain = ws.register_domain_session(key, None);
19567        {
19568            let mut guard = domain.lock();
19569            // A held slot with no turn: the shape the invariant forbids.
19570            guard.turn_pending = false;
19571            let (tx, _rx) = tokio::sync::oneshot::channel();
19572            guard
19573                .pending_interactions
19574                .insert("tool-stranded".to_owned(), testing::test_permission(tx));
19575        }
19576
19577        assert_eq!(
19578            ws.worker_activity(&entry("w-stranded", WorkerLiveness::Running)),
19579            L::Idle,
19580            "a held slot with no turn in flight is incoherent state, not a worker awaiting input",
19581        );
19582    }
19583
19584    /// A parked Slack draft is a pending interaction like any other: the
19585    /// seat is waiting on a person, so the lifecycle has to say
19586    /// `Attention` - or every view that draws its needs mark from the
19587    /// lifecycle (the client's) stays quiet while the dock sits
19588    /// unanswered. The draft lives in the workspace's own registry rather
19589    /// than the session's pending set, which is exactly why this arm needs
19590    /// the read of its own.
19591    #[tokio::test]
19592    async fn a_parked_slack_draft_reads_as_attention() {
19593        let (ws, _rx) = Workspace::testing_stub();
19594        let seat = SessionSlot::from_str_for_test("w-draft");
19595        let domain = ws.register_domain_session(seat.clone(), None);
19596        domain.lock().turn_pending = true;
19597        assert_eq!(
19598            ws.session_activity(&seat),
19599            L::Running,
19600            "a turn advancing on its own is Running, which is the control",
19601        );
19602
19603        let draft = forge_primitives::slack::SlackDraft {
19604            id: uuid::Uuid::new_v4(),
19605            workspace: "acme".to_owned(),
19606            conversation: "C1".to_owned(),
19607            conversation_label: "acme".to_owned(),
19608            thread_ts: None,
19609            text: "hello".to_owned(),
19610            operation: forge_primitives::slack::SlackDraftOperation::Post,
19611            thread: None,
19612            tagged: Vec::new(),
19613        };
19614        let (_id, _decision) = ws.register_slack_draft(&seat, draft);
19615
19616        assert_eq!(
19617            ws.session_activity(&seat),
19618            L::Attention,
19619            "a held draft is a person's to answer, not a running turn's",
19620        );
19621
19622        // A draft belongs to the seat that asked: another seat holding
19623        // nothing of its own stays Running.
19624        let bystander = SessionSlot::from_str_for_test("w-bystander");
19625        let other = ws.register_domain_session(bystander.clone(), None);
19626        other.lock().turn_pending = true;
19627        assert_eq!(
19628            ws.session_activity(&bystander),
19629            L::Running,
19630            "another seat's draft is not this seat's news",
19631        );
19632    }
19633
19634    /// A parked browser hand-off is a pending interaction like any other:
19635    /// the seat waits on a person, so the lifecycle says `Attention` - every
19636    /// needs mark draws from this. Ved, live round 2026-10-07: the hand-off
19637    /// dock waited while the rail and the tabs stayed quiet. Same shape as
19638    /// the parked draft's arm, registry read and all.
19639    #[tokio::test]
19640    async fn a_parked_browser_hand_off_reads_as_attention() {
19641        let (ws, _rx) = Workspace::testing_stub();
19642        let seat = SessionSlot::from_str_for_test("w-handoff");
19643        let domain = ws.register_domain_session(seat.clone(), None);
19644        domain.lock().turn_pending = true;
19645        assert_eq!(ws.session_activity(&seat), L::Running, "the control, as for the draft");
19646
19647        let (calls, _heard) = tokio::sync::mpsc::unbounded_channel();
19648        ws.install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
19649        let (_id, _answer) =
19650            ws.register_browser_hand_off(&seat, "solve the CAPTCHA", "T2").await.expect("parks");
19651
19652        assert_eq!(
19653            ws.session_activity(&seat),
19654            L::Attention,
19655            "a held hand-off is a person's to answer, not a running turn's",
19656        );
19657        assert_eq!(
19658            ws.pending_interaction(&seat),
19659            Some(crate::workspace::PendingInteractionKind::BrowserHandOff),
19660            "and the roster says what the seat is waiting on",
19661        );
19662        assert_eq!(ws.pending_interaction_depth(&seat), 1, "with the ask it holds");
19663        assert_eq!(ws.pending_request_seq(&seat), 1, "and the ask has its own identity");
19664
19665        let bystander = SessionSlot::from_str_for_test("w-bystander2");
19666        let other = ws.register_domain_session(bystander.clone(), None);
19667        other.lock().turn_pending = true;
19668        assert_eq!(
19669            ws.session_activity(&bystander),
19670            L::Running,
19671            "another seat's hand-off is not this seat's news",
19672        );
19673    }
19674
19675    /// **A parked document hand-off rides the same machinery every ask
19676    /// does.** It parks in its own registry, so without this read a seat
19677    /// holding one kept its running mark and the needs-you row said
19678    /// nothing - the silence #1758 and the browser hand-off both had.
19679    #[test]
19680    fn a_parked_document_hand_off_reads_as_attention() {
19681        let (ws, _rx) = Workspace::testing_stub();
19682        let seat = SessionSlot::from_str_for_test("w-dochandoff");
19683        let domain = ws.register_domain_session(seat.clone(), None);
19684        domain.lock().turn_pending = true;
19685        assert_eq!(ws.session_activity(&seat), L::Running, "the control, mid-turn");
19686        assert_eq!(ws.pending_interaction(&seat), None, "and no ask is held yet");
19687
19688        ws.note_client_attached();
19689        let (id, _ending) = ws
19690            .register_document_hand_off(&seat, "/proj/docs/plan.md", true)
19691            .expect("the park lands with a client attached");
19692
19693        assert_eq!(
19694            ws.session_activity(&seat),
19695            L::Attention,
19696            "a held ask is a person's to answer, not a running turn's",
19697        );
19698        assert_eq!(
19699            ws.pending_interaction(&seat),
19700            Some(crate::PendingInteractionKind::DocumentHandOff),
19701            "and the row's needs-you names the kind",
19702        );
19703        assert_eq!(ws.pending_interaction_depth(&seat), 1, "the depth counts it");
19704
19705        assert!(ws.resolve_document_hand_off(
19706            id,
19707            &seat,
19708            forge_primitives::document::DocumentHandOffEnding::Approve {
19709                review_id: None,
19710                note: None,
19711            },
19712        ));
19713        assert_eq!(ws.session_activity(&seat), L::Running, "answered, the turn runs again");
19714        assert_eq!(ws.pending_interaction(&seat), None, "and the row stands down");
19715    }
19716
19717    /// **A parked vault call rides the same machinery every ask does.** It
19718    /// parks in its own registry, so without `has_vault_ask` a seat holding
19719    /// one kept its running mark and the needs-you row said nothing.
19720    #[test]
19721    fn a_parked_vault_ask_reads_as_attention() {
19722        let (ws, _rx) = Workspace::testing_stub();
19723        let seat = SessionSlot::from_str_for_test("w-vaultask");
19724        let domain = ws.register_domain_session(seat.clone(), None);
19725        domain.lock().turn_pending = true;
19726        assert_eq!(ws.session_activity(&seat), L::Running, "the control, mid-turn");
19727
19728        ws.note_client_attached();
19729        let draft = forge_primitives::vault::VaultDraft::new(
19730            forge_primitives::vault::VaultTool::Search,
19731            "search for 'bank'",
19732        );
19733        let (id, _held, _) = ws.register_vault_ask(&seat, draft, false);
19734        assert_eq!(
19735            ws.session_activity(&seat),
19736            L::Attention,
19737            "a held vault call is a person's to answer, not a running turn's",
19738        );
19739
19740        assert!(ws.resolve_vault_ask(
19741            id,
19742            &seat,
19743            forge_primitives::vault::VaultAskAnswer {
19744                decision: forge_primitives::vault::VaultAskDecision::Decline,
19745                master_password: None,
19746                two_factor: None,
19747                released: None,
19748            },
19749        ));
19750        assert!(ws.settle_vault_ask(id, &seat, forge_primitives::vault::VaultAskEnding::Declined,));
19751        assert_eq!(ws.session_activity(&seat), L::Running, "answered, the turn runs again");
19752    }
19753
19754    /// `activity` is populated by the `agents__list` read path only; the
19755    /// `WorkerStatusChanged` event path leaves it `None`.
19756    #[test]
19757    fn event_path_leaves_activity_none() {
19758        let (ws, _rx) = Workspace::testing_stub();
19759        let key = SessionSlot::from_str_for_test("w-both");
19760        ws.register_domain_session(key.clone(), None);
19761        let worker = entry("w-both", WorkerLiveness::Running);
19762
19763        assert_eq!(worker.to_status().activity, None, "the event path derives no activity");
19764        assert_eq!(
19765            ws.worker_status_snapshot(&worker).activity,
19766            Some(L::Idle),
19767            "the read path always derives one",
19768        );
19769    }
19770}
19771
19772#[cfg(test)]
19773#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
19774mod release_session_cascade_tests {
19775    use super::*;
19776    use crate::mcp::workers::types::WorkerEntry;
19777    use forge_primitives::WorkerLiveness;
19778    use std::fs;
19779    use std::time::SystemTime;
19780    use tempfile::tempdir;
19781
19782    /// The `forge` project's lead slot, the slot the fixtures in this
19783    /// module release to trigger the cascade.
19784    fn lead_slot() -> SessionSlot {
19785        SessionSlot::lead("Default", "forge")
19786    }
19787
19788    fn fake_entry(label: &str) -> WorkerEntry {
19789        WorkerEntry {
19790            label: label.into(),
19791            charter: "test charter".into(),
19792            slot: SessionSlot::worker("Default", "forge", label),
19793            session_id: None,
19794            status: WorkerLiveness::Running,
19795            spawned_at: SystemTime::UNIX_EPOCH,
19796            spawned_by: lead_slot(),
19797            needs_tag: false,
19798            is_git_repo_at_spawn: false,
19799            cwd: None,
19800            diagnostic: None,
19801            kick: None,
19802        }
19803    }
19804
19805    fn make_workspace_dir() -> tempfile::TempDir {
19806        let dir = tempdir().expect("tempdir");
19807        fs::write(
19808            forge_toml_path(dir.path()),
19809            r#"
19810[[orgs]]
19811name = "Default"
19812accounts = ["Stargate"]
19813
19814[[orgs.projects]]
19815name = "forge"
19816path = "~/Projects/forge"
19817
19818[[accounts]]
19819display_name = "Stargate"
19820token = "t"
19821models = ["claude-sonnet-5"]
19822provider = "anthropic"
19823"#,
19824        )
19825        .expect("write forge.toml");
19826        dir
19827    }
19828
19829    /// `release_session_with_cascade` cascades worker termination when
19830    /// the released session is a project's lead: its slot carries the
19831    /// lead label and names a declared project. Workers' JSONLs persist
19832    /// on disk (we don't delete them); only the in-memory live_workers
19833    /// entries + the running session subprocesses are torn down.
19834    #[tokio::test]
19835    async fn release_session_on_lead_cascades_workers() {
19836        let dir = make_workspace_dir();
19837        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
19838        let mut rx = workspace.subscribe_answerer();
19839        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
19840        workspace
19841            .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
19842
19843        let project = workspace.list_projects().into_iter().next().expect("forge project");
19844        let project_key = project.key.clone();
19845        let lead_key = lead_slot();
19846
19847        // Insert two workers under the project.
19848        workspace.insert_live_worker(&project_key, fake_entry("r1"));
19849        workspace.insert_live_worker(&project_key, fake_entry("r2"));
19850        assert_eq!(workspace.list_live_workers(&project_key).len(), 2);
19851
19852        // Release the lead. Cascade fires before the lead release
19853        // itself; workers' live_workers entries are gone afterward.
19854        workspace.release_session_with_cascade(&lead_key);
19855        assert!(
19856            workspace.list_live_workers(&project_key).is_empty(),
19857            "workers must be cascade-closed"
19858        );
19859        // **Every closed seat is ended and takes its tabs with it**, the
19860        // cascaded workers' included: the fleet hears the session-ended fact
19861        // for the lead and each worker, and a replacement for each. **The
19862        // order is part of the contract**: a seat's end is told before its
19863        // replacement is asked for.
19864        let mut calls: Vec<(String, serde_json::Value)> = Vec::new();
19865        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
19866        while tokio::time::Instant::now() < deadline {
19867            let left = deadline.saturating_duration_since(tokio::time::Instant::now());
19868            let Ok(Some((what, args))) = tokio::time::timeout(left, heard.recv()).await else {
19869                break;
19870            };
19871            match what.as_str() {
19872                "replace_seat" | "session_ended" => calls.push((what, args)),
19873                other => panic!("unexpected fleet call {other}"),
19874            }
19875            let count = |name: &str| calls.iter().filter(|(what, _)| what == name).count();
19876            if count("replace_seat") >= 3 && count("session_ended") >= 3 {
19877                break;
19878            }
19879        }
19880        let named = |name: &str| -> Vec<serde_json::Value> {
19881            calls.iter().filter(|(what, _)| what == name).map(|(_, args)| args.clone()).collect()
19882        };
19883        let replaced = named("replace_seat");
19884        let ended = named("session_ended");
19885        assert_eq!(replaced.len(), 3, "every closed seat is replaced: {replaced:?}");
19886        assert_eq!(ended.len(), 3, "and every one is ended: {ended:?}");
19887        assert!(
19888            replaced.contains(&serde_json::json!(lead_key)),
19889            "the lead's seat is replaced: {replaced:?}",
19890        );
19891        assert!(
19892            ended.contains(&serde_json::json!(lead_key)),
19893            "the lead's end is told, not inferred: {ended:?}",
19894        );
19895        for (at, (what, seat)) in calls.iter().enumerate() {
19896            if what != "session_ended" {
19897                continue;
19898            }
19899            let replaced_at =
19900                calls.iter().position(|(other, args)| other == "replace_seat" && args == seat);
19901            assert!(
19902                replaced_at.is_some_and(|replaced_at| replaced_at > at),
19903                "seat {seat} is ended before its replacement is asked for",
19904            );
19905        }
19906
19907        // Drain the channel and confirm we saw a Removed
19908        // WorkerStatusChanged for each worker.
19909        let mut removed_count = 0;
19910        while let Ok(frame) = rx.try_recv() {
19911            if let SessionUpdate::WorkerStatusChanged { action, .. } =
19912                std::sync::Arc::unwrap_or_clone(frame.update)
19913                && action == crate::protocol::WorkerStatusAction::Removed
19914            {
19915                removed_count += 1;
19916            }
19917        }
19918        assert_eq!(removed_count, 2, "two Removed events fire for the two workers");
19919    }
19920
19921    /// **The release is announced before anything is torn down** (#1930).
19922    /// A viewer marks the seat from this frame, so it has to be out ahead of
19923    /// the cascade: announced after, a lead's row would keep reading as
19924    /// working for exactly the seconds the teardown takes - and a close made
19925    /// from another view has no other frame at all.
19926    #[tokio::test]
19927    async fn a_release_is_announced_before_the_cascade_runs() {
19928        let dir = make_workspace_dir();
19929        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
19930        let mut rx = workspace.subscribe_answerer();
19931
19932        let project = workspace.list_projects().into_iter().next().expect("forge project");
19933        let project_key = project.key.clone();
19934        let lead_key = lead_slot();
19935        workspace.insert_live_worker(&project_key, fake_entry("r1"));
19936
19937        workspace.release_session_with_cascade(&lead_key);
19938
19939        let announced: Vec<SessionUpdate> = std::iter::from_fn(|| rx.try_recv().ok())
19940            .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
19941            .collect();
19942        let first = announced.first().expect("the release announces something");
19943        assert!(
19944            matches!(first, SessionUpdate::Releasing { key } if key == &lead_key),
19945            "the lead's release is the first thing announced: {first:?}",
19946        );
19947        assert!(
19948            announced.iter().any(|update| matches!(
19949                update,
19950                SessionUpdate::WorkerStatusChanged { action, .. }
19951                    if *action == crate::protocol::WorkerStatusAction::Removed
19952            )),
19953            "and the cascade's own removal follows it: {announced:?}",
19954        );
19955    }
19956
19957    /// `release_session_with_cascade` on a non-lead (or unknown) session
19958    /// is a plain release with NO cascade. Confirms the cascade is gated on
19959    /// "session is a lead of some project."
19960    #[tokio::test]
19961    async fn release_session_on_non_lead_does_not_cascade() {
19962        let dir = make_workspace_dir();
19963        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
19964
19965        let project_key = workspace.list_projects().into_iter().next().expect("forge").key;
19966        workspace.insert_live_worker(&project_key, fake_entry("r1"));
19967
19968        // A worker of the very project the lead slot above names: it
19969        // resolves, so only the label keeps the cascade off.
19970        let unknown = SessionSlot::worker("Default", "forge", "r1");
19971        workspace.release_session_with_cascade(&unknown);
19972        assert_eq!(
19973            workspace.list_live_workers(&project_key).len(),
19974            1,
19975            "non-lead release must not cascade"
19976        );
19977    }
19978
19979    /// The cascade is gated on the slot's own label, not on where the
19980    /// session sits in the catalog. A worker whose Connected landed it
19981    /// at `catalog[0]` is still a worker, so closing it must not drain
19982    /// its peers; the lead's slot, released with the same catalog order,
19983    /// still cascades.
19984    #[tokio::test]
19985    async fn release_session_cascades_when_worker_sits_at_catalog_head() {
19986        let dir = make_workspace_dir();
19987        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
19988
19989        let project = workspace.list_projects().into_iter().next().expect("forge project");
19990        let project_key = project.key.clone();
19991        let project_path = project.path.to_string_lossy().into_owned();
19992
19993        // Seed the lead FIRST, then the worker. record_connected_session
19994        // inserts at index 0, so after both calls catalog[0] is the
19995        // worker and catalog[1] is the lead.
19996        let lead_key = lead_slot();
19997        let worker_key = SessionSlot::worker("Default", "forge", "reviewer");
19998        workspace.record_connected_session(&project_path, &lead_key.display(), None);
19999        workspace.record_connected_session(&project_path, &worker_key.display(), None);
20000        workspace.insert_live_worker(&project_key, fake_entry("reviewer"));
20001
20002        // Sanity: catalog head is the worker, not the lead.
20003        let projects_now = workspace.list_projects();
20004        let first_session = &projects_now[0].sessions[0].session;
20005        assert_eq!(
20006            first_session.as_str(),
20007            worker_key.display(),
20008            "catalog[0] must be the worker for the discrimination to bite"
20009        );
20010
20011        // Closing the WORKER: its slot carries the worker's label, so
20012        // nothing reads it as its lead however the registry is ordered.
20013        workspace.release_session_with_cascade(&worker_key);
20014        assert_eq!(
20015            workspace.list_live_workers(&project_key).len(),
20016            1,
20017            "a worker's slot is never mistaken for its lead, whatever the catalog order"
20018        );
20019
20020        // Release the LEAD, with the catalog still ordering the worker
20021        // first: the label is what cascades.
20022        workspace.release_session_with_cascade(&lead_key);
20023
20024        assert!(
20025            workspace.list_live_workers(&project_key).is_empty(),
20026            "lead release must cascade even when a worker sits at catalog[0]"
20027        );
20028    }
20029}
20030
20031#[cfg(test)]
20032#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
20033mod tag_retry_tests {
20034    use super::*;
20035    use std::fs;
20036    use tempfile::tempdir;
20037
20038    /// Build the on-disk `<config_dir>/projects/<sanitized_cwd>/`
20039    /// directory `tag_session` expects and return the path the JSONL
20040    /// would land at. Caller decides when to actually create the file.
20041    fn jsonl_path_for(
20042        config_dir: &std::path::Path,
20043        cwd: &std::path::Path,
20044        session_id: &str,
20045    ) -> std::path::PathBuf {
20046        let sanitized = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
20047            &cwd.to_string_lossy(),
20048        ));
20049        let project_dir = forge_sdk::projects_dir_for(config_dir).join(&sanitized);
20050        fs::create_dir_all(&project_dir).expect("project dir");
20051        project_dir.join(format!("{session_id}.jsonl"))
20052    }
20053
20054    /// Init a repo at `root` and add the worker's worktree at
20055    /// `<root>/.claude/worktrees/<label>`, so the connected-time worktree
20056    /// check reads a tree git can reuse. The caller names the worktree path
20057    /// itself, as the spawn does.
20058    fn init_worker_worktree(root: &std::path::Path, label: &str) {
20059        let git = |args: &[&str]| {
20060            let status = std::process::Command::new("git")
20061                .args(args)
20062                .current_dir(root)
20063                .status()
20064                .expect("spawn git");
20065            assert!(status.success(), "git {args:?} failed in {root:?}");
20066        };
20067        git(&["init", "-q"]);
20068        git(&["config", "user.email", "t@example.com"]);
20069        git(&["config", "user.name", "Test"]);
20070        git(&["config", "commit.gpgsign", "false"]);
20071        fs::write(root.join("README.md"), "seed").expect("write seed");
20072        git(&["add", "."]);
20073        git(&["commit", "-q", "-m", "init"]);
20074        let worktree = root.join(".claude").join("worktrees").join(label);
20075        fs::create_dir_all(worktree.parent().expect("worktrees dir")).expect("mkdir worktrees");
20076        let branch = format!("worktree-{label}");
20077        let path = worktree.to_string_lossy().into_owned();
20078        git(&["worktree", "add", "-q", "-b", &branch, &path]);
20079    }
20080
20081    /// JSONL exists from the start: tag_session_with_retry succeeds on
20082    /// the first attempt and appends a tag row.
20083    #[tokio::test]
20084    async fn succeeds_immediately_when_jsonl_exists() {
20085        let cfg = tempdir().expect("cfg");
20086        let cwd = tempdir().expect("cwd");
20087        let session_id = "550e8400-e29b-41d4-a716-446655440001";
20088        let path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20089        fs::write(&path, "").expect("seed jsonl");
20090
20091        let result = tag_session_with_retry(
20092            cfg.path(),
20093            session_id,
20094            "forge:worker:smoke",
20095            "Default/forge",
20096            &cwd.path().to_string_lossy(),
20097            5,
20098            Duration::from_millis(10),
20099        )
20100        .await;
20101        assert!(result.is_ok(), "tag should succeed: {result:?}");
20102        let body = fs::read_to_string(&path).expect("read");
20103        assert!(body.contains("\"tag\":\"forge:worker:smoke\""), "tag row appended: {body:?}");
20104    }
20105
20106    /// JSONL appears after a few attempts: retry loop wins. Spawns the
20107    /// retry, sleeps long enough for it to hit `NotFound` once or twice,
20108    /// then creates the file and observes a successful tag.
20109    #[tokio::test]
20110    async fn retries_until_jsonl_appears() {
20111        let cfg = tempdir().expect("cfg");
20112        let cwd = tempdir().expect("cwd");
20113        let session_id = "550e8400-e29b-41d4-a716-446655440002";
20114        let path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20115        // File doesn't exist yet; create it after a brief delay.
20116        let path_clone = path.clone();
20117        tokio::spawn(async move {
20118            tokio::time::sleep(Duration::from_millis(120)).await;
20119            fs::write(&path_clone, "").expect("create jsonl");
20120        });
20121
20122        let result = tag_session_with_retry(
20123            cfg.path(),
20124            session_id,
20125            "forge:worker:smoke",
20126            "Default/forge",
20127            &cwd.path().to_string_lossy(),
20128            30,
20129            Duration::from_millis(50),
20130        )
20131        .await;
20132        assert!(result.is_ok(), "retry should win: {result:?}");
20133        let body = fs::read_to_string(&path).expect("read");
20134        assert!(body.contains("\"tag\":\"forge:worker:smoke\""), "tag row appended: {body:?}");
20135    }
20136
20137    /// JSONL never appears: retry exhausts and surfaces NotFound.
20138    #[tokio::test]
20139    async fn gives_up_after_max_attempts() {
20140        let cfg = tempdir().expect("cfg");
20141        let cwd = tempdir().expect("cwd");
20142        let session_id = "550e8400-e29b-41d4-a716-446655440003";
20143        let _path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20144        // Don't create the JSONL.
20145
20146        let result = tag_session_with_retry(
20147            cfg.path(),
20148            session_id,
20149            "forge:worker:smoke",
20150            "Default/forge",
20151            &cwd.path().to_string_lossy(),
20152            3,
20153            Duration::from_millis(10),
20154        )
20155        .await;
20156        match result {
20157            Err(forge_sdk::Error::Io(io_err)) => {
20158                assert_eq!(io_err.kind(), std::io::ErrorKind::NotFound);
20159            }
20160            other => panic!("expected Io(NotFound), got {other:?}"),
20161        }
20162    }
20163
20164    /// Invalid UUID is a non-NotFound error: must propagate immediately
20165    /// without retrying.
20166    #[tokio::test]
20167    async fn invalid_uuid_propagates_without_retry() {
20168        let cfg = tempdir().expect("cfg");
20169        let cwd = tempdir().expect("cwd");
20170        let start = std::time::Instant::now();
20171        let result = tag_session_with_retry(
20172            cfg.path(),
20173            "not-a-valid-uuid",
20174            "forge:worker:smoke",
20175            "Default/forge",
20176            &cwd.path().to_string_lossy(),
20177            30,
20178            Duration::from_millis(500),
20179        )
20180        .await;
20181        let elapsed = start.elapsed();
20182        assert!(matches!(result, Err(forge_sdk::Error::MessageParse { .. })));
20183        // Should NOT have slept through 30 * 500ms = 15s of retries.
20184        assert!(
20185            elapsed < Duration::from_secs(1),
20186            "non-NotFound errors must skip retry: {elapsed:?}"
20187        );
20188    }
20189
20190    /// A `Spawning` worker entry keyed by the slot its spawn names -
20191    /// the triple the tag path looks the entry up by. The slot does not
20192    /// move across `/new`, so a re-keyed worker keeps the one it was
20193    /// spawned under.
20194    fn fake_spawning_entry(
20195        label: &str,
20196        session_key: &SessionSlot,
20197        needs_tag: bool,
20198    ) -> crate::mcp::workers::types::WorkerEntry {
20199        crate::mcp::workers::types::WorkerEntry {
20200            label: label.into(),
20201            charter: "test".into(),
20202            slot: session_key.clone(),
20203            session_id: None,
20204            status: forge_primitives::WorkerLiveness::Spawning,
20205            spawned_at: std::time::SystemTime::UNIX_EPOCH,
20206            spawned_by: SessionSlot::lead("TestOrg", "forge"),
20207            needs_tag,
20208            is_git_repo_at_spawn: false,
20209            cwd: None,
20210            diagnostic: None,
20211            kick: None,
20212        }
20213    }
20214
20215    /// `apply_worker_tag_or_rollback_with_config_dir`: when the JSONL
20216    /// never appears, the retry exhausts with NotFound, but the worker
20217    /// stays in `live_workers` (NO rollback), transitions to Running,
20218    /// and `needs_tag` remains true so the opportunistic retry on the
20219    /// first turn can try again. A single `StatusChanged` event fires.
20220    ///
20221    /// The durable row stays too, and it is the store that makes that
20222    /// assertion mean something: the worker is live and Running, so a
20223    /// delete reaching this arm would drop the only handle on a worker
20224    /// nothing has rolled back.
20225    #[tokio::test]
20226    async fn notfound_keeps_worker_with_needs_tag_flag() {
20227        let (workspace, mut rx) = Workspace::testing_stub();
20228        workspace.seed_test_project("forge", "/tmp/notfound-tag-keeps");
20229        let db_dir = tempdir().expect("db tempdir");
20230        workspace.install_db_for_test(
20231            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
20232        );
20233        let project_key =
20234            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
20235                "/tmp/notfound-tag-keeps",
20236            )));
20237        let session_id = "550e8400-e29b-41d4-a716-446655440010";
20238        let session_key = SessionSlot::worker("TestOrg", "forge", "idle");
20239        workspace.insert_live_worker(&project_key, fake_spawning_entry("idle", &session_key, true));
20240        workspace
20241            .record_worker_row(
20242                &project_key,
20243                "idle",
20244                session_id,
20245                "charter",
20246                Some("kick"),
20247                None,
20248                false,
20249                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
20250                None,
20251            )
20252            .expect("seed the row this arm must keep");
20253
20254        let cfg = tempdir().expect("cfg");
20255        let cwd = tempdir().expect("cwd");
20256        // Do NOT create the JSONL: tag_session will see NotFound every
20257        // attempt and the retry will exhaust.
20258        let _path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20259
20260        // A tight retry budget keeps the detached task fast without
20261        // bypassing the wrapper: this test verifies the wrapper's
20262        // classification + transition logic, so it must go through the
20263        // spawn the production path uses.
20264        let retry_budget =
20265            WorkerTagRetryBudget { max_attempts: 3, delay: Duration::from_millis(10) };
20266        workspace.apply_worker_tag_or_rollback_with_budget(
20267            &session_key,
20268            session_id,
20269            &project_key,
20270            "idle",
20271            cwd.path(),
20272            WorkerTagFacts { needs_tag: true, minted_row: true },
20273            cfg.path(),
20274            retry_budget,
20275        );
20276
20277        // The detached task sleeps once per attempt - after the last one
20278        // too - before it can classify the exhaustion, so the wait
20279        // derives from the budget handed to it above plus a stall
20280        // allowance a loaded machine cannot eat.
20281        let task_budget = retry_budget.delay * retry_budget.max_attempts;
20282        let status_changed =
20283            tokio::time::timeout(task_budget * 10 + Duration::from_secs(5), async {
20284                loop {
20285                    match rx.recv().await.map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
20286                    {
20287                        Some(SessionUpdate::WorkerStatusChanged { action, status, .. }) => {
20288                            if matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20289                            {
20290                                return Some(status);
20291                            }
20292                            if matches!(action, crate::protocol::WorkerStatusAction::Removed) {
20293                                return None;
20294                            }
20295                        }
20296                        Some(_) => {}
20297                        None => return None,
20298                    }
20299                }
20300            })
20301            .await
20302            .expect("timed out waiting for StatusChanged");
20303
20304        let status = status_changed.expect("expected StatusChanged, not Removed (rollback)");
20305        assert_eq!(status.label, "idle");
20306        assert!(matches!(status.status, forge_primitives::WorkerLiveness::Running));
20307
20308        // Worker entry should remain in live_workers with needs_tag=true.
20309        let entries = workspace.list_live_workers(&project_key);
20310        assert_eq!(entries.len(), 1, "worker must NOT have been rolled back");
20311        assert_eq!(entries[0].label, "idle");
20312        assert!(entries[0].needs_tag, "needs_tag stays true so opportunistic retry can fire");
20313        assert!(matches!(entries[0].status, forge_primitives::WorkerLiveness::Running));
20314        assert!(
20315            !workspace.worker_rows_for_project(&project_key).is_empty(),
20316            "the durable row stays: nothing was rolled back, and the row is what re-spawns this \
20317             worker after a restart",
20318        );
20319    }
20320
20321    /// Drive the connect-time worktree check for a worker whose session is
20322    /// pooled behind the row, and answer the row's status, its reason, and
20323    /// whether the pool, sender and domain were released with the failure.
20324    fn connect_worktree_check(
20325        cwd: &std::path::Path,
20326    ) -> (forge_primitives::WorkerLiveness, Option<String>, bool) {
20327        let (workspace, _updates) = Workspace::testing_stub();
20328        workspace.seed_test_project("proj-x", "/tmp/proj-x");
20329        let project_key = ProjectKey::new(
20330            forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/proj-x")),
20331        );
20332        let session_key = SessionSlot::worker("TestOrg", "proj-x", "builder");
20333        workspace.insert_live_worker(
20334            &project_key,
20335            crate::mcp::workers::types::WorkerEntry {
20336                label: "builder".to_owned(),
20337                charter: "charter".to_owned(),
20338                slot: session_key.clone(),
20339                session_id: None,
20340                status: forge_primitives::WorkerLiveness::Spawning,
20341                spawned_at: std::time::SystemTime::UNIX_EPOCH,
20342                spawned_by: SessionSlot::lead("TestOrg", "proj-x"),
20343                needs_tag: true,
20344                is_git_repo_at_spawn: true,
20345                cwd: Some(cwd.to_path_buf()),
20346                diagnostic: None,
20347                kick: None,
20348            },
20349        );
20350        // The session the row is about, so the release the failure owes can
20351        // be seen: a Failed row must leave none behind.
20352        let (handle, _commands) = Workspace::testing_stub_handle();
20353        let handle = Arc::new(handle);
20354        workspace.pool.lock().insert(
20355            session_key.clone(),
20356            crate::workspace::PooledAgent {
20357                handle: Arc::clone(&handle),
20358                account: forge_gateway::AccountKey("acct-a".to_owned()),
20359                permission_mode: None,
20360                registration: None,
20361                session_id: "builder-session".to_owned(),
20362            },
20363        );
20364        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
20365        workspace.command_senders.lock().insert(session_key.clone(), cmd_tx);
20366        workspace.register_domain_session(session_key.clone(), Some(Arc::clone(&handle)));
20367
20368        let failed = workspace.fail_worker_without_worktree(&session_key);
20369        assert!(failed, "the connect check refuses a git worker without a usable worktree");
20370        let entry = workspace
20371            .list_live_workers(&project_key)
20372            .into_iter()
20373            .find(|entry| entry.label == "builder")
20374            .expect("the entry stays as the visible failure");
20375        let released = !workspace.session_is_pooled(&session_key)
20376            && !workspace.command_senders.lock().contains_key(&session_key)
20377            && !workspace.domain_handles.lock().contains_key(&session_key);
20378        (entry.status, entry.diagnostic, released)
20379    }
20380
20381    /// A worker whose worktree went missing between its creation and its
20382    /// first connect reads Failed with the reason, not Running, and the
20383    /// session behind it is released (#1593).
20384    #[test]
20385    fn a_worker_whose_worktree_is_gone_by_connect_reads_failed() {
20386        let cwd = tempdir().expect("cwd");
20387        let (status, diagnostic, released) = connect_worktree_check(cwd.path());
20388
20389        assert_eq!(
20390            status,
20391            forge_primitives::WorkerLiveness::Failed,
20392            "a worker with no worktree must not read live",
20393        );
20394        let diagnostic = diagnostic.expect("the row carries the reason");
20395        assert!(
20396            diagnostic.contains("worktree") && diagnostic.contains("gone"),
20397            "the reason names what went missing: {diagnostic}",
20398        );
20399        assert!(released, "a Failed row leaves no pooled session behind");
20400    }
20401
20402    /// The same check for a tree that is there but is not a git worktree git
20403    /// can reuse: the branch the tag-write also reads, and the reason says
20404    /// so rather than that the directory is gone (#1593).
20405    #[test]
20406    fn a_worker_whose_worktree_is_not_a_git_worktree_reads_failed() {
20407        let cwd = tempdir().expect("cwd");
20408        let worktree = cwd.path().join(".claude/worktrees/builder");
20409        fs::create_dir_all(&worktree).expect("the worktree dir stands");
20410        let (status, diagnostic, released) = connect_worktree_check(cwd.path());
20411
20412        assert_eq!(status, forge_primitives::WorkerLiveness::Failed);
20413        let diagnostic = diagnostic.expect("the row carries the reason");
20414        assert!(
20415            diagnostic.contains("not a git worktree"),
20416            "the reason names the unusable tree: {diagnostic}",
20417        );
20418        assert!(released, "a Failed row leaves no pooled session behind");
20419    }
20420
20421    /// **A failed worker stays in the persisted-row listing.** The agents
20422    /// surface draws its worker rows from these labels; a gone worktree
20423    /// fails the start probe, and dropping the row there would hide both
20424    /// the failure and its reason (#1593).
20425    #[test]
20426    fn a_failed_worker_with_a_gone_worktree_stays_in_the_listing() {
20427        let (workspace, _updates) = Workspace::testing_stub();
20428        let root = tempdir().expect("root");
20429        let path = root.path().to_string_lossy().into_owned();
20430        workspace.seed_test_project("proj-x", &path);
20431        let project_key = ProjectKey::new(
20432            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&path)),
20433        );
20434        let db_dir = tempdir().expect("db tempdir");
20435        workspace.install_db_for_test(
20436            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
20437        );
20438        workspace
20439            .record_worker_row(
20440                &project_key,
20441                "builder",
20442                "builder-session",
20443                "charter",
20444                None,
20445                None,
20446                false,
20447                crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
20448                None,
20449            )
20450            .expect("seed the worker's row");
20451        let session_key = SessionSlot::worker("TestOrg", "proj-x", "builder");
20452        workspace.insert_live_worker(
20453            &project_key,
20454            crate::mcp::workers::types::WorkerEntry {
20455                label: "builder".to_owned(),
20456                charter: "charter".to_owned(),
20457                slot: session_key.clone(),
20458                session_id: Some(forge_primitives::SessionId::new("builder-session")),
20459                status: forge_primitives::WorkerLiveness::Spawning,
20460                spawned_at: std::time::SystemTime::UNIX_EPOCH,
20461                spawned_by: SessionSlot::lead("TestOrg", "proj-x"),
20462                needs_tag: true,
20463                is_git_repo_at_spawn: true,
20464                cwd: Some(root.path().to_path_buf()),
20465                diagnostic: None,
20466                kick: None,
20467            },
20468        );
20469
20470        assert!(workspace.fail_worker_without_worktree(&session_key));
20471        assert!(
20472            workspace
20473                .worker_labels_by_project()
20474                .get(&project_key)
20475                .is_some_and(|labels| labels.iter().any(|label| label == "builder")),
20476            "the failed row stays listed, so its reason is drawable",
20477        );
20478    }
20479
20480    /// Drive a non-NotFound tag failure over a seeded row and live worker,
20481    /// and return the row that survived it. `needs_tag` and `minted_row`
20482    /// are the two facts the rollback decides the row on; the malformed
20483    /// session id is the failure, since no amount of waiting puts a JSONL
20484    /// on disk that makes that write succeed.
20485    async fn non_notfound_rollback_leftover_row(
20486        needs_tag: bool,
20487        minted_row: bool,
20488    ) -> Option<crate::store::sessions::SessionRecord> {
20489        let (workspace, mut rx) = Workspace::testing_stub();
20490        workspace.seed_test_project("proj-x", "/tmp/proj-x");
20491        let db_dir = tempdir().expect("db tempdir");
20492        workspace.install_db_for_test(
20493            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
20494        );
20495        let project_key = ProjectKey::new(
20496            forge_agent::userdata::catalog::scan::project_key_for_directory(Some("/tmp/proj-x")),
20497        );
20498        let session_key = SessionSlot::worker("TestOrg", "proj-x", "crashed");
20499        workspace.insert_live_worker(
20500            &project_key,
20501            fake_spawning_entry("crashed", &session_key, needs_tag),
20502        );
20503        workspace
20504            .record_worker_row(
20505                &project_key,
20506                "crashed",
20507                "crashed-uuid",
20508                "charter",
20509                Some("kick"),
20510                None,
20511                false,
20512                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
20513                None,
20514            )
20515            .expect("seed the row the rollback judges");
20516
20517        let cfg = tempdir().expect("cfg");
20518        let cwd = tempdir().expect("cwd");
20519        workspace.apply_worker_tag_or_rollback_with_config_dir(
20520            &session_key,
20521            "not-a-uuid",
20522            &project_key,
20523            "crashed",
20524            &cwd.path().to_string_lossy(),
20525            false,
20526            needs_tag,
20527            minted_row,
20528            cfg.path(),
20529        );
20530
20531        loop {
20532            let frame = rx.recv().await.expect("the rollback emits an update");
20533            if let SessionUpdate::WorkerStatusChanged { action, status, .. } =
20534                std::sync::Arc::unwrap_or_clone(frame.update)
20535                && action == crate::protocol::WorkerStatusAction::Removed
20536            {
20537                assert_eq!(status.label, "crashed", "the rolled-back worker is the one removed");
20538                break;
20539            }
20540        }
20541        assert!(
20542            workspace.list_live_workers(&project_key).is_empty(),
20543            "the rollback removes the live entry whatever becomes of the row",
20544        );
20545
20546        let db = workspace.db.lock();
20547        crate::store::sessions::get(db.as_ref().expect("db"), "TestOrg", "proj-x", "crashed")
20548            .expect("read the row the rollback left")
20549    }
20550
20551    /// The non-NotFound rollback discards a spawn that minted its own row,
20552    /// so the row goes with it: left behind, it is a row with no live
20553    /// worker - which `agents__despawn` cannot clear - and the next boot
20554    /// re-spawns the worker this arm just rolled back (#1142).
20555    #[tokio::test]
20556    async fn a_non_notfound_tag_failure_rolls_back_the_row_with_the_worker() {
20557        assert!(
20558            non_notfound_rollback_leftover_row(true, true).await.is_none(),
20559            "a spawn that wrote the row takes it with it",
20560        );
20561    }
20562
20563    /// A resume and a boot re-spawn are handed a row that pre-existed and
20564    /// holds the worker's charter, kick and the id being resumed. Taking
20565    /// it away loses a worker that only failed to write a JSONL tag, and
20566    /// leaves nothing to resume it from.
20567    #[tokio::test]
20568    async fn a_non_notfound_tag_failure_keeps_a_row_this_spawn_did_not_mint() {
20569        assert_eq!(
20570            non_notfound_rollback_leftover_row(true, false).await.and_then(|row| row.charter),
20571            Some("charter".to_owned()),
20572            "a row this spawn did not write is not this spawn's to delete",
20573        );
20574    }
20575
20576    /// The arm also fires on every `/new` re-tag, over a row whose worker
20577    /// has already been tagged on disk. That row belongs to the worker,
20578    /// not to the connection that failed to re-tag it.
20579    #[tokio::test]
20580    async fn a_non_notfound_tag_failure_keeps_a_row_the_worker_already_holds() {
20581        assert_eq!(
20582            non_notfound_rollback_leftover_row(false, true).await.and_then(|row| row.charter),
20583            Some("charter".to_owned()),
20584            "a worker already tagged on disk keeps its row when a later tag write fails",
20585        );
20586    }
20587
20588    /// `apply_worker_tag_or_rollback_with_config_dir`: when the JSONL
20589    /// exists from the start, the retry succeeds on the first attempt,
20590    /// the worker transitions to Running, and `needs_tag` is cleared.
20591    #[tokio::test]
20592    async fn jsonl_present_clears_needs_tag() {
20593        let (workspace, mut rx) = Workspace::testing_stub();
20594        // The tag row names its project, so the fixture needs one
20595        // configured.
20596        workspace.seed_test_project("forge", "/tmp/forge");
20597        let project_key = workspace.project_key_for_name("forge").expect("seeded project");
20598        let session_id = "550e8400-e29b-41d4-a716-446655440011";
20599        let session_key = SessionSlot::worker("TestOrg", "forge", "prompt-driven");
20600        workspace.insert_live_worker(
20601            &project_key,
20602            fake_spawning_entry("prompt-driven", &session_key, true),
20603        );
20604
20605        let cfg = tempdir().expect("cfg");
20606        let cwd = tempdir().expect("cwd");
20607        let path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20608        fs::write(&path, "").expect("seed jsonl");
20609
20610        workspace.apply_worker_tag_or_rollback_with_config_dir(
20611            &session_key,
20612            session_id,
20613            &project_key,
20614            "prompt-driven",
20615            &cwd.path().to_string_lossy(),
20616            false,
20617            true,
20618            true,
20619            cfg.path(),
20620        );
20621
20622        // Wait for the StatusChanged emit.
20623        tokio::time::timeout(Duration::from_secs(2), async {
20624            while let Some(frame) = rx.recv().await {
20625                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20626                    std::sync::Arc::unwrap_or_clone(frame.update)
20627                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20628                {
20629                    break;
20630                }
20631            }
20632        })
20633        .await
20634        .expect("StatusChanged emit within budget");
20635
20636        let entries = workspace.list_live_workers(&project_key);
20637        assert_eq!(entries.len(), 1);
20638        assert!(!entries[0].needs_tag, "needs_tag cleared on successful tag-write");
20639        assert!(matches!(entries[0].status, forge_primitives::WorkerLiveness::Running));
20640        let body = fs::read_to_string(&path).expect("read jsonl");
20641        assert!(body.contains("\"tag\":\"forge:worker:prompt-driven\""));
20642    }
20643
20644    /// `retry_worker_tag_opportunistic_with_config_dir` on a worker
20645    /// whose `needs_tag` is true: when the JSONL is present, the
20646    /// retry succeeds, the flag is cleared, and a single
20647    /// `StatusChanged` event fires.
20648    #[tokio::test]
20649    async fn opportunistic_retry_clears_flag_when_jsonl_appears() {
20650        let (workspace, mut rx) = Workspace::testing_stub();
20651        // The tag row names its project, so the fixture needs one
20652        // configured.
20653        workspace.seed_test_project("forge", "/tmp/forge");
20654        let project_key = workspace.project_key_for_name("forge").expect("seeded project");
20655        let session_id = "550e8400-e29b-41d4-a716-446655440012";
20656        let session_key = SessionSlot::worker("TestOrg", "forge", "idle");
20657        // Pre-state: worker is Running but needs_tag=true (it landed
20658        // here via the deferred-NotFound branch earlier).
20659        let mut entry = fake_spawning_entry("idle", &session_key, true);
20660        entry.status = forge_primitives::WorkerLiveness::Running;
20661        workspace.insert_live_worker(&project_key, entry);
20662
20663        let cfg = tempdir().expect("cfg");
20664        let cwd = tempdir().expect("cwd");
20665        let path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20666        fs::write(&path, "").expect("seed jsonl (claude has now written one)");
20667
20668        workspace.retry_worker_tag_opportunistic_with_config_dir(
20669            &project_key,
20670            &session_key,
20671            session_id,
20672            "idle",
20673            &cwd.path().to_string_lossy(),
20674            false,
20675            cfg.path(),
20676        );
20677
20678        tokio::time::timeout(Duration::from_secs(2), async {
20679            while let Some(frame) = rx.recv().await {
20680                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20681                    std::sync::Arc::unwrap_or_clone(frame.update)
20682                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20683                {
20684                    break;
20685                }
20686            }
20687        })
20688        .await
20689        .expect("StatusChanged within budget");
20690
20691        let entries = workspace.list_live_workers(&project_key);
20692        assert!(!entries[0].needs_tag, "needs_tag cleared on opportunistic retry success");
20693        let body = fs::read_to_string(&path).expect("read jsonl");
20694        assert!(body.contains("\"tag\":\"forge:worker:idle\""));
20695    }
20696
20697    /// #166 regression: when a worker session hits /new, the new
20698    /// session_id needs its own tag row written to its JSONL.
20699    /// `session_task::translate_event` now calls
20700    /// `apply_worker_tag_or_rollback` on EVERY Connected (not just
20701    /// the first), so the post-/new JSONL gets tagged in lockstep
20702    /// with the entry's session_key migration. Without this, the
20703    /// post-/new transcript carries no `forge:worker` tag, so the boot
20704    /// scan lists it as one of the project's own sessions.
20705    ///
20706    /// Workspace-level test: simulate the /new flow by calling the
20707    /// tagger twice, swapping the WorkerEntry's occupant between calls.
20708    /// Verify both session_ids' JSONLs land with the tag. The slot does
20709    /// not move across `/new` - only the occupant the entry carries
20710    /// does - so both Connecteds name the one slot.
20711    #[tokio::test]
20712    async fn worker_tag_re_applied_after_new_session_rekey() {
20713        let (workspace, mut rx) = Workspace::testing_stub();
20714        // The tag row names its project, so the fixture needs one
20715        // configured.
20716        workspace.seed_test_project("forge", "/tmp/forge");
20717        let project_key = workspace.project_key_for_name("forge").expect("seeded project");
20718
20719        // Two distinct session_ids: first Connected, then post-/new.
20720        let session_id_1 = "550e8400-e29b-41d4-a716-446655440021";
20721        let session_id_2 = "550e8400-e29b-41d4-a716-446655440022";
20722        let key_1 = SessionSlot::worker("TestOrg", "forge", "reviewer");
20723
20724        // Seed the WorkerEntry at the slot the worker was spawned
20725        // under, which `/new` leaves alone.
20726        workspace.insert_live_worker(&project_key, fake_spawning_entry("reviewer", &key_1, true));
20727
20728        let cfg = tempdir().expect("cfg");
20729        let cwd = tempdir().expect("cwd");
20730        let path_1 = jsonl_path_for(cfg.path(), cwd.path(), session_id_1);
20731        fs::write(&path_1, "").expect("seed first jsonl");
20732
20733        // First Connected: tag the first session's JSONL.
20734        workspace.apply_worker_tag_or_rollback_with_config_dir(
20735            &key_1,
20736            session_id_1,
20737            &project_key,
20738            "reviewer",
20739            &cwd.path().to_string_lossy(),
20740            false,
20741            true,
20742            true,
20743            cfg.path(),
20744        );
20745        tokio::time::timeout(Duration::from_secs(2), async {
20746            while let Some(frame) = rx.recv().await {
20747                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20748                    std::sync::Arc::unwrap_or_clone(frame.update)
20749                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20750                {
20751                    break;
20752                }
20753            }
20754        })
20755        .await
20756        .expect("first StatusChanged within budget");
20757        let body_1 = fs::read_to_string(&path_1).expect("read first jsonl");
20758        assert!(
20759            body_1.contains("\"tag\":\"forge:worker:reviewer\""),
20760            "first Connected tags the first session's JSONL",
20761        );
20762
20763        // Simulate `/new`: the slot does not move, only the occupant
20764        // the registry entry carries does.
20765        {
20766            let mut workers = workspace.live_workers.lock();
20767            for entry in workers.values_mut().flatten() {
20768                if entry.slot == key_1 {
20769                    entry.session_id = Some(forge_primitives::SessionId::new(session_id_2));
20770                }
20771            }
20772        }
20773
20774        // Seed the second session's JSONL as if claude wrote it on
20775        // the first turn after /new.
20776        let path_2 = jsonl_path_for(cfg.path(), cwd.path(), session_id_2);
20777        fs::write(&path_2, "").expect("seed second jsonl");
20778
20779        // Second Connected (the /new flow). Without #166's fix the
20780        // tagger was never called; with the fix it fires
20781        // unconditionally on every Connected.
20782        workspace.apply_worker_tag_or_rollback_with_config_dir(
20783            &key_1,
20784            session_id_2,
20785            &project_key,
20786            "reviewer",
20787            &cwd.path().to_string_lossy(),
20788            false,
20789            true,
20790            true,
20791            cfg.path(),
20792        );
20793        tokio::time::timeout(Duration::from_secs(2), async {
20794            while let Some(frame) = rx.recv().await {
20795                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20796                    std::sync::Arc::unwrap_or_clone(frame.update)
20797                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20798                {
20799                    break;
20800                }
20801            }
20802        })
20803        .await
20804        .expect("second StatusChanged within budget");
20805
20806        let body_2 = fs::read_to_string(&path_2).expect("read second jsonl");
20807        assert!(
20808            body_2.contains("\"tag\":\"forge:worker:reviewer\""),
20809            "second Connected (post-/new) tags the new session's JSONL",
20810        );
20811
20812        // The first JSONL keeps its tag - re-tagging writes to the
20813        // new file, not the old one. This guards against accidental
20814        // first-JSONL overwrite during the re-tag flow.
20815        let body_1_after = fs::read_to_string(&path_1).expect("read first jsonl");
20816        assert!(
20817            body_1_after.contains("\"tag\":\"forge:worker:reviewer\""),
20818            "first session's tag survives the /new re-tag flow",
20819        );
20820    }
20821
20822    /// #184 regression: when a worker is spawned inside a git repo,
20823    /// claude's `--worktree <label>` forks the subprocess into
20824    /// `<repo>/.claude/worktrees/<label>/` and writes the session
20825    /// JSONL under THAT sanitised path, not the repo root. The
20826    /// tag-write path must follow the JSONL there.
20827    ///
20828    /// Before the fix, `apply_worker_tag_or_rollback_with_config_dir`
20829    /// passed the repo root to `tag_session_with_retry`, the lookup
20830    /// missed (different sanitised key), all 30 retries hit NotFound,
20831    /// and `needs_tag` stayed true forever. On forge restart the
20832    /// catalog scan found zero tagged worker JSONLs and every worker
20833    /// spawned fresh.
20834    ///
20835    /// Test breaks the existing fixture's "write path = lookup path"
20836    /// coupling: cwd argument is the repo root, but the JSONL is
20837    /// seeded at the worktree path's sanitised key.
20838    #[tokio::test]
20839    async fn git_repo_worker_tag_lands_under_worktree_path() {
20840        let (workspace, mut rx) = Workspace::testing_stub();
20841        // The tag row names its project, so the fixture needs one
20842        // configured.
20843        workspace.seed_test_project("repo", "/tmp/repo");
20844        let project_key = workspace.project_key_for_name("repo").expect("seeded project");
20845        let session_id = "550e8400-e29b-41d4-a716-446655440099";
20846        let session_key = SessionSlot::worker("TestOrg", "repo", "debugger");
20847
20848        // is_git_repo_at_spawn=true is the production shape that
20849        // triggers claude's --worktree fork.
20850        let mut entry = fake_spawning_entry("debugger", &session_key, true);
20851        entry.is_git_repo_at_spawn = true;
20852        workspace.insert_live_worker(&project_key, entry);
20853
20854        let cfg = tempdir().expect("cfg");
20855        let repo_root = tempdir().expect("repo");
20856        init_worker_worktree(repo_root.path(), "debugger");
20857        // JSONL lives at the WORKTREE path (matches claude's behaviour
20858        // under --worktree): <repo>/.claude/worktrees/<label>/.
20859        let worktree_path = repo_root.path().join(".claude/worktrees/debugger");
20860        let path = jsonl_path_for(cfg.path(), &worktree_path, session_id);
20861        fs::write(&path, "").expect("seed jsonl at worktree path");
20862
20863        // Caller passes the repo root (NOT the worktree path), matching
20864        // how forge sources the cwd from the project view. The wrapper
20865        // must compute the worktree-derived cwd internally when
20866        // is_git_repo_at_spawn is true.
20867        workspace.apply_worker_tag_or_rollback_with_config_dir(
20868            &session_key,
20869            session_id,
20870            &project_key,
20871            "debugger",
20872            &repo_root.path().to_string_lossy(),
20873            true,
20874            true,
20875            true,
20876            cfg.path(),
20877        );
20878
20879        tokio::time::timeout(Duration::from_secs(2), async {
20880            while let Some(frame) = rx.recv().await {
20881                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20882                    std::sync::Arc::unwrap_or_clone(frame.update)
20883                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20884                {
20885                    break;
20886                }
20887            }
20888        })
20889        .await
20890        .expect("StatusChanged within budget");
20891
20892        let entries = workspace.list_live_workers(&project_key);
20893        assert_eq!(entries.len(), 1, "worker stays in live_workers after successful tag");
20894        assert!(
20895            !entries[0].needs_tag,
20896            "needs_tag cleared, tag-write found the JSONL at the worktree path"
20897        );
20898        let body = fs::read_to_string(&path).expect("read jsonl at worktree path");
20899        assert!(
20900            body.contains("\"tag\":\"forge:worker:debugger\""),
20901            "tag row appended at the worktree-derived JSONL: {body:?}"
20902        );
20903    }
20904
20905    /// #184 regression for the opportunistic-retry path: the
20906    /// first-turn retry from `handle_deliver_worker_prompt` also
20907    /// needs to address the worktree-derived JSONL for git-repo
20908    /// workers. Sibling to `git_repo_worker_tag_lands_under_worktree_path`
20909    /// Same setup as the apply path, different entry point.
20910    #[tokio::test]
20911    async fn git_repo_worker_opportunistic_retry_uses_worktree_path() {
20912        let (workspace, mut rx) = Workspace::testing_stub();
20913        // The tag row names its project, so the fixture needs one
20914        // configured.
20915        workspace.seed_test_project("repo", "/tmp/repo");
20916        let project_key = workspace.project_key_for_name("repo").expect("seeded project");
20917        let session_id = "550e8400-e29b-41d4-a716-446655440100";
20918        let session_key = SessionSlot::worker("TestOrg", "repo", "debugger");
20919
20920        // Pre-state: worker is Running (the apply_* path already ran
20921        // and exhausted into DeferredNotFound). is_git_repo_at_spawn=true.
20922        let mut entry = fake_spawning_entry("debugger", &session_key, true);
20923        entry.status = forge_primitives::WorkerLiveness::Running;
20924        entry.is_git_repo_at_spawn = true;
20925        workspace.insert_live_worker(&project_key, entry);
20926
20927        let cfg = tempdir().expect("cfg");
20928        let repo_root = tempdir().expect("repo");
20929        let worktree_path = repo_root.path().join(".claude/worktrees/debugger");
20930        let path = jsonl_path_for(cfg.path(), &worktree_path, session_id);
20931        fs::write(&path, "").expect("seed jsonl at worktree path (claude has now written)");
20932
20933        workspace.retry_worker_tag_opportunistic_with_config_dir(
20934            &project_key,
20935            &session_key,
20936            session_id,
20937            "debugger",
20938            &repo_root.path().to_string_lossy(),
20939            true,
20940            cfg.path(),
20941        );
20942
20943        tokio::time::timeout(Duration::from_secs(2), async {
20944            while let Some(frame) = rx.recv().await {
20945                if let SessionUpdate::WorkerStatusChanged { action, .. } =
20946                    std::sync::Arc::unwrap_or_clone(frame.update)
20947                    && matches!(action, crate::protocol::WorkerStatusAction::StatusChanged)
20948                {
20949                    break;
20950                }
20951            }
20952        })
20953        .await
20954        .expect("StatusChanged within budget");
20955
20956        let entries = workspace.list_live_workers(&project_key);
20957        assert!(
20958            !entries[0].needs_tag,
20959            "opportunistic retry found the worktree-path JSONL; needs_tag cleared"
20960        );
20961        let body = fs::read_to_string(&path).expect("read jsonl at worktree path");
20962        assert!(
20963            body.contains("\"tag\":\"forge:worker:debugger\""),
20964            "tag row appended at worktree path: {body:?}"
20965        );
20966    }
20967}
20968
20969#[cfg(test)]
20970#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
20971mod worker_respawn_tests {
20972    use super::*;
20973    use crate::protocol::{Command, SessionChoice, WorkerSpawnReply};
20974
20975    /// The `demo` project's lead slot, which the fixtures in this module
20976    /// boot.
20977    fn lead_slot() -> SessionSlot {
20978        SessionSlot::lead("TestOrg", "demo")
20979    }
20980
20981    /// A live worker entry for the `demo` project, keyed by the slot the
20982    /// worker's spawn names - the triple `worker_lookup_for_session`
20983    /// matches on.
20984    fn worker_entry(label: &str) -> crate::mcp::workers::types::WorkerEntry {
20985        crate::mcp::workers::types::WorkerEntry {
20986            label: label.to_owned(),
20987            charter: "test".to_owned(),
20988            slot: SessionSlot::worker("TestOrg", "demo", label),
20989            session_id: None,
20990            status: forge_primitives::WorkerLiveness::Running,
20991            spawned_at: std::time::SystemTime::UNIX_EPOCH,
20992            spawned_by: lead_slot(),
20993            needs_tag: false,
20994            is_git_repo_at_spawn: false,
20995            cwd: None,
20996            diagnostic: None,
20997            kick: None,
20998        }
20999    }
21000
21001    /// Seed a project into the stub workspace and hand back the
21002    /// `LoadedProject` a spawn resolves it to.
21003    fn seed_project_and_return(ws: &Workspace, name: &str, path: &str) -> LoadedProject {
21004        ws.seed_test_project(name, path);
21005        ws.find_project_view_by_name(name).expect("seeded project")
21006    }
21007
21008    /// The tool names the per-session `forge` MCP server registers for
21009    /// `kind`, composed exactly as the spawn path composes them. Read
21010    /// off the server's debug listing rather than substring-searched in
21011    /// it, so a description naming a tool cannot answer for the
21012    /// registration.
21013    fn forge_tool_surface(
21014        workspace: &Arc<Workspace>,
21015        kind: crate::mcp::SessionKind,
21016    ) -> Vec<String> {
21017        let server = crate::mcp::build_forge_server(
21018            crate::mcp::ForgeServerFacades {
21019                workspace: crate::mcp::peers::facade::ProdWorkspaceFacade::from_arc(workspace),
21020                worker: crate::mcp::workers::facade::ProdWorkerFacade::from_arc(workspace),
21021                browser: crate::mcp::browser::facade::ProdBrowserFacade::from_workspace(workspace),
21022                terminal: crate::mcp::terminal::facade::ProdTerminalFacade::from_workspace(
21023                    workspace,
21024                ),
21025                documents: crate::mcp::documents::facade::ProdDocumentFacade::from_workspace(
21026                    workspace,
21027                ),
21028                review: crate::mcp::review::facade::ProdReviewFacade::from_arc(workspace),
21029                cron: crate::mcp::cron::facade::ProdCronFacade::from_arc(workspace),
21030                gotify: crate::mcp::gotify::facade::ProdGotifyFacade::from_arc(workspace),
21031                slack: crate::mcp::slack::facade::ProdSlackFacade::from_arc(workspace),
21032                settings: crate::mcp::settings::facade::ProdSettingsFacade::from_arc(workspace),
21033                vault: None,
21034                tasks: crate::mcp::tasks::facade::ProdTasksFacade::from_arc(workspace),
21035                voice: workspace.voice_service(),
21036                systemone: workspace.systemone.read().as_ref().map(|client| {
21037                    crate::mcp::systemone::facade::ProdSystemOneFacade::new(std::sync::Arc::clone(
21038                        client,
21039                    ))
21040                    .into_arc()
21041                }),
21042            },
21043            &crate::mcp::McpFamily::all(),
21044            &SessionSlot::from_str_for_test("caller"),
21045            kind,
21046        );
21047        let debug = format!("{server:?}");
21048        let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
21049        let (tools, _) = tools.split_once(']').expect("the tool list is closed");
21050        tools
21051            .split(", ")
21052            .map(|name| name.trim_matches('"').to_owned())
21053            .filter(|name| !name.is_empty())
21054            .collect()
21055    }
21056
21057    /// The guard refuses a second claim while the first is outstanding.
21058    /// Holding the claim directly is all this needs - the blocked branch
21059    /// is unreachable only when claim and release share one call, which
21060    /// is a property of the callers rather than of the guard.
21061    #[test]
21062    fn respawn_guard_refuses_a_second_claim() {
21063        let (ws, _rx) = Workspace::testing_stub();
21064        let key = ProjectKey::new("proj");
21065        assert!(ws.try_claim_respawn(&key), "an unclaimed guard grants");
21066        assert!(!ws.try_claim_respawn(&key), "a claimed guard refuses");
21067        ws.release_respawn(&key);
21068        assert!(ws.try_claim_respawn(&key), "release makes it claimable again");
21069    }
21070
21071    /// A worker must not receive the delegation block. It instructs the
21072    /// reader to call `agents__spawn`, which is lead-only, so a worker
21073    /// given it would be told to call a tool that refuses it. The lead
21074    /// half is the control: without it, a helper that did nothing at all
21075    /// would still satisfy the assertion above. The negative pin keeps
21076    /// the charter the sole carrier of the delegation default.
21077    #[test]
21078    fn only_a_lead_session_gets_the_delegation_block() {
21079        let mut worker = SessionLaunchSettings::default();
21080        Workspace::apply_lead_delegation(&mut worker, crate::mcp::SessionKind::Worker);
21081        assert_eq!(worker.delegation_preamble, None, "a worker gets no delegation block");
21082
21083        let mut lead = SessionLaunchSettings::default();
21084        Workspace::apply_lead_delegation(&mut lead, crate::mcp::SessionKind::Lead);
21085        let preamble = lead.delegation_preamble.expect("a lead does get it");
21086        assert!(
21087            preamble.contains("agents__spawn")
21088                && preamble.contains("always creates the worker in YOUR project")
21089                && preamble.contains("Workers build; subagents review"),
21090            "a lead does get it",
21091        );
21092        assert!(
21093            preamble.contains(
21094                "A work cut - one feature, one issue, one fix - lands whole: one branch, one \
21095                 review, one look, one merge. Never slice it into pieces that land one after \
21096                 another, and never show or merge an intermediate step on the way."
21097            ),
21098            "the handle and the ban, through the ban's period, are pinned together - the same \
21099             sentence the conduct block and the charter carry",
21100        );
21101        assert!(
21102            preamble.contains(
21103                "The look is the one that cut is owed: a feature waits for the user's own look \
21104                 before merge; a fix merges after the review loop as usual."
21105            ),
21106            "the look's scope is pinned",
21107        );
21108        assert!(
21109            preamble.contains(
21110                "A plan, spec or design the user must review reaches them through \
21111                 `documents__hand_off` with `reviewable: true`, never as a path in chat"
21112            ),
21113            "the document hand-off is how a reviewable plan reaches the user",
21114        );
21115        for (clause, why) in [
21116            (
21117                "Run independent pieces in parallel, up to the project's capacity",
21118                "the parallel-by-default headline",
21119            ),
21120            ("a merge conflict is not, by itself, a reason to hold a piece", "the conflict rule"),
21121            ("resolve the conflict and re-run the gates", "its remedy"),
21122            (
21123                "Only genuine shared-state ordering serializes: one in-flight migration \
21124                 sequence, main's merge queue, a release/version line",
21125                "the shared-state list",
21126            ),
21127            ("decide whether the options genuinely differ", "the dispatch cue's scope"),
21128            (
21129                "put the enumerated options and the context that bears on them to \
21130                 systemone__ask_choice when your tools include it",
21131                "the dispatch cue and its guard",
21132            ),
21133            ("without it decide it yourself", "the dispatch fallback"),
21134            (
21135                "Either way, report the decision where the work is reported",
21136                "the dispatch report duty",
21137            ),
21138        ] {
21139            assert!(
21140                preamble.contains(clause),
21141                "the delegation preamble does not carry the parallel rule ({why}): {clause}"
21142            );
21143        }
21144        assert!(
21145            !preamble.contains("doing the work yourself"),
21146            "the charter, not this block, carries the delegation default",
21147        );
21148        // The preamble is lead-facing text, and a lead's launch denies the
21149        // replaced CLI tools: naming one here sends the lead to a surface
21150        // it does not have. The charter carries the same test in `spawn`.
21151        for tool in [
21152            "CronCreate",
21153            "CronDelete",
21154            "CronList",
21155            "TaskCreate",
21156            "TaskGet",
21157            "TaskList",
21158            "TaskUpdate",
21159            "SendMessage",
21160            "ListAgents",
21161            "Workflow",
21162            "RemoteTrigger",
21163        ] {
21164            assert!(
21165                !preamble.contains(tool),
21166                "the delegation preamble must not name the blocked CLI tool {tool}",
21167            );
21168        }
21169    }
21170
21171    /// A respawn rebuilds both of a lead's blocks. They are stamped at spawn
21172    /// and the store holds no charter for a lead row, so a `/new`, a
21173    /// `/resume` or a handoff used to hand back a lead with neither its
21174    /// mission nor its delegation rules - and a handoff that quietly did
21175    /// that is the bug this pins. A worker's respawn gets neither: its
21176    /// charter comes from its own stored row.
21177    #[test]
21178    fn a_respawn_rebuilds_a_leads_charter_and_preamble() {
21179        let (ws, _rx) = Workspace::testing_stub();
21180        let lead = SessionSlot::lead("TestOrg", "forge");
21181        let mut settings = SessionLaunchSettings::default();
21182        ws.stamp_respawn_overrides(&lead, "new-id", &mut settings);
21183        assert!(
21184            settings.charter.as_deref().is_some_and(|charter| charter.contains("engineering lead")),
21185            "the lead's mission survives the respawn: {:?}",
21186            settings.charter,
21187        );
21188        assert!(settings.delegation_preamble.is_some(), "and so do its delegation rules");
21189
21190        let worker = SessionSlot::worker("TestOrg", "forge", "handoff-cut");
21191        let mut worker_settings = SessionLaunchSettings::default();
21192        ws.stamp_respawn_overrides(&worker, "new-id", &mut worker_settings);
21193        assert!(worker_settings.charter.is_none(), "a worker's charter is its own row's");
21194        assert!(
21195            worker_settings.delegation_preamble.is_none(),
21196            "and a worker is never given the lead-only block",
21197        );
21198    }
21199
21200    /// The role a spawn carries is the role it gets, and the slot's label
21201    /// comes from the same value - so a worker cannot be handed a worker's
21202    /// tool surface AND the lead's address. A worker re-spawned by the
21203    /// boot resume path was classified as Lead while the key's shape was
21204    /// the only signal, which hands it the lead-only `agents__*` group; the
21205    /// caller that knows the row is a worker now says so.
21206    #[test]
21207    fn a_worker_spawn_carries_its_label_and_a_worker_tool_surface() {
21208        let (ws, _rx) = Workspace::testing_stub();
21209        let project = seed_project_and_return(&ws, "forge", "/tmp/role-worker");
21210        let role = crate::protocol::SpawnRole::Worker {
21211            label: "implementer".to_owned(),
21212            wrote_row: true,
21213            mcp_families: None,
21214        };
21215
21216        let slot = Workspace::slot_for_spawn(&role, &project);
21217        assert_eq!(slot.label(), "implementer", "the slot names the worker");
21218        let kind = if slot.is_lead() {
21219            crate::mcp::SessionKind::Lead
21220        } else {
21221            crate::mcp::SessionKind::Worker
21222        };
21223        assert!(
21224            !forge_tool_surface(&ws, kind).contains(&"agents__spawn".to_owned()),
21225            "and a worker's forge server carries no lead-only verb",
21226        );
21227    }
21228
21229    /// Controls for [`a_worker_spawn_carries_its_label_and_a_worker_tool_surface`]:
21230    /// without a case that answers Lead, a derivation answering Worker for
21231    /// everything would satisfy it. The shape cases the old prefix test
21232    /// read are unreachable here by construction - `slot_for_spawn` takes
21233    /// no key, so a parse cannot be reintroduced without a signature
21234    /// change - which is the stronger form of that pin.
21235    #[test]
21236    fn a_lead_spawn_carries_no_label() {
21237        let (ws, _rx) = Workspace::testing_stub();
21238        let project = seed_project_and_return(&ws, "forge", "/tmp/role-lead");
21239
21240        let slot = Workspace::slot_for_spawn(&crate::protocol::SpawnRole::Lead, &project);
21241        assert!(slot.is_lead(), "a lead spawn carries the lead label");
21242    }
21243
21244    /// A tool surface for each kind, which is what the role gates: this is
21245    /// the lead half of the pair above, and it is the control that stops a
21246    /// derivation answering Worker for everything from satisfying the
21247    /// worker case while stripping the lead-only verbs from every lead.
21248    #[test]
21249    fn a_lead_tool_surface_keeps_its_lead_only_verbs() {
21250        let (ws, _rx) = Workspace::testing_stub();
21251        assert!(
21252            forge_tool_surface(&ws, crate::mcp::SessionKind::Lead)
21253                .contains(&"agents__spawn".to_owned()),
21254            "a lead keeps the verbs only a lead may call",
21255        );
21256    }
21257
21258    #[test]
21259    fn force_new_respawn_dispatches_workers_fresh() {
21260        // A force-new lead came up fresh, so its workers do too: every
21261        // one dispatches with resume_existing = None.
21262        let (workspace, _update_rx) = Workspace::testing_stub();
21263        let dir = tempfile::tempdir().expect("tempdir");
21264        workspace.install_db_for_test(
21265            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
21266        );
21267        workspace.seed_test_project("data-modules", "/tmp/force-new-data-modules");
21268        let key = workspace.project_key_for_name("data-modules").expect("seeded project");
21269        seed_worker_row(&workspace, &key, "steward");
21270        workspace.enable_test_dispatch_intercept();
21271        workspace.respawn_workers_for_lead(
21272            &SessionSlot::from_str_for_test("lead-uuid"),
21273            key,
21274            true, // force_new: the workers come up fresh, like their lead
21275        );
21276
21277        let dispatched = workspace.drain_test_dispatch_buffer();
21278        let spawns: Vec<&Command> =
21279            dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
21280        assert_eq!(spawns.len(), 1, "force-new still spawns the stored worker");
21281        if let Command::SpawnWorker { resume_existing, .. } = spawns[0] {
21282            assert!(resume_existing.is_none(), "force_new => worker spawns fresh (no resume)");
21283        }
21284    }
21285
21286    /// A stub carrying one `proj-x` project (org `TestOrg`, path
21287    /// `/tmp/proj-x`) over an empty store, plus the key that project
21288    /// resolves under. The store is what the re-spawn wave reads, so a
21289    /// test expecting a resume writes the label's row through
21290    /// `record_session_id`. The tempdir must outlive the caller.
21291    fn stub_with_worker_store() -> (Arc<Workspace>, ProjectKey, tempfile::TempDir) {
21292        let (workspace, _rx) = Workspace::testing_stub();
21293        workspace.seed_test_project("proj-x", "/tmp/proj-x");
21294        let dir = tempfile::tempdir().expect("tempdir");
21295        workspace.install_db_for_test(
21296            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
21297        );
21298        let key = ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(
21299            Some("/tmp/proj-x"),
21300        ));
21301        (workspace, key, dir)
21302    }
21303
21304    /// A `sessions` row for a `proj-x` worker: the shape the re-spawn
21305    /// wave reads. `session_id` is absent unless a test writes one
21306    /// through `record_session_id`, which is what makes the row resume
21307    /// rather than re-deliver its kick.
21308    fn worker_row(label: &str, kick: Option<&str>) -> crate::store::sessions::SessionRecord {
21309        crate::store::sessions::SessionRecord {
21310            org: "TestOrg".to_owned(),
21311            project: "proj-x".to_owned(),
21312            label: label.to_owned(),
21313            session_id: None,
21314            charter: Some(format!("dynamic charter for {label}")),
21315            kick: kick.map(str::to_owned),
21316            resume_kick: None,
21317            interactive: Some(false),
21318            seat_state: None,
21319            is_git_repo: None,
21320            mcp_families: None,
21321            cwd: None,
21322        }
21323    }
21324
21325    /// `proj-x` is a real git repo and `proj-y` is not, so the four
21326    /// seeded rows below cut across the two axes the skip must not
21327    /// confuse: whether the ROW says its worker runs in a worktree, and
21328    /// whether the project path looks like a repo on disk. The two
21329    /// disagreeing rows are the point - a predicate inferring gitness
21330    /// from the filesystem gets both of them wrong.
21331    fn seed_worktree_fixture(
21332        workspace: &Arc<Workspace>,
21333    ) -> (ProjectKey, ProjectKey, tempfile::TempDir, tempfile::TempDir) {
21334        let repo = tempfile::tempdir().expect("git project dir");
21335        run_git_in(repo.path(), &["init", "-q"]);
21336        let plain = tempfile::tempdir().expect("non-git project dir");
21337        workspace.seed_test_project("proj-x", repo.path().to_str().expect("utf8 repo path"));
21338        workspace.seed_test_project("proj-y", plain.path().to_str().expect("utf8 plain path"));
21339        // Only `present`'s worktree stands; the other three are gone.
21340        std::fs::create_dir_all(repo.path().join(".claude").join("worktrees").join("present"))
21341            .expect("create the surviving worktree");
21342
21343        let proj_x = workspace.project_key_for_name("proj-x").expect("seeded project");
21344        let proj_y = workspace.project_key_for_name("proj-y").expect("seeded project");
21345        let seeded: [(&ProjectKey, &str, bool); 4] = [
21346            // A git worker whose worktree is gone: the defect.
21347            (&proj_x, "stranded", true),
21348            // Its sibling whose worktree stands.
21349            (&proj_x, "present", true),
21350            // Runs in the project root, so it has no worktree to lose -
21351            // even though the project path IS a repo with a `.git`.
21352            (&proj_x, "rooted", false),
21353            // Says it runs in a worktree, in a project with no `.git`
21354            // anywhere. The row decides, not the disk.
21355            (&proj_y, "ghostworktree", true),
21356        ];
21357        for (key, label, is_git) in seeded {
21358            workspace
21359                .record_worker_row(
21360                    key,
21361                    label,
21362                    &format!("{label}-uuid"),
21363                    "c",
21364                    None,
21365                    None,
21366                    false,
21367                    crate::mcp::workers::types::WorkerTree { is_git_repo: is_git, cwd: None },
21368                    None,
21369                )
21370                .expect("seed the row this wave re-spawns");
21371        }
21372        // A row whose spawn never got an id. The wave re-spawns it FRESH,
21373        // which runs in the project root and passes `--worktree`, so
21374        // claude creates the worktree itself and there is nothing missing
21375        // for it to fail on.
21376        let guard = workspace.db.lock();
21377        crate::store::sessions::put(
21378            guard.as_ref().expect("db installed"),
21379            &crate::store::sessions::SessionRecord {
21380                org: "TestOrg".to_owned(),
21381                project: "proj-x".to_owned(),
21382                label: "unstarted".to_owned(),
21383                session_id: None,
21384                charter: Some("c".to_owned()),
21385                kick: None,
21386                resume_kick: None,
21387                interactive: Some(false),
21388                seat_state: None,
21389                is_git_repo: Some(true),
21390                mcp_families: None,
21391                cwd: None,
21392            },
21393        )
21394        .expect("seed the id-less row");
21395        drop(guard);
21396        (proj_x, proj_y, repo, plain)
21397    }
21398
21399    /// A worker whose worktree is gone is re-spawned on every boot and
21400    /// fails every time, forever. The wave skips it, and only it.
21401    #[test]
21402    fn boot_respawn_skips_a_row_whose_worktree_is_gone() {
21403        let (workspace, _rx) = Workspace::testing_stub();
21404        workspace.enable_test_dispatch_intercept();
21405        let db_dir = tempfile::tempdir().expect("db dir");
21406        workspace.install_db_for_test(
21407            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
21408        );
21409        let (proj_x, proj_y, _repo, _plain) = seed_worktree_fixture(&workspace);
21410
21411        for key in [&proj_x, &proj_y] {
21412            // The wave reads the rows the store holds, not a hand-made
21413            // shape, so this also pins that the row carries the flag.
21414            let rows = workspace.worker_rows_for_project(key);
21415            workspace.dispatch_worker_respawns(&lead_slot(), key, &rows, false);
21416        }
21417
21418        let spawned: Vec<String> = workspace
21419            .drain_test_dispatch_buffer()
21420            .into_iter()
21421            .map(|cmd| match cmd {
21422                Command::SpawnWorker { label, .. } => label,
21423                other => panic!("expected SpawnWorker, got {other:?}"),
21424            })
21425            .collect();
21426        assert!(
21427            !spawned.contains(&"stranded".to_owned()),
21428            "a worker whose worktree is gone must not be re-spawned - the spawn cannot \
21429             enter its cwd and would fail on every boot forever; spawned {spawned:?}",
21430        );
21431        assert!(
21432            !spawned.contains(&"ghostworktree".to_owned()),
21433            "the skip must follow the row's recorded gitness, not a `.git` on disk: \
21434             this row says worktree and the project path has no repo; spawned {spawned:?}",
21435        );
21436        assert!(
21437            spawned.contains(&"present".to_owned()),
21438            "a worker whose worktree stands must still re-spawn; spawned {spawned:?}",
21439        );
21440        assert!(
21441            spawned.contains(&"rooted".to_owned()),
21442            "a worker whose row says it runs in the project root must still re-spawn, \
21443             even though that project holds a `.git`; spawned {spawned:?}",
21444        );
21445        assert!(
21446            spawned.contains(&"unstarted".to_owned()),
21447            "a row with no stored id re-spawns FRESH and creates its own worktree, so \
21448             the missing one must not strand it; spawned {spawned:?}",
21449        );
21450
21451        // Skipping is not deleting. The row is the only handle on the
21452        // worker, so a restored worktree is what brings it back.
21453        let kept = |key: &ProjectKey| -> Vec<String> {
21454            workspace.worker_rows_for_project(key).into_iter().map(|r| r.label).collect()
21455        };
21456        assert!(
21457            kept(&proj_x).contains(&"stranded".to_owned())
21458                && kept(&proj_y).contains(&"ghostworktree".to_owned()),
21459            "a skipped row must stay in the store, or the restore path this promises is \
21460             gone; kept {:?} / {:?}",
21461            kept(&proj_x),
21462            kept(&proj_y),
21463        );
21464    }
21465
21466    /// The launchpad renders every persisted worker row, so a row the
21467    /// wave skips would still be offered as a worker - which is the
21468    /// visible half of the same defect. It must not reach the pane.
21469    ///
21470    /// Only the skips are asserted here. Both sites call
21471    /// `worker_row_can_start`, so an over-skip regression fails the boot
21472    /// wave's test above; what this one pins is that this site calls the
21473    /// predicate at all.
21474    #[test]
21475    fn launchpad_does_not_offer_a_row_whose_worktree_is_gone() {
21476        let (workspace, _rx) = Workspace::testing_stub();
21477        let db_dir = tempfile::tempdir().expect("db dir");
21478        workspace.install_db_for_test(
21479            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
21480        );
21481        let (proj_x, _proj_y, _repo, _plain) = seed_worktree_fixture(&workspace);
21482
21483        let offered = workspace.worker_labels_by_project();
21484        let labels = offered.get(&proj_x).cloned().unwrap_or_default();
21485        assert!(
21486            !labels.contains(&"stranded".to_owned()),
21487            "a worker whose worktree is gone must not be offered by the launchpad; \
21488             offered {labels:?}",
21489        );
21490        assert!(
21491            !labels.contains(&"ghostworktree".to_owned()),
21492            "the launchpad must read the row's gitness, not the project's filesystem; \
21493             offered {labels:?}",
21494        );
21495    }
21496
21497    /// The interactive flag rides the subprocess CLI args, so a
21498    /// re-spawn that dropped it would take `AskUserQuestion` away from
21499    /// a worker mid-conversation on the first forge restart - and give
21500    /// it to one that was never meant to have it. The family selection
21501    /// rides the same row for the same reason.
21502    #[test]
21503    fn dispatch_worker_respawns_carries_interactive_from_the_row() {
21504        let (workspace, _update_rx) = Workspace::testing_stub();
21505        workspace.enable_test_dispatch_intercept();
21506        workspace.seed_test_project("proj-x", "/tmp/proj-x");
21507        let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
21508        let mut talkative = worker_row("talkative", None);
21509        talkative.interactive = Some(true);
21510        talkative.mcp_families = Some(vec!["cron".to_owned()]);
21511        let dynamic = vec![talkative, worker_row("quiet", None)];
21512
21513        workspace.dispatch_worker_respawns(
21514            &SessionSlot::from_str_for_test("new-lead"),
21515            &project_key,
21516            &dynamic,
21517            false,
21518        );
21519
21520        for cmd in workspace.drain_test_dispatch_buffer() {
21521            let Command::SpawnWorker {
21522                label, interactive, from_boot_respawn, mcp_families, ..
21523            } = cmd
21524            else {
21525                panic!("expected SpawnWorker");
21526            };
21527            assert!(
21528                from_boot_respawn,
21529                "a boot re-spawn must stay exempt from the worker cap; flipping this \
21530                 strands persisted rows on restart with the refusal dropped unheard"
21531            );
21532            match label.as_str() {
21533                "talkative" => {
21534                    assert!(interactive, "an interactive row re-spawns interactive");
21535                    assert_eq!(
21536                        mcp_families,
21537                        Some(vec!["cron".to_owned()]),
21538                        "the row's family selection rides the re-spawn",
21539                    );
21540                }
21541                "quiet" => {
21542                    assert!(!interactive, "a non-interactive row re-spawns non-interactive");
21543                    assert_eq!(mcp_families, None, "a row naming none re-spawns unnarrowed");
21544                }
21545                other => panic!("unexpected label {other}"),
21546            }
21547        }
21548    }
21549
21550    /// Re-spawn resumes by the id the store holds: a worker whose label
21551    /// has one resumes and takes the forge restart note as its kick; one
21552    /// without re-delivers its stored kick.
21553    #[test]
21554    fn dispatch_worker_respawns_kicks_only_what_was_left_mid_turn() {
21555        use crate::store::sessions::SeatState;
21556
21557        let (workspace, project_key, _dir) = stub_with_worker_store();
21558        workspace.enable_test_dispatch_intercept();
21559        let mut dynamic = vec![
21560            worker_row("reviewer", Some("original kick")),
21561            worker_row("scratch", Some("start scratch")),
21562            worker_row("idle", None),
21563            worker_row("parked", Some("never delivered")),
21564        ];
21565        // The reviewer was mid-turn when forge stopped; the parked worker
21566        // was not, so its resume must deliver nothing.
21567        dynamic[0].seat_state = Some(SeatState::InTurn);
21568        dynamic[3].seat_state = Some(SeatState::Idle);
21569        workspace.record_session_id("TestOrg", "proj-x", "reviewer", "reviewer-uuid");
21570        workspace.record_session_id("TestOrg", "proj-x", "parked", "parked-uuid");
21571
21572        workspace.dispatch_worker_respawns(
21573            &SessionSlot::from_str_for_test("new-lead"),
21574            &project_key,
21575            &dynamic,
21576            false,
21577        );
21578
21579        let dispatched = workspace.drain_test_dispatch_buffer();
21580        assert_eq!(dispatched.len(), 4, "one SpawnWorker per persisted dynamic worker");
21581        for cmd in dispatched {
21582            let Command::SpawnWorker {
21583                label,
21584                charter,
21585                resume_existing,
21586                kick,
21587                spawned_by,
21588                project_key: pk,
21589                ..
21590            } = cmd
21591            else {
21592                panic!("expected SpawnWorker");
21593            };
21594            assert_eq!(
21595                spawned_by,
21596                SessionSlot::from_str_for_test("new-lead"),
21597                "re-parented to the current lead's slot",
21598            );
21599            assert_eq!(pk, project_key);
21600            assert_eq!(charter, format!("dynamic charter for {label}"), "charter from the DB row");
21601            match label.as_str() {
21602                "reviewer" => {
21603                    assert_eq!(
21604                        resume_existing.as_deref(),
21605                        Some("reviewer-uuid"),
21606                        "a stored id resumes"
21607                    );
21608                    assert_eq!(
21609                        kick.as_deref(),
21610                        Some(DYNAMIC_WORKER_RESTART_NOTE),
21611                        "resume delivers the forge restart note, not the stored kick",
21612                    );
21613                }
21614                "scratch" => {
21615                    assert!(resume_existing.is_none(), "no stored id -> fresh");
21616                    assert_eq!(
21617                        kick.as_deref(),
21618                        Some("start scratch"),
21619                        "fresh re-delivers the stored kick"
21620                    );
21621                }
21622                "idle" => {
21623                    assert!(resume_existing.is_none());
21624                    assert!(kick.is_none(), "fresh with no stored kick stays kickless");
21625                }
21626                "parked" => {
21627                    assert_eq!(
21628                        resume_existing.as_deref(),
21629                        Some("parked-uuid"),
21630                        "a stored id resumes",
21631                    );
21632                    assert!(
21633                        kick.is_none(),
21634                        "a worker that was idle when it stopped is resumed with nothing \
21635                         delivered; its kick stays on the row for a future fresh spawn",
21636                    );
21637                }
21638                other => panic!("unexpected label {other}"),
21639            }
21640        }
21641    }
21642
21643    /// The restart note is the first turn a resumed worker reads, and a
21644    /// brief in its history can carry an earlier loss's clock. Pinned so the
21645    /// note names the clock and the schedule list as the authority.
21646    #[test]
21647    fn the_restart_note_marks_a_briefs_timestamps_unverified() {
21648        assert!(
21649            DYNAMIC_WORKER_RESTART_NOTE.contains(
21650                "If the history's timestamps disagree with the clock and the live schedules"
21651            ),
21652            "the note sends a resumed worker to the clock and the schedules",
21653        );
21654        assert!(
21655            DYNAMIC_WORKER_RESTART_NOTE.contains("believe the clock and the schedules"),
21656            "the note names which side wins, not only that they may disagree",
21657        );
21658        assert!(
21659            DYNAMIC_WORKER_RESTART_NOTE.contains("still do the concrete asks"),
21660            "the note keeps the brief's asks, which are usually right",
21661        );
21662    }
21663
21664    /// The lead restart note is the first turn a boot-resumed lead reads.
21665    /// Pinned so it sends the lead to the handoff and the board before it
21666    /// acts, keeps the history conditional (a refused resume falls back to
21667    /// a fresh one), warns that a message sent before the restart may have
21668    /// been lost, and goes to the clock over a stale history.
21669    #[test]
21670    fn the_lead_restart_note_names_the_handoff_the_board_and_the_clock() {
21671        assert!(LEAD_RESTART_NOTE.contains("forge restarted"), "the note says what happened");
21672        assert!(
21673            LEAD_RESTART_NOTE.contains("handoff.md` in the project's memory directory"),
21674            "the note names where the handoff lives, not only its filename",
21675        );
21676        assert!(
21677            LEAD_RESTART_NOTE.contains("task board before acting"),
21678            "the note sends the lead to the board before it acts",
21679        );
21680        assert!(
21681            LEAD_RESTART_NOTE.contains("they say what was in flight"),
21682            "the note names the handoff and the board as the source, not the history",
21683        );
21684        assert!(
21685            LEAD_RESTART_NOTE.contains("Continue that work and do not restart what is finished"),
21686            "the note tells the lead to carry on and not redo finished work",
21687        );
21688        assert!(
21689            LEAD_RESTART_NOTE.contains("treat it as the record of this seat's last run"),
21690            "a history that IS present is named as this seat's own record",
21691        );
21692        assert!(
21693            LEAD_RESTART_NOTE.contains("If a prior conversation is in the history above"),
21694            "the history is conditional, so the note stays true over a fresh fallback",
21695        );
21696        assert!(
21697            LEAD_RESTART_NOTE.contains(
21698                "The last turn was interrupted, so its final tool call may not have completed"
21699            ) && LEAD_RESTART_NOTE.contains("check the state before continuing"),
21700            "the note says why the seat is reading it and what the interruption may have left \
21701             half-done",
21702        );
21703        assert!(
21704            LEAD_RESTART_NOTE.contains(
21705                "Anything you sent before the restart may not have arrived; confirm it did \
21706                 before relying on it"
21707            ),
21708            "the note warns that an outbound message may have died with the restart",
21709        );
21710        assert!(
21711            LEAD_RESTART_NOTE.contains(
21712                "If the history's timestamps disagree with the clock and the live schedules, \
21713                 believe the clock and the schedules"
21714            ),
21715            "the note names the whole condition and which side wins",
21716        );
21717    }
21718
21719    /// A boot spawn's restart note is delivered only when the spawn
21720    /// carries a stored id to resume: a first boot, or `--new`, sends
21721    /// nothing even though the boot wave asked for the note.
21722    #[test]
21723    fn a_resume_kick_reaches_only_a_spawn_with_a_stored_id() {
21724        let note = || Some("continue where you left off".to_owned());
21725        assert_eq!(
21726            resume_kick_for(true, note()),
21727            note(),
21728            "a spawn with a stored id takes the note",
21729        );
21730        assert!(resume_kick_for(false, note()).is_none(), "a first boot delivers none");
21731        assert!(
21732            resume_kick_for(true, None).is_none(),
21733            "and a caller that asked for no note gets none",
21734        );
21735    }
21736
21737    /// A row carrying its own `resume_kick` takes that text on resume
21738    /// instead of the generic restart note. The fresh-spawn path is
21739    /// untouched by it: no stored id still means the stored kick.
21740    #[test]
21741    fn dispatch_worker_respawns_prefers_the_rows_resume_kick() {
21742        let (workspace, project_key, _dir) = stub_with_worker_store();
21743        workspace.enable_test_dispatch_intercept();
21744        let mut steward = worker_row("steward", Some("original kick"));
21745        steward.resume_kick = Some("Re-read the taste notes, then drain both queues.".to_owned());
21746        // Mid-turn when forge stopped, so its resume is the one that gets a
21747        // kick; the row's own `resume_kick` is preferred to the generic note.
21748        steward.seat_state = Some(crate::store::sessions::SeatState::InTurn);
21749        let mut fresh = worker_row("fresh", Some("original kick"));
21750        fresh.resume_kick = Some("never delivered: this one has no stored id".to_owned());
21751        let dynamic = vec![steward, fresh];
21752        workspace.record_session_id("TestOrg", "proj-x", "steward", "steward-uuid");
21753
21754        workspace.dispatch_worker_respawns(
21755            &SessionSlot::from_str_for_test("new-lead"),
21756            &project_key,
21757            &dynamic,
21758            false,
21759        );
21760
21761        let dispatched = workspace.drain_test_dispatch_buffer();
21762        assert_eq!(dispatched.len(), 2);
21763        for cmd in dispatched {
21764            let Command::SpawnWorker { label, kick, .. } = cmd else {
21765                panic!("expected SpawnWorker");
21766            };
21767            match label.as_str() {
21768                "steward" => assert_eq!(
21769                    kick.as_deref(),
21770                    Some("Re-read the taste notes, then drain both queues."),
21771                    "a resuming worker with its own resume_kick gets that, not the generic note",
21772                ),
21773                "fresh" => assert_eq!(
21774                    kick.as_deref(),
21775                    Some("original kick"),
21776                    "a fresh re-spawn still takes the stored kick",
21777                ),
21778                other => panic!("unexpected label {other}"),
21779            }
21780        }
21781    }
21782
21783    /// A persisted worker re-spawns on lead reconnect, carrying the
21784    /// row's own charter.
21785    #[test]
21786    fn catalog_scan_respawns_persisted_worker() {
21787        let (workspace, _update_rx) = Workspace::testing_stub();
21788        let dir = tempfile::tempdir().expect("tempdir");
21789        workspace.install_db_for_test(
21790            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
21791        );
21792        let project_path = dir.path().join("catalog-scan-respawn");
21793        std::fs::create_dir_all(&project_path).expect("create the project dir");
21794        workspace.seed_test_project("data-modules", &project_path.to_string_lossy());
21795        let project_key = workspace.project_key_for_name("data-modules").expect("seeded project");
21796        workspace
21797            .record_worker_row(
21798                &project_key,
21799                "scratch",
21800                "scratch-test-id",
21801                "resume the scratch task",
21802                Some("go"),
21803                None,
21804                false,
21805                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
21806                None,
21807            )
21808            .expect("seed the persisted worker this test re-spawns");
21809        workspace.enable_test_dispatch_intercept();
21810
21811        // No tokio runtime in a plain #[test] -> the sync fallback path.
21812        workspace.respawn_workers_for_lead(
21813            &SessionSlot::from_str_for_test("lead-uuid"),
21814            project_key,
21815            false,
21816        );
21817
21818        let dispatched = workspace.drain_test_dispatch_buffer();
21819        let spawns: Vec<&Command> =
21820            dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
21821        assert_eq!(spawns.len(), 1, "the persisted worker re-spawns on lead reconnect");
21822        if let Command::SpawnWorker { label, charter, .. } = spawns[0] {
21823            assert_eq!(label, "scratch");
21824            assert_eq!(charter, "resume the scratch task", "charter comes from the DB row");
21825        }
21826    }
21827
21828    /// A dynamic worker whose row was deleted (despawn / close) does NOT
21829    /// re-spawn on the next lead reconnect.
21830    #[test]
21831    fn catalog_scan_skips_deleted_dynamic_worker() {
21832        let (workspace, _update_rx) = Workspace::testing_stub();
21833        let dir = tempfile::tempdir().expect("tempdir");
21834        workspace.install_db_for_test(
21835            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
21836        );
21837        workspace.seed_test_project("data-modules", "/tmp/catalog-scan-deleted");
21838        let project_key = workspace.project_key_for_name("data-modules").expect("seeded project");
21839        // Expect rather than discard: a silent write failure would leave
21840        // nothing to delete and the negative assertion would hold for the
21841        // wrong reason.
21842        workspace
21843            .record_worker_row(
21844                &project_key,
21845                "scratch",
21846                "scratch-test-id",
21847                "c",
21848                None,
21849                None,
21850                false,
21851                crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
21852                None,
21853            )
21854            .expect("write the row this test then deletes");
21855        workspace.delete_worker_row(&project_key, "scratch").expect("delete the row");
21856        workspace.enable_test_dispatch_intercept();
21857
21858        workspace.respawn_workers_for_lead(
21859            &SessionSlot::from_str_for_test("lead-uuid"),
21860            project_key,
21861            false,
21862        );
21863
21864        let dispatched = workspace.drain_test_dispatch_buffer();
21865        assert!(
21866            dispatched.iter().all(|c| !matches!(c, Command::SpawnWorker { .. })),
21867            "a deleted dynamic worker must not re-spawn",
21868        );
21869    }
21870
21871    /// The id the fixture writes into the label's transcript, which is not
21872    /// the id its row holds: an assertion on a resolved id says which of
21873    /// the two answered.
21874    const TRANSCRIPT_ONLY_ID: &str = "660e8400-e29b-41d4-a716-4466554400aa";
21875
21876    /// Two fixed seconds for a test that reads the newest transcript:
21877    /// the later one has to be the test's own write whatever the clock's
21878    /// granularity is.
21879    const TRANSCRIPT_OLDER_SECS: u64 = 1_700_000_000;
21880    const TRANSCRIPT_NEWER_SECS: u64 = 1_700_000_001;
21881
21882    /// Boot a real workspace over a one-project forge.toml and give the
21883    /// `steward` label both of the pointers a session can be found by: the
21884    /// row, which the boot wave reads and which answers the MCP resume
21885    /// while it is there, and a tagged transcript in the project root's
21886    /// directory, which the MCP resume falls back to once the row is gone.
21887    /// The project is not a git repo, so the worker runs in the project
21888    /// root. Returns the workspace plus the project's key and path, and
21889    /// the id the row holds.
21890    ///
21891    /// Both tempdirs must outlive the caller.
21892    fn resumable_worker_fixture(
21893        project: &tempfile::TempDir,
21894        cfg: &tempfile::TempDir,
21895    ) -> (Arc<Workspace>, crate::target::ProjectKey, PathBuf, String) {
21896        let project_path = project.path().to_string_lossy().replace('\\', "/");
21897        let forge_dir = cfg.path().join("forge");
21898        std::fs::create_dir_all(&forge_dir).expect("forge dir");
21899        std::fs::write(
21900            forge_dir.join("forge.toml"),
21901            format!(
21902                r#"
21903[[orgs]]
21904name = "TestOrg"
21905accounts = ["acct-a"]
21906[[orgs.projects]]
21907name = "demo"
21908path = "{project_path}"
21909
21910[[accounts]]
21911display_name = "acct-a"
21912token = "t"
21913models = ["claude-sonnet-5"]
21914provider = "anthropic"
21915"#
21916            ),
21917        )
21918        .expect("write forge.toml");
21919
21920        // The id the store holds for the label is what the re-spawn wave
21921        // resumes onto, so the fixture writes that row.
21922        let session_id = "550e8400-e29b-41d4-a716-446655440099";
21923        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
21924        let view = ws.list_projects().into_iter().find(|v| v.name == "demo").expect("project");
21925        ws.record_worker_row(
21926            &view.key,
21927            "steward",
21928            session_id,
21929            "mind the queues",
21930            None,
21931            None,
21932            false,
21933            // `demo` is not a git repo, so the worker runs in the project
21934            // root and has no worktree the wave must find.
21935            crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
21936            None,
21937        )
21938        .expect("the row the re-spawn wave resumes onto");
21939        write_tagged_transcript(cfg, &view.path, TRANSCRIPT_ONLY_ID, "steward");
21940        ws.enable_test_dispatch_intercept();
21941        (ws, view.key.clone(), view.path.clone(), session_id.to_owned())
21942    }
21943
21944    /// Poll the intercept buffer until a SpawnWorker lands or the
21945    /// deadline passes; the dispatch happens in a spawned task after an
21946    /// async catalog scan.
21947    async fn await_spawn_worker(ws: &Arc<Workspace>) -> Vec<Command> {
21948        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
21949        while std::time::Instant::now() < deadline {
21950            let dispatched = ws.drain_test_dispatch_buffer();
21951            if dispatched.iter().any(|c| matches!(c, Command::SpawnWorker { .. })) {
21952                return dispatched;
21953            }
21954            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
21955        }
21956        Vec::new()
21957    }
21958
21959    /// The async arm: under a runtime the wave runs off the event loop
21960    /// and re-spawns the worker onto the id the store holds for it.
21961    #[tokio::test]
21962    async fn respawn_workers_resumes_a_worker_onto_its_stored_id() {
21963        let project = tempfile::tempdir().expect("project dir");
21964        let cfg = tempfile::tempdir().expect("cfg dir");
21965        let (ws, key, _path, session_id) = resumable_worker_fixture(&project, &cfg);
21966
21967        ws.respawn_workers_for_lead(&lead_slot(), key, false);
21968
21969        let dispatched = await_spawn_worker(&ws).await;
21970        let spawns: Vec<&Command> =
21971            dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
21972        assert_eq!(spawns.len(), 1, "the persisted row dispatches one SpawnWorker");
21973        let Command::SpawnWorker { label, resume_existing, .. } = spawns[0] else {
21974            panic!("expected SpawnWorker");
21975        };
21976        assert_eq!(label, "steward");
21977        assert_eq!(
21978            resume_existing.as_deref(),
21979            Some(session_id.as_str()),
21980            "the wave resumes the worker onto the id the store holds",
21981        );
21982    }
21983
21984    /// A despawn must still stop the revival. The transcript outlives the
21985    /// despawn, so a boot wave that went looking for labels on disk would
21986    /// bring back a worker the user closed.
21987    #[tokio::test]
21988    async fn a_despawned_labels_transcript_revives_nothing_at_boot() {
21989        let project = tempfile::tempdir().expect("project dir");
21990        let cfg = tempfile::tempdir().expect("cfg dir");
21991        let (ws, key, _path, _session_id) = resumable_worker_fixture(&project, &cfg);
21992        assert!(
21993            ws.delete_worker_row(&key, "steward").expect("delete the row"),
21994            "fixture precondition: the despawn took the row",
21995        );
21996
21997        ws.respawn_workers_for_lead(&lead_slot(), key, false);
21998
21999        let dispatched = await_spawn_worker(&ws).await;
22000        assert!(
22001            dispatched.iter().all(|c| !matches!(c, Command::SpawnWorker { .. })),
22002            "the tagged transcript the despawn left is not a reason to bring the worker back",
22003        );
22004    }
22005
22006    /// The MCP path is the only one leads spawn through; its
22007    /// `from_boot_respawn` must stay false or the cap silently stops
22008    /// governing exactly the spawns the cap exists for.
22009    #[tokio::test]
22010    async fn mcp_spawn_carries_from_boot_respawn_false() {
22011        let project = tempfile::tempdir().expect("project dir");
22012        let cfg = tempfile::tempdir().expect("cfg dir");
22013        let (ws, _key, _path, _session_id) = resumable_worker_fixture(&project, &cfg);
22014        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22015
22016        // The intercept swallows the command and holds its reply sender,
22017        // so the facade's await only resolves once the buffer is
22018        // drained below - which is also where the assertion target
22019        // comes from.
22020        let spawner = tokio::spawn(async move {
22021            let _ = facade
22022                .spawn_worker(
22023                    &lead_slot(),
22024                    "reviewer".to_owned(),
22025                    "charter".to_owned(),
22026                    None,
22027                    None,
22028                    false,
22029                    false,
22030                    None,
22031                    None,
22032                )
22033                .await;
22034        });
22035        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
22036        let from_boot_respawn = loop {
22037            let drained = ws.drain_test_dispatch_buffer();
22038            if let Some(from_boot_respawn) = drained.iter().find_map(|cmd| match cmd {
22039                Command::SpawnWorker { from_boot_respawn, .. } => Some(*from_boot_respawn),
22040                _ => None,
22041            }) {
22042                break from_boot_respawn;
22043            }
22044            assert!(
22045                std::time::Instant::now() < deadline,
22046                "the MCP spawn never dispatched a SpawnWorker"
22047            );
22048            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
22049        };
22050        spawner.await.expect("facade task joins");
22051        assert!(!from_boot_respawn, "the MCP path is cap-governed, never boot-exempt");
22052    }
22053
22054    /// The MCP resume-spawn resolves the label's prior session and
22055    /// threads it into `Command::SpawnWorker.resume_existing` - the exact
22056    /// argument the boot re-spawn path fills. The row answers while it is
22057    /// there, so the id it holds is the one that arrives, not the one in
22058    /// the label's transcript.
22059    #[tokio::test]
22060    async fn mcp_spawn_with_resume_session_threads_the_resolved_session() {
22061        let project = tempfile::tempdir().expect("project dir");
22062        let cfg = tempfile::tempdir().expect("cfg dir");
22063        let (ws, _key, _path, session_id) = resumable_worker_fixture(&project, &cfg);
22064        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22065
22066        let spawner = tokio::spawn(async move {
22067            facade
22068                .spawn_worker(
22069                    &lead_slot(),
22070                    "steward".to_owned(),
22071                    "charter".to_owned(),
22072                    None,
22073                    None,
22074                    false,
22075                    true,
22076                    None,
22077                    None,
22078                )
22079                .await
22080        });
22081        let resume_existing = poll_spawn_worker_field(&ws, |cmd| match cmd {
22082            Command::SpawnWorker { label, resume_existing, .. } if label == "steward" => {
22083                Some(resume_existing.clone())
22084            }
22085            _ => None,
22086        })
22087        .await
22088        .expect("the resume spawn dispatched");
22089        // The drain above dropped the command's reply sender, so the
22090        // facade's await resolves to a DispatchFailed we do not assert on.
22091        let _ = spawner.await.expect("facade task joins");
22092
22093        assert_eq!(
22094            resume_existing.as_deref(),
22095            Some(session_id.as_str()),
22096            "the facade resumes the id the store holds for the label"
22097        );
22098    }
22099
22100    /// A `resume_session` spawn for a label with nothing to resume starts
22101    /// a new session rather than refusing: the caller asked for old
22102    /// context and has to be told it did not get it, but a fresh worker
22103    /// is usually what it wanted anyway.
22104    #[tokio::test]
22105    async fn mcp_spawn_resume_without_prior_session_starts_fresh() {
22106        let project = tempfile::tempdir().expect("project dir");
22107        let cfg = tempfile::tempdir().expect("cfg dir");
22108        let (ws, _key, worktree) = git_worker_fixture(&project, &cfg, "never-used");
22109        ws.enable_test_dispatch_intercept();
22110        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22111
22112        let spawner = tokio::spawn(async move {
22113            facade
22114                .spawn_worker(
22115                    &lead_slot(),
22116                    "never-used".to_owned(),
22117                    "charter".to_owned(),
22118                    None,
22119                    None,
22120                    false,
22121                    true,
22122                    None,
22123                    None,
22124                )
22125                .await
22126        });
22127        let resume_existing = poll_spawn_worker_field(&ws, |cmd| match cmd {
22128            Command::SpawnWorker { label, resume_existing, .. } if label == "never-used" => {
22129                Some(resume_existing.clone())
22130            }
22131            _ => None,
22132        })
22133        .await
22134        .expect("a label with no prior session still spawns");
22135        let _ = spawner.await.expect("facade task joins");
22136
22137        assert!(resume_existing.is_none(), "there was nothing to resume, so the session is fresh");
22138        assert!(
22139            worktree.exists(),
22140            "the worktree the ensure minted is the directory the fresh session runs in",
22141        );
22142    }
22143
22144    /// A one-commit git repo as the project plus a workspace booted over
22145    /// a one-project forge.toml naming it. Returns the workspace, the
22146    /// project's key, and the label's worktree path (not created).
22147    fn git_worker_fixture(
22148        project: &tempfile::TempDir,
22149        cfg: &tempfile::TempDir,
22150        label: &str,
22151    ) -> (Arc<Workspace>, crate::target::ProjectKey, PathBuf) {
22152        let project_path = project.path().to_string_lossy().replace('\\', "/");
22153        run_git_in(project.path(), &["init", "-q"]);
22154        run_git_in(project.path(), &["config", "user.email", "t@example.com"]);
22155        run_git_in(project.path(), &["config", "user.name", "Test"]);
22156        run_git_in(project.path(), &["config", "commit.gpgsign", "false"]);
22157        std::fs::write(project.path().join("README.md"), "seed").expect("write seed");
22158        run_git_in(project.path(), &["add", "."]);
22159        run_git_in(project.path(), &["commit", "-q", "-m", "init"]);
22160
22161        let forge_dir = cfg.path().join("forge");
22162        std::fs::create_dir_all(&forge_dir).expect("forge dir");
22163        std::fs::write(
22164            forge_dir.join("forge.toml"),
22165            format!(
22166                r#"
22167[[orgs]]
22168name = "TestOrg"
22169accounts = ["acct-a"]
22170[[orgs.projects]]
22171name = "demo"
22172path = "{project_path}"
22173
22174[[accounts]]
22175display_name = "acct-a"
22176token = "t"
22177models = ["claude-sonnet-5"]
22178provider = "anthropic"
22179"#,
22180            ),
22181        )
22182        .expect("write forge.toml");
22183
22184        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
22185        let key =
22186            ws.list_projects().into_iter().find(|v| v.name == "demo").expect("project").key.clone();
22187        let worktree = project.path().join(".claude").join("worktrees").join(label);
22188        (ws, key, worktree)
22189    }
22190
22191    /// The regression this change exists for: a despawn deletes the
22192    /// store row, and the row used to be the only pointer to the session
22193    /// the label resumes onto. The transcript survives the despawn.
22194    #[tokio::test]
22195    async fn a_despawned_label_resolves_from_its_transcripts() {
22196        let project = tempfile::tempdir().expect("project dir");
22197        let cfg = tempfile::tempdir().expect("cfg dir");
22198        let (ws, key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22199        std::fs::create_dir_all(&worktree).expect("worktree dir");
22200        write_tagged_transcript(&cfg, &worktree, "aaaa", "steward");
22201        let _store = seed_stored_session(&ws, "steward", "bbbb");
22202        assert!(
22203            ws.delete_worker_row(&key, "steward").expect("delete the row"),
22204            "fixture precondition: the despawn took the row the label used to be found by",
22205        );
22206
22207        let found = ws
22208            .resolve_worker_resume_session("TestOrg", "demo", &worktree, "steward")
22209            .expect("lookup");
22210        assert_eq!(
22211            found,
22212            ResumeTarget::Found { session_id: "aaaa".to_owned(), restamp: true },
22213            "the label resolves to its newest transcript, which is what a despawn leaves behind; \
22214             a pre-field row is flagged for stamping",
22215        );
22216    }
22217
22218    /// The row is authoritative while it exists: a worker already
22219    /// registered opens from it even where the directory holds a newer
22220    /// tagged session. The transcripts are the recovery path for a row
22221    /// that is gone, not the primary.
22222    #[tokio::test]
22223    async fn a_registered_labels_row_wins_over_its_transcripts() {
22224        let project = tempfile::tempdir().expect("project dir");
22225        let cfg = tempfile::tempdir().expect("cfg dir");
22226        let (ws, _key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22227        std::fs::create_dir_all(&worktree).expect("worktree dir");
22228        write_tagged_transcript(&cfg, &worktree, "aaaa", "steward");
22229        let _store = seed_stored_session(&ws, "steward", "bbbb");
22230
22231        let found = ws
22232            .resolve_worker_resume_session("TestOrg", "demo", &worktree, "steward")
22233            .expect("lookup");
22234        assert_eq!(
22235            found,
22236            ResumeTarget::Found { session_id: "bbbb".to_owned(), restamp: false },
22237            "the row the label is registered under is what it opens as",
22238        );
22239    }
22240
22241    /// A directory that is there but cannot be read is a failure, not an
22242    /// absence: a caller told to start fresh would be told a resume
22243    /// happened on an answer the lookup never gave.
22244    #[tokio::test]
22245    async fn an_unreadable_transcript_directory_is_a_failure_not_an_absence() {
22246        use std::os::unix::fs::PermissionsExt as _;
22247
22248        let project = tempfile::tempdir().expect("project dir");
22249        let cfg = tempfile::tempdir().expect("cfg dir");
22250        let (ws, _key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22251        std::fs::create_dir_all(&worktree).expect("worktree dir");
22252        write_tagged_transcript(&cfg, &worktree, "aaaa", "steward");
22253        let dir = worker_transcript_dir(&cfg, &worktree);
22254        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).expect("chmod");
22255        let found = ws.resolve_worker_resume_session("TestOrg", "demo", &worktree, "steward");
22256        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("chmod back");
22257
22258        let error = found.expect_err("a directory that cannot be read is not an absence");
22259        assert!(
22260            error.to_string().contains(&dir.to_string_lossy().to_string()),
22261            "the failure names the directory it could not read: {error}",
22262        );
22263    }
22264
22265    /// Write a `forge:worker:<label>` tagged transcript under the storage
22266    /// key of `run_dir` - a worktree, or a project root for a worker with
22267    /// no worktree of its own - computed while that directory exists, the
22268    /// way claude names the directory at session time.
22269    fn write_tagged_transcript(
22270        cfg: &tempfile::TempDir,
22271        run_dir: &std::path::Path,
22272        session_id: &str,
22273        label: &str,
22274    ) {
22275        let worktree_str = run_dir.to_string_lossy().replace('\\', "/");
22276        let jsonl_dir = worker_transcript_dir(cfg, run_dir);
22277        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
22278        std::fs::write(
22279            jsonl_dir.join(format!("{session_id}.jsonl")),
22280            format!(
22281                "{{\"type\":\"user\",\"cwd\":\"{worktree_str}\",\"message\":{{\"content\":\"hi\"}}}}\n\
22282                 {{\"type\":\"tag\",\"tag\":\"forge:worker:{label}\"}}\n"
22283            ),
22284        )
22285        .expect("write tagged jsonl");
22286    }
22287
22288    /// Pin a transcript's mtime to a fixed second, so a test that reads
22289    /// the newest one does not depend on the clock's granularity.
22290    fn pin_mtime(cfg: &tempfile::TempDir, run_dir: &std::path::Path, id: &str, secs: u64) {
22291        let path = worker_transcript_dir(cfg, run_dir).join(format!("{id}.jsonl"));
22292        let file = std::fs::File::options().write(true).open(&path).expect("open the transcript");
22293        file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
22294            .expect("pin the transcript's mtime");
22295    }
22296
22297    /// The config-dir directory claude writes `run_dir`'s sessions in.
22298    fn worker_transcript_dir(cfg: &tempfile::TempDir, run_dir: &std::path::Path) -> PathBuf {
22299        let run_dir_str = run_dir.to_string_lossy().replace('\\', "/");
22300        let storage_key =
22301            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&run_dir_str));
22302        forge_sdk::projects_dir_for(cfg.path()).join(storage_key)
22303    }
22304
22305    /// The attach case is the data-loss guard on a path that still
22306    /// refuses: the branch predates the spawn and holds the worker's only
22307    /// copy of its commits, so the rollback removes the worktree it
22308    /// created and spares the branch.
22309    #[tokio::test]
22310    async fn mcp_resume_lookup_failure_keeps_a_pre_existing_branch() {
22311        use std::os::unix::fs::PermissionsExt as _;
22312
22313        let project = tempfile::tempdir().expect("project dir");
22314        let cfg = tempfile::tempdir().expect("cfg dir");
22315        let (ws, _key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22316        let worktree_str = worktree.to_string_lossy().replace('\\', "/");
22317        run_git_in(
22318            project.path(),
22319            &["worktree", "add", "-q", "-b", "worktree-steward", worktree_str.as_str()],
22320        );
22321        std::fs::write(worktree.join("work.txt"), "a worker committed here").expect("write work");
22322        run_git_in(&worktree, &["add", "."]);
22323        run_git_in(&worktree, &["commit", "-q", "-m", "real work"]);
22324        write_tagged_transcript(&cfg, &worktree, "550e8400-e29b-41d4-a716-446655440099", "steward");
22325        let transcripts = worker_transcript_dir(&cfg, &worktree);
22326        std::fs::set_permissions(&transcripts, std::fs::Permissions::from_mode(0o000))
22327            .expect("chmod");
22328        run_git_in(project.path(), &["worktree", "remove", "--force", worktree_str.as_str()]);
22329        ws.enable_test_dispatch_intercept();
22330        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22331
22332        // The timeout is the assertion for the other direction: a lookup
22333        // failure that dispatched instead of returning would sit here
22334        // waiting on a reply nobody sends, which times out the test
22335        // rather than failing it with this message.
22336        let err = tokio::time::timeout(
22337            std::time::Duration::from_secs(5),
22338            facade.spawn_worker(
22339                &lead_slot(),
22340                "steward".to_owned(),
22341                "charter".to_owned(),
22342                None,
22343                None,
22344                false,
22345                true,
22346                None,
22347                None,
22348            ),
22349        )
22350        .await
22351        .expect("a failed lookup returns; it does not dispatch and wait on a reply")
22352        .expect_err("the label's transcript directory cannot be read");
22353        std::fs::set_permissions(&transcripts, std::fs::Permissions::from_mode(0o755))
22354            .expect("chmod back");
22355
22356        assert!(
22357            matches!(err, crate::mcp::workers::facade::WorkerSpawnError::ResumeLookupFailed { .. }),
22358            "an unreadable lookup is reported as a failure, got {err:?}",
22359        );
22360        assert!(
22361            ws.drain_test_dispatch_buffer()
22362                .iter()
22363                .all(|c| !matches!(c, Command::SpawnWorker { .. })),
22364            "a failed lookup spawns nothing",
22365        );
22366        assert!(!worktree.exists(), "the attached worktree is rolled back");
22367        assert!(
22368            forge_agent::env::worktree::branch_ref_exists(project.path(), "worktree-steward"),
22369            "the pre-existing branch and its commits survive the refusal"
22370        );
22371    }
22372
22373    /// A refusal from the shared guard (label already live) arrives
22374    /// after dispatch, so the rollback rides the reply path: the
22375    /// worktree the ensure step minted is removed and its branch reaped.
22376    #[tokio::test]
22377    async fn mcp_label_live_refusal_rolls_back_a_minted_worktree() {
22378        let project = tempfile::tempdir().expect("project dir");
22379        let cfg = tempfile::tempdir().expect("cfg dir");
22380        let (ws, key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22381        let worktree_str = worktree.to_string_lossy().replace('\\', "/");
22382        run_git_in(
22383            project.path(),
22384            &["worktree", "add", "-q", "-b", "worktree-steward", worktree_str.as_str()],
22385        );
22386        write_tagged_transcript(&cfg, &worktree, "550e8400-e29b-41d4-a716-446655440099", "steward");
22387        run_git_in(project.path(), &["worktree", "remove", "--force", worktree_str.as_str()]);
22388        run_git_in(project.path(), &["branch", "-D", "worktree-steward"]);
22389        let _store = seed_stored_session(&ws, "steward", "550e8400-e29b-41d4-a716-446655440099");
22390        // No dispatch intercept: the label-live refusal must come from
22391        // the real shared core, which fires before any subprocess spawn.
22392        ws.insert_live_worker(&key, worker_entry("steward"));
22393        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22394
22395        let err = facade
22396            .spawn_worker(
22397                &lead_slot(),
22398                "steward".to_owned(),
22399                "charter".to_owned(),
22400                None,
22401                None,
22402                false,
22403                true,
22404                None,
22405                None,
22406            )
22407            .await
22408            .expect_err("the label is already live");
22409        let crate::mcp::workers::facade::WorkerSpawnError::DispatchFailed { message } = err else {
22410            panic!("the label-live refusal classifies as DispatchFailed, got {err:?}");
22411        };
22412        assert!(message.contains("already live"), "the refusal is the label guard: {message}");
22413        assert!(!worktree.exists(), "the minted worktree is rolled back");
22414        assert!(
22415            !forge_agent::env::worktree::branch_ref_exists(project.path(), "worktree-steward"),
22416            "the minted branch is reaped"
22417        );
22418    }
22419
22420    /// When the recreation itself cannot happen (the label's branch is
22421    /// checked out in a second worktree, so `git worktree add` refuses),
22422    /// the lead gets WorktreeCreationFailed and nothing dispatches - a
22423    /// log-and-continue mutation here would fall through to a scan the
22424    /// missing worktree makes misleading.
22425    #[tokio::test]
22426    async fn mcp_resume_spawn_surfaces_a_failed_worktree_recreation() {
22427        let project = tempfile::tempdir().expect("project dir");
22428        let cfg = tempfile::tempdir().expect("cfg dir");
22429        let (ws, _key, worktree) = git_worker_fixture(&project, &cfg, "steward");
22430        run_git_in(
22431            project.path(),
22432            &["worktree", "add", "-q", "-b", "worktree-steward", "elsewhere"],
22433        );
22434        ws.enable_test_dispatch_intercept();
22435        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22436
22437        let err = facade
22438            .spawn_worker(
22439                &lead_slot(),
22440                "steward".to_owned(),
22441                "charter".to_owned(),
22442                None,
22443                None,
22444                false,
22445                true,
22446                None,
22447                None,
22448            )
22449            .await
22450            .expect_err("git cannot check the branch out in a second worktree");
22451        assert!(
22452            matches!(
22453                err,
22454                crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { .. }
22455            ),
22456            "the recreation failure surfaces typed, got {err:?}"
22457        );
22458        assert!(
22459            ws.drain_test_dispatch_buffer()
22460                .iter()
22461                .all(|c| !matches!(c, Command::SpawnWorker { .. })),
22462            "the failed recreation stops the spawn before dispatch"
22463        );
22464        assert!(!worktree.exists(), "the refused add left no worktree behind");
22465    }
22466
22467    /// The motivating shape: a git worker despawned (worktree removed),
22468    /// then re-spawned with `resume_session`. The facade recreates the
22469    /// worktree before dispatch, because the transcript lives under the
22470    /// worktree's storage key and the resumed subprocess needs that cwd.
22471    ///
22472    /// The project is reached through a symlink so the two spellings of
22473    /// its worktree path can never agree by accident: the transcript's
22474    /// dir was named from the real spelling (existing at write time, so
22475    /// canonicalised), while a scan that runs without the worktree falls
22476    /// back to the forge.toml spelling. Recreating before the scan is
22477    /// what makes the two keys meet; reverted, this test fails on every
22478    /// platform rather than only where the tempdir root is itself a
22479    /// symlink.
22480    #[tokio::test]
22481    async fn mcp_resume_spawn_recreates_a_despawned_worktree() {
22482        let project = tempfile::tempdir().expect("project dir");
22483        let cfg = tempfile::tempdir().expect("cfg dir");
22484        run_git_in(project.path(), &["init", "-q"]);
22485        run_git_in(project.path(), &["config", "user.email", "t@example.com"]);
22486        run_git_in(project.path(), &["config", "user.name", "Test"]);
22487        run_git_in(project.path(), &["config", "commit.gpgsign", "false"]);
22488        std::fs::write(project.path().join("README.md"), "seed").expect("write seed");
22489        run_git_in(project.path(), &["add", "."]);
22490        run_git_in(project.path(), &["commit", "-q", "-m", "init"]);
22491
22492        let via_link = project.path().join("via-link");
22493        std::os::unix::fs::symlink(project.path(), &via_link).expect("symlink project root");
22494        let project_path = via_link.to_string_lossy().replace('\\', "/");
22495
22496        let forge_dir = cfg.path().join("forge");
22497        std::fs::create_dir_all(&forge_dir).expect("forge dir");
22498        std::fs::write(
22499            forge_dir.join("forge.toml"),
22500            format!(
22501                r#"
22502[[orgs]]
22503name = "TestOrg"
22504accounts = ["acct-a"]
22505[[orgs.projects]]
22506name = "demo"
22507path = "{project_path}"
22508
22509[[accounts]]
22510display_name = "acct-a"
22511token = "t"
22512models = ["claude-sonnet-5"]
22513provider = "anthropic"
22514"#,
22515            ),
22516        )
22517        .expect("write forge.toml");
22518
22519        let session_id = "550e8400-e29b-41d4-a716-446655440099".to_owned();
22520        let worktree = project.path().join(".claude").join("worktrees").join("steward");
22521        let worktree_str = worktree.to_string_lossy().replace('\\', "/");
22522        run_git_in(
22523            project.path(),
22524            &["worktree", "add", "-q", "-b", "worktree-steward", worktree_str.as_str()],
22525        );
22526        // The transcript lives under the worktree's storage key in the
22527        // config dir's projects tree, so it survives the worktree removal.
22528        let storage_key =
22529            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&worktree_str));
22530        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(&storage_key);
22531        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
22532        std::fs::write(
22533            jsonl_dir.join(format!("{session_id}.jsonl")),
22534            format!(
22535                "{{\"type\":\"user\",\"cwd\":\"{worktree_str}\",\"message\":{{\"content\":\"hi\"}}}}\n\
22536                 {{\"type\":\"tag\",\"tag\":\"forge:worker:steward\"}}\n"
22537            ),
22538        )
22539        .expect("write tagged jsonl");
22540        // Despawn: remove the worktree the way a clean despawn does.
22541        run_git_in(project.path(), &["worktree", "remove", "--force", worktree_str.as_str()]);
22542        assert!(!worktree.exists(), "fixture precondition: the worktree is gone");
22543
22544        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
22545        let _store = seed_stored_session(&ws, "steward", &session_id);
22546        ws.enable_test_dispatch_intercept();
22547        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22548
22549        let spawner = tokio::spawn(async move {
22550            facade
22551                .spawn_worker(
22552                    &lead_slot(),
22553                    "steward".to_owned(),
22554                    "charter".to_owned(),
22555                    None,
22556                    None,
22557                    false,
22558                    true,
22559                    None,
22560                    None,
22561                )
22562                .await
22563        });
22564        let resume_existing = poll_spawn_worker_field(&ws, |cmd| match cmd {
22565            Command::SpawnWorker { label, resume_existing, .. } if label == "steward" => {
22566                Some(resume_existing.clone())
22567            }
22568            _ => None,
22569        })
22570        .await
22571        .expect("the resume spawn dispatched");
22572        // Same as above: the drain dropped the reply sender, so the
22573        // facade's own result is not the assertion target here.
22574        let _ = spawner.await.expect("facade task joins");
22575
22576        assert!(worktree.exists(), "the facade recreated the worktree the despawn removed");
22577        assert_eq!(resume_existing.as_deref(), Some(session_id.as_str()));
22578    }
22579
22580    /// A resume on a project that is itself a linked worktree: its
22581    /// workers' worktrees live under the MAIN checkout (#2460), so the
22582    /// ensure's repo is the main checkout. A leftover directory there is
22583    /// cleared through that root; composing the project path refuses it as
22584    /// outside the project's own `.claude/worktrees` and fails the resume.
22585    #[tokio::test]
22586    async fn mcp_resume_clears_a_leftover_under_the_main_checkout_for_a_linked_project() {
22587        let main = tempfile::tempdir().expect("main dir");
22588        let cfg = tempfile::tempdir().expect("cfg dir");
22589        run_git_in(main.path(), &["init", "-q"]);
22590        run_git_in(main.path(), &["config", "user.email", "t@example.com"]);
22591        run_git_in(main.path(), &["config", "user.name", "Test"]);
22592        run_git_in(main.path(), &["config", "commit.gpgsign", "false"]);
22593        std::fs::write(main.path().join("README.md"), "seed").expect("write seed");
22594        run_git_in(main.path(), &["add", "."]);
22595        run_git_in(main.path(), &["commit", "-q", "-m", "init"]);
22596        let main_root = std::fs::canonicalize(main.path()).expect("canonical main");
22597
22598        let holder = tempfile::tempdir().expect("holder");
22599        let linked = holder.path().join("linked");
22600        let linked_str = linked.to_string_lossy().replace('\\', "/");
22601        run_git_in(&main_root, &["worktree", "add", "-q", "-b", "linked-project", &linked_str]);
22602
22603        let forge_dir = cfg.path().join("forge");
22604        std::fs::create_dir_all(&forge_dir).expect("forge dir");
22605        std::fs::write(
22606            forge_dir.join("forge.toml"),
22607            format!(
22608                r#"
22609[[orgs]]
22610name = "TestOrg"
22611accounts = ["acct-a"]
22612[[orgs.projects]]
22613name = "demo"
22614path = "{linked_str}"
22615
22616[[accounts]]
22617display_name = "acct-a"
22618token = "t"
22619models = ["claude-sonnet-5"]
22620provider = "anthropic"
22621"#,
22622            ),
22623        )
22624        .expect("write forge.toml");
22625
22626        let session_id = "550e8400-e29b-41d4-a716-446655440098".to_owned();
22627        let worktree = main_root.join(".claude").join("worktrees").join("steward");
22628        let worktree_str = worktree.to_string_lossy().replace('\\', "/");
22629        run_git_in(&main_root, &["worktree", "add", "-q", "-b", "worktree-steward", &worktree_str]);
22630        // The transcript lives under the worktree's storage key in the
22631        // config dir's projects tree, so it survives the worktree removal.
22632        let storage_key =
22633            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&worktree_str));
22634        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(&storage_key);
22635        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
22636        std::fs::write(
22637            jsonl_dir.join(format!("{session_id}.jsonl")),
22638            format!(
22639                "{{\"type\":\"user\",\"cwd\":\"{worktree_str}\",\"message\":{{\"content\":\"hi\"}}}}\n\
22640                 {{\"type\":\"tag\",\"tag\":\"forge:worker:steward\"}}\n"
22641            ),
22642        )
22643        .expect("write tagged jsonl");
22644        // The leftover a failed resume path meets: the registration is
22645        // gone and an empty directory stands where the worktree was.
22646        run_git_in(&main_root, &["worktree", "remove", "--force", &worktree_str]);
22647        std::fs::create_dir_all(&worktree).expect("empty leftover");
22648        assert!(!worktree.join(".git").exists(), "fixture precondition: no registration left");
22649
22650        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
22651        let _store = seed_stored_session(&ws, "steward", &session_id);
22652        // The worker's own row names the composed path; the collision
22653        // check must not read that as another project's claim.
22654        ws.record_worker_row(
22655            &ws.list_projects().into_iter().next().expect("project").key,
22656            "steward",
22657            &session_id,
22658            "charter",
22659            None,
22660            None,
22661            false,
22662            crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
22663            None,
22664        )
22665        .expect("record the worker's own row");
22666        ws.enable_test_dispatch_intercept();
22667        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22668
22669        let spawner = tokio::spawn(async move {
22670            facade
22671                .spawn_worker(
22672                    &lead_slot(),
22673                    "steward".to_owned(),
22674                    "charter".to_owned(),
22675                    None,
22676                    None,
22677                    false,
22678                    true,
22679                    None,
22680                    None,
22681                )
22682                .await
22683        });
22684        let resume_existing = poll_spawn_worker_field(&ws, |cmd| match cmd {
22685            Command::SpawnWorker { label, resume_existing, .. } if label == "steward" => {
22686                Some(resume_existing.clone())
22687            }
22688            _ => None,
22689        })
22690        .await
22691        .expect("the resume spawn dispatched");
22692        let _ = spawner.await.expect("facade task joins");
22693
22694        assert!(
22695            worktree.join(".git").is_file(),
22696            "the ensure cleared the leftover under the main checkout and rebuilt a worktree there",
22697        );
22698        assert!(
22699            !linked.join(".claude").join("worktrees").join("steward").exists(),
22700            "nothing is composed under the project path",
22701        );
22702        assert_eq!(resume_existing.as_deref(), Some(session_id.as_str()));
22703    }
22704
22705    /// A resume never touches another project's worktree: the refusal comes
22706    /// before the ensure (which can clear a deregistered leftover, rewrite
22707    /// a link and record ownership) and before the transcript lookup (which
22708    /// would adopt that project's conversation). A durable row claims the
22709    /// tree even with no live entry, after a restart.
22710    #[tokio::test]
22711    async fn mcp_resume_refuses_a_worktree_another_project_holds() {
22712        let holder = tempfile::tempdir().expect("holder");
22713        let main = holder.path().join("main");
22714        std::fs::create_dir_all(&main).expect("main dir");
22715        run_git_in(&main, &["init", "-q"]);
22716        run_git_in(&main, &["config", "user.email", "t@example.com"]);
22717        run_git_in(&main, &["config", "user.name", "Test"]);
22718        run_git_in(&main, &["config", "commit.gpgsign", "false"]);
22719        std::fs::write(main.join("README.md"), "seed").expect("write seed");
22720        run_git_in(&main, &["add", "."]);
22721        run_git_in(&main, &["commit", "-q", "-m", "init"]);
22722        let main = std::fs::canonicalize(&main).expect("canonical main");
22723        let linked_a = holder.path().join("proj-a");
22724        let linked_b = holder.path().join("proj-b");
22725        run_git_in(
22726            &main,
22727            &["worktree", "add", "-q", "-b", "proj-a", linked_a.to_str().expect("utf8")],
22728        );
22729        run_git_in(
22730            &main,
22731            &["worktree", "add", "-q", "-b", "proj-b", linked_b.to_str().expect("utf8")],
22732        );
22733        let cfg = tempfile::tempdir().expect("cfg dir");
22734        let forge_dir = cfg.path().join("forge");
22735        std::fs::create_dir_all(&forge_dir).expect("forge dir");
22736        std::fs::write(
22737            forge_dir.join("forge.toml"),
22738            format!(
22739                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
22740                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
22741                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
22742                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
22743                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
22744                linked_a.display(),
22745                linked_b.display()
22746            ),
22747        )
22748        .expect("write forge.toml");
22749        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
22750        let key_of = |name: &str| {
22751            ws.list_projects().into_iter().find(|v| v.name == name).expect("project").key
22752        };
22753        let session_id = "550e8400-e29b-41d4-a716-446655440085";
22754        let store = tempfile::tempdir().expect("store dir");
22755        ws.install_db_for_test(
22756            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
22757        );
22758        ws.record_worker_row(
22759            &key_of("proj-b"),
22760            "shared",
22761            session_id,
22762            "charter",
22763            None,
22764            None,
22765            false,
22766            crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
22767            None,
22768        )
22769        .expect("record proj-b's row");
22770
22771        let shared = main.join(".claude").join("worktrees").join("shared");
22772        std::fs::create_dir_all(shared.parent().expect("worktrees root")).expect("mkdir");
22773        run_git_in(
22774            &main,
22775            &["worktree", "add", "-q", "-b", "worktree-shared", shared.to_str().expect("utf8")],
22776        );
22777        // proj-a's worker holds the tree, first as a live seat...
22778        let entry = crate::mcp::workers::types::WorkerEntry {
22779            label: "shared".to_owned(),
22780            charter: "test".to_owned(),
22781            slot: SessionSlot::worker("TestOrg", "proj-a", "shared"),
22782            session_id: Some(forge_primitives::SessionId::new(
22783                "550e8400-e29b-41d4-a716-446655440084".to_owned(),
22784            )),
22785            status: forge_primitives::WorkerLiveness::Running,
22786            spawned_at: std::time::SystemTime::UNIX_EPOCH,
22787            spawned_by: SessionSlot::lead("TestOrg", "proj-a"),
22788            needs_tag: false,
22789            is_git_repo_at_spawn: true,
22790            cwd: None,
22791            diagnostic: None,
22792            kick: None,
22793        };
22794        ws.insert_live_worker(&key_of("proj-a"), entry);
22795        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22796        let err = facade
22797            .spawn_worker(
22798                &SessionSlot::lead("TestOrg", "proj-b"),
22799                "shared".to_owned(),
22800                "charter".to_owned(),
22801                None,
22802                None,
22803                false,
22804                true,
22805                None,
22806                None,
22807            )
22808            .await
22809            .expect_err("a live holder refuses the resume");
22810        let crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { reason } = err
22811        else {
22812            panic!("the refusal is the worktree guard: {err:?}");
22813        };
22814        assert!(reason.contains("worker 'shared'"), "it names the holder: {reason}");
22815        assert!(reason.contains("proj-a"), "and its project: {reason}");
22816        assert!(
22817            !main.join(".git").join("forge-worktrees").exists(),
22818            "the refused resume never reached the ensure",
22819        );
22820
22821        // ...then only as a durable row: after a restart, before proj-a's
22822        // lead connects, the row still claims the tree.
22823        ws.remove_latest_worker(&key_of("proj-a"), "shared");
22824        ws.record_worker_row(
22825            &key_of("proj-a"),
22826            "shared",
22827            "550e8400-e29b-41d4-a716-446655440084",
22828            "charter",
22829            None,
22830            None,
22831            false,
22832            crate::mcp::workers::types::WorkerTree { is_git_repo: true, cwd: None },
22833            None,
22834        )
22835        .expect("record proj-a's row");
22836        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22837        let err = facade
22838            .spawn_worker(
22839                &SessionSlot::lead("TestOrg", "proj-b"),
22840                "shared".to_owned(),
22841                "charter".to_owned(),
22842                None,
22843                None,
22844                false,
22845                true,
22846                None,
22847                None,
22848            )
22849            .await
22850            .expect_err("the durable row refuses the resume");
22851        let crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { reason } = err
22852        else {
22853            panic!("the refusal is the worktree guard: {err:?}");
22854        };
22855        assert!(reason.contains("proj-a"), "the durable row still names its project: {reason}");
22856        assert!(
22857            !main.join(".git").join("forge-worktrees").exists(),
22858            "and nothing was ensured against the other project's tree",
22859        );
22860        assert!(shared.exists(), "the other project's tree is untouched");
22861    }
22862
22863    /// A resume in one project never adopts another project's conversation:
22864    /// both compose the same worktree directory, and a Projects-pane close
22865    /// deletes the row but leaves the tree and its tagged transcript. The
22866    /// tag's project field is what tells them apart.
22867    #[tokio::test]
22868    async fn mcp_resume_does_not_adopt_another_projects_session() {
22869        let repo = tempfile::tempdir().expect("repo");
22870        let main = repo.path().join("main");
22871        std::fs::create_dir_all(&main).expect("main dir");
22872        run_git_in(&main, &["init", "-q"]);
22873        run_git_in(&main, &["config", "user.email", "t@example.com"]);
22874        run_git_in(&main, &["config", "user.name", "Test"]);
22875        run_git_in(&main, &["config", "commit.gpgsign", "false"]);
22876        std::fs::write(main.join("README.md"), "seed").expect("write seed");
22877        run_git_in(&main, &["add", "."]);
22878        run_git_in(&main, &["commit", "-q", "-m", "init"]);
22879        let main = std::fs::canonicalize(&main).expect("canonical main");
22880        let holder = tempfile::tempdir().expect("holder");
22881        let linked_a = holder.path().join("proj-a");
22882        let linked_b = holder.path().join("proj-b");
22883        run_git_in(
22884            &main,
22885            &["worktree", "add", "-q", "-b", "proj-a", linked_a.to_str().expect("utf8")],
22886        );
22887        run_git_in(
22888            &main,
22889            &["worktree", "add", "-q", "-b", "proj-b", linked_b.to_str().expect("utf8")],
22890        );
22891        let cfg = tempfile::tempdir().expect("cfg");
22892        let forge_dir = cfg.path().join("forge");
22893        std::fs::create_dir_all(&forge_dir).expect("forge dir");
22894        std::fs::write(
22895            forge_dir.join("forge.toml"),
22896            format!(
22897                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
22898                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
22899                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
22900                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
22901                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
22902                linked_a.display(),
22903                linked_b.display()
22904            ),
22905        )
22906        .expect("write forge.toml");
22907        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
22908        let store = tempfile::tempdir().expect("store dir");
22909        ws.install_db_for_test(
22910            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
22911        );
22912
22913        // The tree both projects compose, left behind by proj-a's closed
22914        // worker: its row is gone, the tree and its transcript are not.
22915        let shared = main.join(".claude").join("worktrees").join("shared");
22916        std::fs::create_dir_all(shared.parent().expect("worktrees root")).expect("mkdir");
22917        run_git_in(
22918            &main,
22919            &["worktree", "add", "-q", "-b", "worktree-shared", shared.to_str().expect("utf8")],
22920        );
22921        let shared_str = shared.to_string_lossy().replace('\\', "/");
22922        let key =
22923            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&shared_str));
22924        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(key);
22925        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
22926        let theirs = "550e8400-e29b-41d4-a716-446655440080";
22927        std::fs::write(
22928            jsonl_dir.join(format!("{theirs}.jsonl")),
22929            format!(
22930                "{{\"type\":\"user\",\"cwd\":\"{shared_str}\"}}\n\
22931                 {{\"type\":\"tag\",\"tag\":\"forge:worker:shared\",\
22932                 \"project\":\"TestOrg/proj-a\"}}\n"
22933            ),
22934        )
22935        .expect("write proj-a's tagged transcript");
22936
22937        ws.enable_test_dispatch_intercept();
22938        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22939        let spawner = tokio::spawn(async move {
22940            facade
22941                .spawn_worker(
22942                    &SessionSlot::lead("TestOrg", "proj-b"),
22943                    "shared".to_owned(),
22944                    "charter".to_owned(),
22945                    None,
22946                    None,
22947                    false,
22948                    true,
22949                    None,
22950                    None,
22951                )
22952                .await
22953        });
22954        let resumed = poll_spawn_worker_field(&ws, |cmd| match cmd {
22955            Command::SpawnWorker { label, resume_existing, .. } if label == "shared" => {
22956                Some(resume_existing.clone())
22957            }
22958            _ => None,
22959        })
22960        .await
22961        .expect("the resume spawn dispatched");
22962        let _ = spawner.await.expect("facade task joins");
22963        assert_eq!(
22964            resumed, None,
22965            "proj-b must not adopt proj-a's session from the shared directory",
22966        );
22967
22968        // The proxy: proj-b's own tagged transcript IS adopted.
22969        let mine = "550e8400-e29b-41d4-a716-446655440081";
22970        std::fs::write(
22971            jsonl_dir.join(format!("{mine}.jsonl")),
22972            format!(
22973                "{{\"type\":\"user\",\"cwd\":\"{shared_str}\"}}\n\
22974                 {{\"type\":\"tag\",\"tag\":\"forge:worker:shared\",\
22975                 \"project\":\"TestOrg/proj-b\"}}\n"
22976            ),
22977        )
22978        .expect("write proj-b's tagged transcript");
22979        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
22980        let spawner = tokio::spawn(async move {
22981            facade
22982                .spawn_worker(
22983                    &SessionSlot::lead("TestOrg", "proj-b"),
22984                    "shared".to_owned(),
22985                    "charter".to_owned(),
22986                    None,
22987                    None,
22988                    false,
22989                    true,
22990                    None,
22991                    None,
22992                )
22993                .await
22994        });
22995        let resumed = poll_spawn_worker_field(&ws, |cmd| match cmd {
22996            Command::SpawnWorker { label, resume_existing, .. } if label == "shared" => {
22997                Some(resume_existing.clone())
22998            }
22999            _ => None,
23000        })
23001        .await
23002        .expect("the resume spawn dispatched");
23003        let _ = spawner.await.expect("facade task joins");
23004        assert_eq!(
23005            resumed.as_deref(),
23006            Some(mine),
23007            "proj-b's own tagged session is the one resumed",
23008        );
23009    }
23010
23011    /// A pre-field tag row is adopted once and stamped by that adoption:
23012    /// the second project's resume skips it, and a later unstamped row in
23013    /// a directory two projects compose is refused as ambiguous instead
23014    /// of being handed to either.
23015    #[tokio::test]
23016    async fn a_pre_field_transcript_is_adopted_once_and_stamped() {
23017        let repo = tempfile::tempdir().expect("repo");
23018        let main = repo.path().join("main");
23019        std::fs::create_dir_all(&main).expect("main dir");
23020        run_git_in(&main, &["init", "-q"]);
23021        run_git_in(&main, &["config", "user.email", "t@example.com"]);
23022        run_git_in(&main, &["config", "user.name", "Test"]);
23023        run_git_in(&main, &["config", "commit.gpgsign", "false"]);
23024        std::fs::write(main.join("README.md"), "seed").expect("write seed");
23025        run_git_in(&main, &["add", "."]);
23026        run_git_in(&main, &["commit", "-q", "-m", "init"]);
23027        let main = std::fs::canonicalize(&main).expect("canonical main");
23028        let holder = tempfile::tempdir().expect("holder");
23029        let linked_a = holder.path().join("proj-a");
23030        let linked_b = holder.path().join("proj-b");
23031        run_git_in(
23032            &main,
23033            &["worktree", "add", "-q", "-b", "proj-a", linked_a.to_str().expect("utf8")],
23034        );
23035        run_git_in(
23036            &main,
23037            &["worktree", "add", "-q", "-b", "proj-b", linked_b.to_str().expect("utf8")],
23038        );
23039        let cfg = tempfile::tempdir().expect("cfg");
23040        let forge_dir = cfg.path().join("forge");
23041        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23042        std::fs::write(
23043            forge_dir.join("forge.toml"),
23044            format!(
23045                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23046                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23047                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23048                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23049                linked_a.display()
23050            ),
23051        )
23052        .expect("write forge.toml");
23053        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23054        let store = tempfile::tempdir().expect("store dir");
23055        ws.install_db_for_test(
23056            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23057        );
23058
23059        let shared = main.join(".claude").join("worktrees").join("shared");
23060        std::fs::create_dir_all(shared.parent().expect("worktrees root")).expect("mkdir");
23061        run_git_in(
23062            &main,
23063            &["worktree", "add", "-q", "-b", "worktree-shared", shared.to_str().expect("utf8")],
23064        );
23065        let shared_str = shared.to_string_lossy().replace('\\', "/");
23066        let key =
23067            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&shared_str));
23068        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(key);
23069        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
23070        let theirs = "550e8400-e29b-41d4-a716-446655440082";
23071        let unstamped = format!(
23072            "{{\"type\":\"user\",\"cwd\":\"{shared_str}\"}}\n\
23073             {{\"type\":\"tag\",\"tag\":\"forge:worker:shared\"}}\n"
23074        );
23075        std::fs::write(jsonl_dir.join(format!("{theirs}.jsonl")), &unstamped)
23076            .expect("write the pre-field transcript");
23077
23078        let resume_as = |project: &'static str| {
23079            let ws = Arc::clone(&ws);
23080            async move {
23081                ws.enable_test_dispatch_intercept();
23082                let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23083                let spawner = tokio::spawn(async move {
23084                    facade
23085                        .spawn_worker(
23086                            &SessionSlot::lead("TestOrg", project),
23087                            "shared".to_owned(),
23088                            "charter".to_owned(),
23089                            None,
23090                            None,
23091                            false,
23092                            true,
23093                            None,
23094                            None,
23095                        )
23096                        .await
23097                });
23098                let resumed = poll_spawn_worker_field(&ws, |cmd| match cmd {
23099                    Command::SpawnWorker { label, resume_existing, .. } if label == "shared" => {
23100                        Some(resume_existing.clone())
23101                    }
23102                    _ => None,
23103                })
23104                .await;
23105                let _ = spawner.await.expect("facade task joins");
23106                resumed
23107            }
23108        };
23109
23110        // One project composes the directory, so the pre-field row is
23111        // adopted - and stamped by that adoption.
23112        let resumed = resume_as("proj-a").await.expect("dispatched");
23113        assert_eq!(resumed.as_deref(), Some(theirs), "the pre-field row is adopted once");
23114        let body = std::fs::read_to_string(jsonl_dir.join(format!("{theirs}.jsonl")))
23115            .expect("read the stamped transcript");
23116        assert!(
23117            body.contains("\"project\":\"TestOrg/proj-a\""),
23118            "the adoption stamped the row: {body:?}",
23119        );
23120
23121        // A second project composing the same directory now sees the
23122        // stamped row as proj-a's and starts fresh.
23123        ws.seed_test_project("proj-b", &linked_b.to_string_lossy());
23124        let resumed = resume_as("proj-b").await.expect("dispatched");
23125        assert_eq!(resumed, None, "the stamped row is not the second project's to adopt");
23126
23127        // An unstamped row in the now-shared directory is ambiguous: the
23128        // resume is refused rather than handed to either project.
23129        let newer = "550e8400-e29b-41d4-a716-446655440083";
23130        std::fs::write(jsonl_dir.join(format!("{newer}.jsonl")), &unstamped)
23131            .expect("write another pre-field transcript");
23132        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23133        let spawner = tokio::spawn(async move {
23134            facade
23135                .spawn_worker(
23136                    &SessionSlot::lead("TestOrg", "proj-b"),
23137                    "shared".to_owned(),
23138                    "charter".to_owned(),
23139                    None,
23140                    None,
23141                    false,
23142                    true,
23143                    None,
23144                    None,
23145                )
23146                .await
23147        });
23148        // A refusal returns without dispatching; a dispatch would block
23149        // on the intercept, so the timeout is the fail-fast guard.
23150        let err = tokio::time::timeout(std::time::Duration::from_secs(5), spawner)
23151            .await
23152            .expect("the refusal returns without dispatching")
23153            .expect("facade task joins")
23154            .expect_err("an ambiguous pre-field row is refused");
23155        let crate::mcp::workers::facade::WorkerSpawnError::ResumeLookupFailed { message, .. } = err
23156        else {
23157            panic!("the refusal is the lookup: {err:?}");
23158        };
23159        assert!(message.contains("predates project tags"), "the refusal says why: {message}");
23160    }
23161
23162    /// A stamp that fails after the ensure undoes the worktree it minted,
23163    /// so a refused adoption strands neither worktree nor branch.
23164    #[tokio::test]
23165    async fn a_failed_stamp_discards_the_worktree_the_resume_minted() {
23166        let repo = tempfile::tempdir().expect("repo");
23167        let main = repo.path().join("main");
23168        std::fs::create_dir_all(&main).expect("main dir");
23169        run_git_in(&main, &["init", "-q"]);
23170        run_git_in(&main, &["config", "user.email", "t@example.com"]);
23171        run_git_in(&main, &["config", "user.name", "Test"]);
23172        run_git_in(&main, &["config", "commit.gpgsign", "false"]);
23173        std::fs::write(main.join("README.md"), "seed").expect("write seed");
23174        run_git_in(&main, &["add", "."]);
23175        run_git_in(&main, &["commit", "-q", "-m", "init"]);
23176        let main = std::fs::canonicalize(&main).expect("canonical main");
23177        let cfg = tempfile::tempdir().expect("cfg");
23178        let forge_dir = cfg.path().join("forge");
23179        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23180        std::fs::write(
23181            forge_dir.join("forge.toml"),
23182            format!(
23183                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23184                 [[orgs.projects]]\nname = \"demo\"\npath = \"{}\"\n\
23185                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23186                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23187                main.display()
23188            ),
23189        )
23190        .expect("write forge.toml");
23191        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23192        let store = tempfile::tempdir().expect("store dir");
23193        ws.install_db_for_test(
23194            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23195        );
23196
23197        let shared = main.join(".claude").join("worktrees").join("shared");
23198        let shared_str = shared.to_string_lossy().replace('\\', "/");
23199        let key =
23200            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&shared_str));
23201        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(key);
23202        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
23203        let theirs = "550e8400-e29b-41d4-a716-446655440084";
23204        let transcript = jsonl_dir.join(format!("{theirs}.jsonl"));
23205        std::fs::write(
23206            &transcript,
23207            format!(
23208                "{{\"type\":\"user\",\"cwd\":\"{shared_str}\"}}\n\
23209                 {{\"type\":\"tag\",\"tag\":\"forge:worker:shared\"}}\n"
23210            ),
23211        )
23212        .expect("write the pre-field transcript");
23213        let mut perms = std::fs::metadata(&transcript).expect("stat").permissions();
23214        perms.set_readonly(true);
23215        std::fs::set_permissions(&transcript, perms).expect("make the transcript read-only");
23216
23217        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23218        let err = tokio::time::timeout(
23219            std::time::Duration::from_secs(10),
23220            facade.spawn_worker(
23221                &SessionSlot::lead("TestOrg", "demo"),
23222                "shared".to_owned(),
23223                "charter".to_owned(),
23224                None,
23225                None,
23226                false,
23227                true,
23228                None,
23229                None,
23230            ),
23231        )
23232        .await
23233        .expect("the refused spawn returns")
23234        .expect_err("a transcript that cannot be stamped refuses the resume");
23235        let crate::mcp::workers::facade::WorkerSpawnError::DispatchFailed { message } = err else {
23236            panic!("the refusal is the stamp: {err:?}");
23237        };
23238        assert!(
23239            message.contains("stamping the adopted transcript's project failed"),
23240            "the refusal names the stamp: {message}",
23241        );
23242        assert!(!shared.exists(), "the ensure's worktree was discarded with the refusal");
23243    }
23244
23245    /// The transcript lookup keys a directory by its sanitised path, so
23246    /// two non-git projects at `a-b` and `a/b` read the same transcripts.
23247    /// A pre-field tag there is not known to be either project's, so both
23248    /// refuse it instead of the first adopter taking the other's
23249    /// conversation.
23250    #[tokio::test]
23251    async fn colliding_transcript_keys_refuse_a_pre_field_adoption_in_both_projects() {
23252        let holder = tempfile::tempdir().expect("holder");
23253        let a_dir = holder.path().join("a-b");
23254        let b_dir = holder.path().join("a").join("b");
23255        std::fs::create_dir_all(&a_dir).expect("a-b dir");
23256        std::fs::create_dir_all(&b_dir).expect("a/b dir");
23257        let cfg = tempfile::tempdir().expect("cfg");
23258        let forge_dir = cfg.path().join("forge");
23259        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23260        std::fs::write(
23261            forge_dir.join("forge.toml"),
23262            format!(
23263                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23264                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23265                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
23266                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23267                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23268                a_dir.display(),
23269                b_dir.display()
23270            ),
23271        )
23272        .expect("write forge.toml");
23273        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23274        let store = tempfile::tempdir().expect("store dir");
23275        ws.install_db_for_test(
23276            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23277        );
23278
23279        let a_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
23280            &a_dir.to_string_lossy(),
23281        ));
23282        let b_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
23283            &b_dir.to_string_lossy(),
23284        ));
23285        assert_eq!(a_key, b_key, "the fixture's two roots sanitise to one transcript key");
23286        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(&a_key);
23287        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
23288        let theirs = "550e8400-e29b-41d4-a716-446655440090";
23289        std::fs::write(
23290            jsonl_dir.join(format!("{theirs}.jsonl")),
23291            "{\"type\":\"user\",\"cwd\":\"/somewhere\"}\n\
23292             {\"type\":\"tag\",\"tag\":\"forge:worker:shared\"}\n",
23293        )
23294        .expect("write the pre-field transcript");
23295
23296        let resume = |project: &'static str| {
23297            let ws = Arc::clone(&ws);
23298            async move {
23299                let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23300                tokio::time::timeout(
23301                    std::time::Duration::from_secs(5),
23302                    facade.spawn_worker(
23303                        &SessionSlot::lead("TestOrg", project),
23304                        "shared".to_owned(),
23305                        "charter".to_owned(),
23306                        None,
23307                        None,
23308                        false,
23309                        true,
23310                        None,
23311                        None,
23312                    ),
23313                )
23314                .await
23315                .expect("the spawn returns")
23316            }
23317        };
23318
23319        for project in ["proj-a", "proj-b"] {
23320            let err = match resume(project).await {
23321                Ok(reply) => panic!("{project} adopted the colliding transcript: {reply:?}"),
23322                Err(err) => err,
23323            };
23324            let crate::mcp::workers::facade::WorkerSpawnError::ResumeLookupFailed {
23325                message, ..
23326            } = err
23327            else {
23328                panic!("{project}'s refusal is the lookup: {err:?}");
23329            };
23330            assert!(
23331                message.contains("predates project tags"),
23332                "{project} refuses the ambiguous row: {message}",
23333            );
23334        }
23335    }
23336
23337    /// Two git projects whose composed worker trees sanitise to one key
23338    /// (`a-b` and `a/b`) read the same transcripts, so a pre-field tag
23339    /// there is ambiguous and both refuse it.
23340    #[tokio::test]
23341    async fn colliding_worktree_keys_refuse_a_pre_field_adoption_in_both_git_projects() {
23342        let holder = tempfile::tempdir().expect("holder");
23343        let a_dir = holder.path().join("a-b");
23344        let b_dir = holder.path().join("a").join("b");
23345        for dir in [&a_dir, &b_dir] {
23346            std::fs::create_dir_all(dir).expect("project dir");
23347            run_git_in(dir, &["init", "-q"]);
23348            run_git_in(dir, &["config", "user.email", "t@example.com"]);
23349            run_git_in(dir, &["config", "user.name", "Test"]);
23350            run_git_in(dir, &["config", "commit.gpgsign", "false"]);
23351            std::fs::write(dir.join("README.md"), "seed").expect("write seed");
23352            run_git_in(dir, &["add", "."]);
23353            run_git_in(dir, &["commit", "-q", "-m", "init"]);
23354        }
23355        let a_dir = std::fs::canonicalize(&a_dir).expect("canonical a-b");
23356        let b_dir = std::fs::canonicalize(&b_dir).expect("canonical a/b");
23357        let cfg = tempfile::tempdir().expect("cfg");
23358        let forge_dir = cfg.path().join("forge");
23359        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23360        std::fs::write(
23361            forge_dir.join("forge.toml"),
23362            format!(
23363                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23364                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23365                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
23366                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23367                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23368                a_dir.display(),
23369                b_dir.display()
23370            ),
23371        )
23372        .expect("write forge.toml");
23373        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23374        let store = tempfile::tempdir().expect("store dir");
23375        ws.install_db_for_test(
23376            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23377        );
23378
23379        let a_run = a_dir.join(".claude").join("worktrees").join("shared");
23380        let b_run = b_dir.join(".claude").join("worktrees").join("shared");
23381        let a_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
23382            &a_run.to_string_lossy(),
23383        ));
23384        let b_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
23385            &b_run.to_string_lossy(),
23386        ));
23387        assert_eq!(a_key, b_key, "the composed worktrees sanitise to one transcript key");
23388        let jsonl_dir = forge_sdk::projects_dir_for(cfg.path()).join(&a_key);
23389        std::fs::create_dir_all(&jsonl_dir).expect("jsonl dir");
23390        let theirs = "550e8400-e29b-41d4-a716-446655440091";
23391        std::fs::write(
23392            jsonl_dir.join(format!("{theirs}.jsonl")),
23393            "{\"type\":\"user\",\"cwd\":\"/somewhere\"}\n\
23394             {\"type\":\"tag\",\"tag\":\"forge:worker:shared\"}\n",
23395        )
23396        .expect("write the pre-field transcript");
23397
23398        let resume = |project: &'static str| {
23399            let ws = Arc::clone(&ws);
23400            async move {
23401                let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23402                tokio::time::timeout(
23403                    std::time::Duration::from_secs(15),
23404                    facade.spawn_worker(
23405                        &SessionSlot::lead("TestOrg", project),
23406                        "shared".to_owned(),
23407                        "charter".to_owned(),
23408                        None,
23409                        None,
23410                        false,
23411                        true,
23412                        None,
23413                        None,
23414                    ),
23415                )
23416                .await
23417                .expect("the spawn returns")
23418            }
23419        };
23420
23421        for project in ["proj-a", "proj-b"] {
23422            let err = match resume(project).await {
23423                Ok(reply) => panic!("{project} adopted the colliding transcript: {reply:?}"),
23424                Err(err) => err,
23425            };
23426            let crate::mcp::workers::facade::WorkerSpawnError::ResumeLookupFailed {
23427                message, ..
23428            } = err
23429            else {
23430                panic!("{project}'s refusal is the lookup: {err:?}");
23431            };
23432            assert!(
23433                message.contains("predates project tags"),
23434                "{project} refuses the ambiguous row: {message}",
23435            );
23436        }
23437    }
23438
23439    /// A git project whose checkout path collides with a non-git
23440    /// project's root does not read that root's transcripts: its worker
23441    /// runs under `.claude/worktrees`, so it is not counted as composing
23442    /// the directory.
23443    #[tokio::test]
23444    async fn a_colliding_git_checkout_is_not_counted_as_composing() {
23445        let holder = tempfile::tempdir().expect("holder");
23446        let a_dir = holder.path().join("a-b");
23447        let b_dir = holder.path().join("a").join("b");
23448        std::fs::create_dir_all(&a_dir).expect("a-b dir");
23449        std::fs::create_dir_all(&b_dir).expect("a/b dir");
23450        run_git_in(&b_dir, &["init", "-q"]);
23451        let a_dir = std::fs::canonicalize(&a_dir).expect("canonical a-b");
23452        let b_dir = std::fs::canonicalize(&b_dir).expect("canonical a/b");
23453        let cfg = tempfile::tempdir().expect("cfg");
23454        let forge_dir = cfg.path().join("forge");
23455        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23456        std::fs::write(
23457            forge_dir.join("forge.toml"),
23458            format!(
23459                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23460                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23461                 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
23462                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23463                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23464                a_dir.display(),
23465                b_dir.display()
23466            ),
23467        )
23468        .expect("write forge.toml");
23469        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23470        let store = tempfile::tempdir().expect("store dir");
23471        ws.install_db_for_test(
23472            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23473        );
23474
23475        assert_eq!(
23476            ws.projects_composing(&a_dir, "shared"),
23477            vec![("TestOrg".to_owned(), "proj-a".to_owned())],
23478            "the git checkout that only shares the sanitised key is not counted",
23479        );
23480    }
23481
23482    /// A worker directory that is gone still names the project whose
23483    /// worktree it was, even when the tag row spells the root through a
23484    /// symlink: nothing below the root exists, so nothing can be
23485    /// canonicalised, and both sides have to resolve the same existing
23486    /// prefix to compare equal.
23487    #[tokio::test]
23488    async fn a_gone_worker_dir_behind_a_symlink_still_names_its_project() {
23489        let holder = tempfile::tempdir().expect("holder");
23490        let real = holder.path().join("real");
23491        let project = real.join("proj");
23492        std::fs::create_dir_all(&project).expect("project dir");
23493        run_git_in(&project, &["init", "-q"]);
23494        let link = holder.path().join("link");
23495        std::os::unix::fs::symlink(&real, &link).expect("symlink");
23496
23497        let cfg = tempfile::tempdir().expect("cfg");
23498        let forge_dir = cfg.path().join("forge");
23499        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23500        std::fs::write(
23501            forge_dir.join("forge.toml"),
23502            format!(
23503                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23504                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23505                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23506                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23507                project.display()
23508            ),
23509        )
23510        .expect("write forge.toml");
23511        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23512        let store = tempfile::tempdir().expect("store dir");
23513        ws.install_db_for_test(
23514            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23515        );
23516
23517        let gone = link.join("proj").join(".claude/worktrees/shared");
23518        assert!(!gone.exists(), "the worker's tree is gone, which is the case under test");
23519        assert_eq!(
23520            ws.projects_composing(&gone, "shared"),
23521            vec![("TestOrg".to_owned(), "proj-a".to_owned())],
23522            "a gone worker dir behind a symlink names its project",
23523        );
23524    }
23525
23526    /// The other comparison is the sanitised key, and it is built from the
23527    /// resolved spelling: a run directory that resolves to a different path
23528    /// from the project's worker directory but sanitises to the same key
23529    /// still names the project.
23530    #[tokio::test]
23531    async fn a_sanitised_key_names_its_project_from_the_resolved_spelling() {
23532        let holder = tempfile::tempdir().expect("holder");
23533        let real = holder.path().join("real");
23534        let project = real.join("a-b");
23535        std::fs::create_dir_all(&project).expect("project dir");
23536        run_git_in(&project, &["init", "-q"]);
23537        // The run dir's existing ancestor: its last components are gone, so
23538        // only the prefix can be canonicalised.
23539        std::fs::create_dir_all(real.join("a")).expect("the run dir's existing ancestor");
23540        let link = holder.path().join("link");
23541        std::os::unix::fs::symlink(&real, &link).expect("symlink");
23542
23543        let cfg = tempfile::tempdir().expect("cfg");
23544        let forge_dir = cfg.path().join("forge");
23545        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23546        std::fs::write(
23547            forge_dir.join("forge.toml"),
23548            format!(
23549                "[[orgs]]\nname = \"TestOrg\"\naccounts = [\"acct-a\"]\n\
23550                 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
23551                 [[accounts]]\ndisplay_name = \"acct-a\"\ntoken = \"t\"\n\
23552                 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
23553                project.display()
23554            ),
23555        )
23556        .expect("write forge.toml");
23557        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23558        let store = tempfile::tempdir().expect("store dir");
23559        ws.install_db_for_test(
23560            crate::store::Db::open(&store.path().join("db.redb")).expect("open db"),
23561        );
23562
23563        // Resolves to `real/a/b/.claude/worktrees/shared`, which sanitises to
23564        // the same key as the project's `real/a-b/.claude/worktrees/shared`.
23565        // Both paths are far under the 200-character sanitise cap, so the key
23566        // is the plain substitution rather than a truncated-and-hashed one.
23567        let gone = link.join("a/b").join(".claude/worktrees/shared");
23568        assert!(!gone.exists(), "the run directory is gone, which is the case under test");
23569        assert_eq!(
23570            ws.projects_composing(&gone, "shared"),
23571            vec![("TestOrg".to_owned(), "proj-a".to_owned())],
23572            "a sanitised key from the resolved spelling names its project",
23573        );
23574    }
23575
23576    /// The mirror of the recreation case: the row says the worker runs in
23577    /// the project root, and the project is a repo. The ensure has to read
23578    /// the ROW, as the spawn does, or it mints a worktree the resumed
23579    /// session will not run in and nothing will clean up.
23580    #[tokio::test]
23581    async fn mcp_resume_does_not_ensure_a_worktree_the_row_does_not_use() {
23582        let project = tempfile::tempdir().expect("project dir");
23583        let cfg = tempfile::tempdir().expect("cfg dir");
23584        let (ws, key, worktree) = git_worker_fixture(&project, &cfg, "steward");
23585        let session_id = "550e8400-e29b-41d4-a716-446655440099";
23586        let dir = tempfile::tempdir().expect("tempdir");
23587        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
23588        ws.record_worker_row(
23589            &key,
23590            "steward",
23591            session_id,
23592            "charter",
23593            None,
23594            None,
23595            false,
23596            crate::mcp::workers::types::WorkerTree { is_git_repo: false, cwd: None },
23597            None,
23598        )
23599        .expect("seed the row that disagrees with the disk");
23600        ws.enable_test_dispatch_intercept();
23601        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23602
23603        let spawner = tokio::spawn(async move {
23604            facade
23605                .spawn_worker(
23606                    &lead_slot(),
23607                    "steward".to_owned(),
23608                    "charter".to_owned(),
23609                    None,
23610                    None,
23611                    false,
23612                    true,
23613                    None,
23614                    None,
23615                )
23616                .await
23617        });
23618        poll_spawn_worker_field(&ws, |cmd| match cmd {
23619            Command::SpawnWorker { label, .. } if label == "steward" => Some(()),
23620            _ => None,
23621        })
23622        .await
23623        .expect("the resume spawn dispatched");
23624        let _ = spawner.await.expect("facade task joins");
23625
23626        assert!(
23627            !worktree.exists(),
23628            "the ensure must follow the row's recorded gitness, not probe the project: \
23629             this row runs in the project root, so no worktree belongs here",
23630        );
23631    }
23632
23633    /// The sequence the regression turned on: a worker spawns - the row
23634    /// the spawn handler writes and the transcript claude writes in the
23635    /// worktree are what it leaves behind - then it is despawned, row
23636    /// deleted and worktree gone. The transcript is not the despawn's to
23637    /// remove, so the label spawns again onto the session it ran under.
23638    #[tokio::test]
23639    async fn a_despawned_label_spawns_again_onto_its_session() {
23640        let project = tempfile::tempdir().expect("project dir");
23641        let cfg = tempfile::tempdir().expect("cfg dir");
23642        let (ws, key, worktree) = git_worker_fixture(&project, &cfg, "steward");
23643        let worktree_str = worktree.to_string_lossy().replace('\\', "/");
23644        let session_id = "550e8400-e29b-41d4-a716-446655440099";
23645        run_git_in(
23646            project.path(),
23647            &["worktree", "add", "-q", "-b", "worktree-steward", worktree_str.as_str()],
23648        );
23649        write_tagged_transcript(&cfg, &worktree, session_id, "steward");
23650        let _store = seed_stored_session(&ws, "steward", session_id);
23651        assert!(ws.delete_worker_row(&key, "steward").expect("delete the row"));
23652        run_git_in(project.path(), &["worktree", "remove", "--force", worktree_str.as_str()]);
23653        ws.enable_test_dispatch_intercept();
23654        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23655
23656        let spawner = tokio::spawn(async move {
23657            facade
23658                .spawn_worker(
23659                    &lead_slot(),
23660                    "steward".to_owned(),
23661                    "charter".to_owned(),
23662                    None,
23663                    None,
23664                    false,
23665                    true,
23666                    None,
23667                    None,
23668                )
23669                .await
23670        });
23671        let Command::SpawnWorker { resume_existing, mcp_families, return_to, .. } =
23672            take_dispatched_spawn_worker(&ws).await
23673        else {
23674            panic!("expected the resume to dispatch a SpawnWorker");
23675        };
23676        assert_eq!(
23677            resume_existing.as_deref(),
23678            Some(session_id),
23679            "the label resumes the session its transcript still names",
23680        );
23681        // The row is gone (that is what the despawn did), so the spawn
23682        // legitimately carries no selection: every family.
23683        assert_eq!(mcp_families, None, "no row means no narrowing");
23684        // Answer the way `handle_spawn_worker` does for a resume; what
23685        // the assertion below reads is the facade's own mapping of it,
23686        // which reports a fallback for a resume that found nothing.
23687        return_to
23688            .expect("a dispatch off the workspace's own bus carries a reply channel")
23689            .send(Ok(WorkerSpawnReply {
23690                session_id: session_id.to_owned(),
23691                tag: forge_primitives::worker_tag("steward"),
23692                mcp_families: None,
23693                rate_limited_account: None,
23694                durability_warning: None,
23695                worktree: None,
23696                session_choice: SessionChoice::Resumed,
23697            }))
23698            .expect("the facade is awaiting its reply");
23699        let reply = spawner.await.expect("facade task joins").expect("the resume is not an error");
23700
23701        assert_eq!(
23702            reply.session_choice,
23703            SessionChoice::Resumed,
23704            "a resume that found its session reports the resume, not a fallback",
23705        );
23706        assert!(worktree.exists(), "and lands back in the worktree the despawn removed");
23707    }
23708
23709    /// A worker in a project that is not a git repo runs in the project
23710    /// root, so its sessions live in the project's own transcript
23711    /// directory - the one holding every session that ever ran there.
23712    /// The label's tag is what picks its own out of that, once the row a
23713    /// despawn deleted is gone.
23714    #[tokio::test]
23715    async fn a_despawned_non_git_label_spawns_again_onto_its_session() {
23716        let project = tempfile::tempdir().expect("project dir");
23717        let cfg = tempfile::tempdir().expect("cfg dir");
23718        let (ws, key, project_path, session_id) = resumable_worker_fixture(&project, &cfg);
23719        write_tagged_transcript(&cfg, &project_path, &session_id, "steward");
23720        // Newer, and not this label's: the directory is shared, so
23721        // newest-in-directory would hand the lead's own work to the
23722        // worker.
23723        write_tagged_transcript(
23724            &cfg,
23725            &project_path,
23726            "550e8400-e29b-41d4-a716-4466554400ff",
23727            "lead",
23728        );
23729        // The pick is by mtime, and a tie goes to the greater session id.
23730        // Pin the two, so "the test's is newer" is a fact of the fixture
23731        // rather than of the filesystem's clock granularity: on a coarse
23732        // one both writes land in the same tick, and the tie then hands
23733        // the win to the fixture's `660e...` over the test's `550e...`.
23734        pin_mtime(&cfg, &project_path, TRANSCRIPT_ONLY_ID, TRANSCRIPT_OLDER_SECS);
23735        pin_mtime(&cfg, &project_path, &session_id, TRANSCRIPT_NEWER_SECS);
23736        assert!(ws.delete_worker_row(&key, "steward").expect("delete the row"));
23737        ws.enable_test_dispatch_intercept();
23738        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23739
23740        let spawner = tokio::spawn(async move {
23741            facade
23742                .spawn_worker(
23743                    &lead_slot(),
23744                    "steward".to_owned(),
23745                    "charter".to_owned(),
23746                    None,
23747                    None,
23748                    false,
23749                    true,
23750                    None,
23751                    None,
23752                )
23753                .await
23754        });
23755        let resumed = poll_spawn_worker_field(&ws, |cmd| match cmd {
23756            Command::SpawnWorker { label, resume_existing, .. } if label == "steward" => {
23757                Some(resume_existing.clone())
23758            }
23759            _ => None,
23760        })
23761        .await
23762        .expect("the despawned label spawns again");
23763        let _ = spawner.await.expect("facade task joins");
23764
23765        assert_eq!(
23766            resumed.as_deref(),
23767            Some(session_id.as_str()),
23768            "the label resumes its own session, not the newest one in the directory",
23769        );
23770    }
23771
23772    /// The other half: a resume with nothing to resume starts fresh, and
23773    /// the reply the tool renders says which happened - a caller that
23774    /// asked for old context is never left believing it got some.
23775    #[tokio::test]
23776    async fn a_resume_with_nothing_to_resume_reports_a_fresh_session() {
23777        let project = tempfile::tempdir().expect("project dir");
23778        let cfg = tempfile::tempdir().expect("cfg dir");
23779        let (ws, _key, _worktree) = git_worker_fixture(&project, &cfg, "ghost");
23780        ws.enable_test_dispatch_intercept();
23781        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23782
23783        let spawner = tokio::spawn(async move {
23784            facade
23785                .spawn_worker(
23786                    &lead_slot(),
23787                    "ghost".to_owned(),
23788                    "charter".to_owned(),
23789                    None,
23790                    None,
23791                    false,
23792                    true,
23793                    None,
23794                    None,
23795                )
23796                .await
23797        });
23798        let Command::SpawnWorker { resume_existing, return_to, .. } =
23799            take_dispatched_spawn_worker(&ws).await
23800        else {
23801            panic!("expected the fallback to dispatch a SpawnWorker");
23802        };
23803        assert!(resume_existing.is_none(), "nothing to resume, so the session is fresh");
23804        // Answer the way `handle_spawn_worker` does for a spawn carrying
23805        // no `resume_existing`, which is what a fallback dispatches -
23806        // whatever flag the caller passed. Whether it asked to resume and
23807        // found nothing is known only to the facade, which restates it.
23808        return_to
23809            .expect("a dispatch off the workspace's own bus carries a reply channel")
23810            .send(Ok(WorkerSpawnReply {
23811                session_id: "fresh-session-uuid".into(),
23812                tag: forge_primitives::worker_tag("ghost"),
23813                mcp_families: None,
23814                rate_limited_account: None,
23815                durability_warning: None,
23816                worktree: None,
23817                session_choice: SessionChoice::Fresh,
23818            }))
23819            .expect("the facade is awaiting its reply");
23820        let reply =
23821            spawner.await.expect("facade task joins").expect("the fallback is not an error");
23822
23823        assert_eq!(
23824            reply.session_choice,
23825            SessionChoice::FreshWithoutPrior,
23826            "a resume that found nothing reports the fallback, not a plain fresh spawn",
23827        );
23828    }
23829
23830    /// The third outcome, one arm over from the fallback: a spawn that
23831    /// never asked to resume reports a plain fresh session, so a lead that
23832    /// set no flag is not told a lookup found nothing.
23833    #[tokio::test]
23834    async fn a_spawn_without_the_flag_reports_a_plain_fresh_session() {
23835        let project = tempfile::tempdir().expect("project dir");
23836        let cfg = tempfile::tempdir().expect("cfg dir");
23837        let (ws, _key, _worktree) = git_worker_fixture(&project, &cfg, "ghost");
23838        ws.enable_test_dispatch_intercept();
23839        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23840
23841        let spawner = tokio::spawn(async move {
23842            facade
23843                .spawn_worker(
23844                    &lead_slot(),
23845                    "ghost".to_owned(),
23846                    "charter".to_owned(),
23847                    None,
23848                    None,
23849                    false,
23850                    false,
23851                    None,
23852                    None,
23853                )
23854                .await
23855        });
23856        let Command::SpawnWorker { resume_existing, return_to, .. } =
23857            take_dispatched_spawn_worker(&ws).await
23858        else {
23859            panic!("expected the plain spawn to dispatch a SpawnWorker");
23860        };
23861        assert!(resume_existing.is_none(), "the flag was not set, so nothing is resumed");
23862        return_to
23863            .expect("a dispatch off the workspace's own bus carries a reply channel")
23864            .send(Ok(WorkerSpawnReply {
23865                session_id: "fresh-session-uuid".into(),
23866                tag: forge_primitives::worker_tag("ghost"),
23867                mcp_families: None,
23868                rate_limited_account: None,
23869                durability_warning: None,
23870                worktree: None,
23871                session_choice: SessionChoice::Fresh,
23872            }))
23873            .expect("the facade is awaiting its reply");
23874        let reply = spawner.await.expect("facade task joins").expect("the spawn is not an error");
23875
23876        assert_eq!(
23877            reply.session_choice,
23878            SessionChoice::Fresh,
23879            "a spawn that never asked to resume is not a resume that found nothing",
23880        );
23881    }
23882
23883    /// The first `SpawnWorker` a facade dispatched, taken whole so the
23884    /// test can answer its reply channel the way the spawn handler would.
23885    async fn take_dispatched_spawn_worker(ws: &Arc<Workspace>) -> Command {
23886        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
23887        loop {
23888            if let Some(cmd) = ws
23889                .drain_test_dispatch_buffer()
23890                .into_iter()
23891                .find(|c| matches!(c, Command::SpawnWorker { .. }))
23892            {
23893                return cmd;
23894            }
23895            assert!(std::time::Instant::now() < deadline, "no SpawnWorker was dispatched");
23896            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
23897        }
23898    }
23899
23900    fn run_git_in(dir: &std::path::Path, args: &[&str]) {
23901        let status = std::process::Command::new("git")
23902            .args(args)
23903            .current_dir(dir)
23904            .status()
23905            .expect("spawn git");
23906        assert!(status.success(), "git {args:?} failed in {dir:?}");
23907    }
23908
23909    /// Record `id` as the store's answer for `label` in the `demo`
23910    /// project. A resume reads the store, so a test that expects one has
23911    /// to put the id there; `new_for_test` boots without a store, so
23912    /// this installs one. The tempdir must outlive the caller.
23913    fn seed_stored_session(ws: &Arc<Workspace>, label: &str, id: &str) -> tempfile::TempDir {
23914        let dir = tempfile::tempdir().expect("tempdir");
23915        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
23916        ws.record_session_id("TestOrg", "demo", label, id);
23917        dir
23918    }
23919
23920    /// Poll the intercept buffer until `pick` matches a SpawnWorker or
23921    /// the deadline passes; the dispatch happens inside the facade's
23922    /// awaited dispatch and the buffer holds the command until drained.
23923    async fn poll_spawn_worker_field<T>(
23924        ws: &Arc<Workspace>,
23925        pick: impl Fn(&Command) -> Option<T>,
23926    ) -> Option<T> {
23927        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
23928        loop {
23929            let drained = ws.drain_test_dispatch_buffer();
23930            let picked = drained.iter().find_map(&pick);
23931            if picked.is_some() {
23932                return picked;
23933            }
23934            if std::time::Instant::now() >= deadline {
23935                return None;
23936            }
23937            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
23938        }
23939    }
23940
23941    /// D7: a resume that states no tree resumes the one the ROW records.
23942    /// The subprocess has to start where the JSONL lives, and for a
23943    /// worker the spawn pointed at another repository that is its own
23944    /// tree - the project's path would send the resume looking under a
23945    /// directory nothing was ever written to.
23946    #[tokio::test]
23947    async fn a_resume_without_a_stated_cwd_adopts_the_rows_tree() {
23948        let project = tempfile::tempdir().expect("project dir");
23949        let cfg = tempfile::tempdir().expect("cfg dir");
23950        run_git_in(project.path(), &["init", "-q"]);
23951        let forge_dir = cfg.path().join("forge");
23952        std::fs::create_dir_all(&forge_dir).expect("forge dir");
23953        std::fs::write(
23954            forge_dir.join("forge.toml"),
23955            format!(
23956                r#"
23957[[orgs]]
23958name = "TestOrg"
23959accounts = ["acct-a"]
23960[[orgs.projects]]
23961name = "demo"
23962path = "{}"
23963
23964[[accounts]]
23965display_name = "acct-a"
23966token = "t"
23967models = ["claude-sonnet-5"]
23968provider = "anthropic"
23969"#,
23970                project.path().to_string_lossy().replace('\\', "/")
23971            ),
23972        )
23973        .expect("write forge.toml");
23974
23975        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
23976        let view = ws.list_projects().into_iter().find(|v| v.name == "demo").expect("project");
23977        let _store = seed_stored_session(&ws, "steward", "550e8400-e29b-41d4-a716-446655440099");
23978        // The tree the spawn resolved: a second repository, with the
23979        // worktree claude's `--worktree` would have forked there.
23980        let sibling = tempfile::tempdir().expect("sibling repo");
23981        run_git_in(sibling.path(), &["init", "-q"]);
23982        run_git_in(sibling.path(), &["config", "commit.gpgsign", "false"]);
23983        let root = std::fs::canonicalize(sibling.path()).expect("canonical sibling");
23984        ws.seed_test_worker_row_at(&view.key, "steward", &root.to_string_lossy(), true);
23985        let worktree = root.join(".claude").join("worktrees").join("steward");
23986        run_git_in(
23987            &root,
23988            &["worktree", "add", "-q", "-b", "worktree-steward", worktree.to_str().expect("utf8")],
23989        );
23990        ws.enable_test_dispatch_intercept();
23991
23992        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
23993        let spawner = tokio::spawn(async move {
23994            facade
23995                .spawn_worker(
23996                    &lead_slot(),
23997                    "steward".to_owned(),
23998                    "charter".to_owned(),
23999                    None,
24000                    None,
24001                    false,
24002                    true,
24003                    None,
24004                    None,
24005                )
24006                .await
24007        });
24008
24009        let dispatched = poll_spawn_worker_field(&ws, |cmd| match cmd {
24010            Command::SpawnWorker { cwd, .. } => Some(cwd.clone()),
24011            _ => None,
24012        })
24013        .await;
24014        assert_eq!(
24015            dispatched.flatten().as_deref(),
24016            Some(root.to_string_lossy().as_ref()),
24017            "the resume carries the row's tree, not the project's",
24018        );
24019        spawner.abort();
24020    }
24021
24022    /// A resume that states a DIFFERENT tree than the row's is refused
24023    /// rather than started: claude finds a conversation by the project
24024    /// key derived from the directory it runs in, so a moved resume dies
24025    /// "No conversation found" - the history is keyed to its tree, and a
24026    /// worker moves trees only by being despawned and spawned fresh.
24027    #[tokio::test]
24028    async fn a_resume_stating_a_different_tree_is_refused() {
24029        let project = tempfile::tempdir().expect("project dir");
24030        let cfg = tempfile::tempdir().expect("cfg dir");
24031        run_git_in(project.path(), &["init", "-q"]);
24032        let forge_dir = cfg.path().join("forge");
24033        std::fs::create_dir_all(&forge_dir).expect("forge dir");
24034        std::fs::write(
24035            forge_dir.join("forge.toml"),
24036            format!(
24037                r#"
24038[[orgs]]
24039name = "TestOrg"
24040accounts = ["acct-a"]
24041[[orgs.projects]]
24042name = "demo"
24043path = "{}"
24044
24045[[accounts]]
24046display_name = "acct-a"
24047token = "t"
24048models = ["claude-sonnet-5"]
24049provider = "anthropic"
24050"#,
24051                project.path().to_string_lossy().replace('\\', "/")
24052            ),
24053        )
24054        .expect("write forge.toml");
24055
24056        let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
24057        let view = ws.list_projects().into_iter().find(|v| v.name == "demo").expect("project");
24058        let _store = seed_stored_session(&ws, "steward", "550e8400-e29b-41d4-a716-446655440099");
24059        // The row the history lives under: its own repository.
24060        let home_tree = tempfile::tempdir().expect("worker tree");
24061        run_git_in(home_tree.path(), &["init", "-q"]);
24062        let home_root = std::fs::canonicalize(home_tree.path()).expect("canonical home");
24063        ws.seed_test_worker_row_at(&view.key, "steward", &home_root.to_string_lossy(), true);
24064        // A different tree the caller asks the resume to move to.
24065        let other = tempfile::tempdir().expect("other tree");
24066        run_git_in(other.path(), &["init", "-q"]);
24067        let other_root = std::fs::canonicalize(other.path()).expect("canonical other");
24068        ws.enable_test_dispatch_intercept();
24069
24070        let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(&ws);
24071        // The timeout is the assertion for the other direction: a
24072        // resume that dispatched instead of refusing would sit here
24073        // waiting on a reply nobody sends, which times out the test
24074        // rather than failing it with this message.
24075        let error = tokio::time::timeout(
24076            std::time::Duration::from_secs(5),
24077            facade.spawn_worker(
24078                &lead_slot(),
24079                "steward".to_owned(),
24080                "charter".to_owned(),
24081                None,
24082                None,
24083                false,
24084                true,
24085                None,
24086                Some(other_root.to_string_lossy().into_owned()),
24087            ),
24088        )
24089        .await
24090        .expect("the refusal answers without dispatching")
24091        .expect_err("a resume cannot move the worker's tree");
24092
24093        let crate::mcp::workers::facade::WorkerSpawnError::DispatchFailed { message } = error
24094        else {
24095            panic!("the refusal is a dispatch failure");
24096        };
24097        assert!(
24098            message.contains("history"),
24099            "the refusal says why the tree cannot move: {message}",
24100        );
24101        assert!(
24102            ws.drain_test_dispatch_buffer().is_empty(),
24103            "nothing was dispatched for a refused resume",
24104        );
24105
24106        // The control, and the arm a guard that refused every stated tree
24107        // would break: a resume stating the row's OWN tree is accepted, and
24108        // dispatches with that tree.
24109        let stated = home_root.to_string_lossy().into_owned();
24110        let spawner = tokio::spawn(async move {
24111            facade
24112                .spawn_worker(
24113                    &lead_slot(),
24114                    "steward".to_owned(),
24115                    "charter".to_owned(),
24116                    None,
24117                    None,
24118                    false,
24119                    true,
24120                    None,
24121                    Some(stated),
24122                )
24123                .await
24124        });
24125        let dispatched = poll_spawn_worker_field(&ws, |cmd| match cmd {
24126            Command::SpawnWorker { cwd, .. } => Some(cwd.clone()),
24127            _ => None,
24128        })
24129        .await;
24130        assert_eq!(
24131            dispatched.flatten().as_deref(),
24132            Some(home_root.to_string_lossy().as_ref()),
24133            "a resume stating the tree the history lives in is accepted and carries it",
24134        );
24135        spawner.abort();
24136    }
24137
24138    /// `--new`: the same fixture comes up fresh, so the worker that
24139    /// WOULD have resumed spawns fresh. Paired with the resume test
24140    /// deliberately - against a fixture with nothing stored both arms
24141    /// yield None and the branch is unobservable.
24142    #[tokio::test]
24143    async fn force_new_spawns_fresh_the_worker_the_scan_would_have_resumed() {
24144        let project = tempfile::tempdir().expect("project dir");
24145        let cfg = tempfile::tempdir().expect("cfg dir");
24146        let (ws, key, _path, _session_id) = resumable_worker_fixture(&project, &cfg);
24147
24148        ws.respawn_workers_for_lead(&lead_slot(), key, true);
24149
24150        let dispatched = await_spawn_worker(&ws).await;
24151        let spawns: Vec<&Command> =
24152            dispatched.iter().filter(|c| matches!(c, Command::SpawnWorker { .. })).collect();
24153        assert_eq!(spawns.len(), 1, "force_new still spawns the persisted worker");
24154        let Command::SpawnWorker { resume_existing, .. } = spawns[0] else {
24155            panic!("expected SpawnWorker");
24156        };
24157        assert!(
24158            resume_existing.is_none(),
24159            "force_new skips the scan, so a resumable worker still spawns fresh",
24160        );
24161    }
24162}
24163
24164#[cfg(test)]
24165#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
24166mod async_worker_spawn_failure_tests {
24167    use super::*;
24168    use crate::mcp::workers::types::WorkerEntry;
24169    use forge_agent::client::SpawnFailureKind;
24170
24171    fn fake_worker(label: &str, worker_key: &str, lead_id: &str, is_git: bool) -> WorkerEntry {
24172        WorkerEntry {
24173            label: label.to_owned(),
24174            charter: "test".to_owned(),
24175            slot: SessionSlot::from_str_for_test(worker_key),
24176            session_id: None,
24177            status: forge_primitives::WorkerLiveness::Spawning,
24178            spawned_at: std::time::SystemTime::UNIX_EPOCH,
24179            spawned_by: SessionSlot::from_str_for_test(lead_id),
24180            needs_tag: true,
24181            is_git_repo_at_spawn: is_git,
24182            cwd: None,
24183            diagnostic: None,
24184            kick: None,
24185        }
24186    }
24187
24188    /// Seed the workspace pool with a stub Agent so the notice
24189    /// dispatch's `pool.lock().contains_key(&lead_key)` lead-
24190    /// resolution check passes. Mirrors the `install_fake_session_task`
24191    /// helper used by the migration tests.
24192    fn install_lead_in_pool(workspace: &Arc<Workspace>, lead_id: &str) -> SessionSlot {
24193        let key = SessionSlot::from_str_for_test(lead_id);
24194        let (handle, _agent_rx) = Workspace::testing_stub_handle();
24195        workspace.pool.lock().insert(
24196            key.clone(),
24197            PooledAgent {
24198                handle: Arc::new(handle),
24199                account: AccountKey("test".to_owned()),
24200                permission_mode: None,
24201                registration: None,
24202                session_id: "pooled-session".to_owned(),
24203            },
24204        );
24205        key
24206    }
24207
24208    /// Pins the lookup predicate the Connected catalog-mirror guard (in
24209    /// session_task) depends on: worker_lookup_for_session keyed by the
24210    /// worker's own id resolves a seeded worker (so its tag-less mirror
24211    /// is skipped) and not a lead/regular session. The guard's
24212    /// end-to-end skip is exercised in production.
24213    #[test]
24214    fn worker_lookup_drives_catalog_mirror_skip() {
24215        let (workspace, _rx) = Workspace::testing_stub();
24216        let project_key = ProjectKey::new("proj-x");
24217        let worker_key = "worker-uuid";
24218        workspace.insert_live_worker(
24219            &project_key,
24220            fake_worker("reviewer", worker_key, "lead-uuid", false),
24221        );
24222        assert!(
24223            workspace
24224                .worker_lookup_for_session(&SessionSlot::from_str_for_test(worker_key))
24225                .is_some(),
24226            "seeded worker must be detected so its tag-less catalog mirror is skipped"
24227        );
24228        assert!(
24229            workspace
24230                .worker_lookup_for_session(&SessionSlot::from_str_for_test("lead-uuid"))
24231                .is_none(),
24232            "the lead is not a live worker, so it is still mirrored into the catalog"
24233        );
24234    }
24235
24236    /// #146: async worktree-creation failure → notice envelope
24237    /// dispatched to the lead's chat AND the WorkerEntry rolled
24238    /// back. Verifies both effects in one go.
24239    #[tokio::test]
24240    async fn async_failure_with_worktree_classification_dispatches_notice_and_rolls_back() {
24241        let (workspace, mut update_rx) = Workspace::testing_stub();
24242        workspace.enable_test_dispatch_intercept();
24243
24244        let project_key = ProjectKey::new("proj-x");
24245        let worker_key = "worker-uuid";
24246        let lead_id = "lead-uuid";
24247        workspace
24248            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24249        let lead_key = install_lead_in_pool(&workspace, lead_id);
24250        // The ConnectionFailed arm records the failure before this handler
24251        // runs, and the rollback deletes the row it would have drawn.
24252        let worker_slot = SessionSlot::from_str_for_test(worker_key);
24253        workspace.record_spawn_failure_for_test(&worker_slot, "the subprocess exited");
24254
24255        let handled = workspace.handle_async_worker_spawn_failure(
24256            &worker_slot,
24257            "fatal: 'reviewer' is already used by worktree at /a/b/c",
24258            SpawnFailureKind::Unclassified,
24259        );
24260        assert!(handled, "async worker failure path must consume the failure");
24261
24262        // Notice dispatched via Command::Prompt to the lead's key.
24263        let dispatched = workspace.drain_test_dispatch_buffer();
24264        let prompts: Vec<&Command> = dispatched
24265            .iter()
24266            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
24267            .collect();
24268        assert_eq!(prompts.len(), 1, "exactly one WorkerSpawnFailedNotice envelope");
24269        if let Command::Prompt { key, text, .. } | Command::PromptUnder { key, text, .. } =
24270            prompts[0]
24271        {
24272            assert_eq!(*key, lead_key, "notice targets the lead session id");
24273            assert!(text.starts_with("[Worker 'reviewer' spawn failed"));
24274            assert!(text.contains("already used by worktree"));
24275        }
24276        // And the echo a view draws: the CLI does not paint a prompt it was
24277        // handed on stdin, so without this the page shows nothing at all.
24278        let echoed = std::iter::from_fn(|| update_rx.try_recv().ok()).any(|frame| {
24279            matches!(
24280                std::sync::Arc::unwrap_or_clone(frame.update),
24281                SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. }
24282                    if key == lead_key
24283                        && matches!(wrapped.kind, WrappedKind::WorkerSpawnFailedNotice)
24284            )
24285        });
24286        assert!(echoed, "the notice reaches the chat as the update a view draws");
24287
24288        // WorkerEntry rolled back: live_workers is empty.
24289        assert!(
24290            workspace.list_live_workers(&project_key).is_empty(),
24291            "WorkerEntry removed on async failure",
24292        );
24293        assert_eq!(
24294            workspace.spawn_failure(&worker_slot),
24295            None,
24296            "the failure record goes with the row the rollback deletes",
24297        );
24298    }
24299
24300    /// #146 + #245 Layer C: async failure with a non-worktree-classified
24301    /// message must NOT dispatch a lead-notice; the entry transitions to
24302    /// `WorkerLiveness::Failed` with the message as diagnostic, so the
24303    /// user sees the failure surfaced on the row rather than the worker
24304    /// silently vanishing.
24305    #[tokio::test]
24306    async fn async_failure_without_worktree_classification_transitions_to_failed() {
24307        let (workspace, _update_rx) = Workspace::testing_stub();
24308        workspace.enable_test_dispatch_intercept();
24309
24310        let project_key = ProjectKey::new("proj-x");
24311        let worker_key = "worker-uuid";
24312        let lead_id = "lead-uuid";
24313        workspace
24314            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24315        install_lead_in_pool(&workspace, lead_id);
24316
24317        let handled = workspace.handle_async_worker_spawn_failure(
24318            &SessionSlot::from_str_for_test(worker_key),
24319            "agent spawn failed: subprocess exited with code 2",
24320            SpawnFailureKind::Unclassified,
24321        );
24322        assert!(handled);
24323
24324        let dispatched = workspace.drain_test_dispatch_buffer();
24325        let prompts: Vec<&Command> = dispatched
24326            .iter()
24327            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
24328            .collect();
24329        assert!(prompts.is_empty(), "non-worktree classifier outcome must NOT dispatch a notice");
24330        let entries = workspace.list_live_workers(&project_key);
24331        assert_eq!(entries.len(), 1, "non-worktree failure keeps the entry visible");
24332        assert!(
24333            matches!(entries[0].status, forge_primitives::WorkerLiveness::Failed),
24334            "non-worktree failure transitions to Failed; got {:?}",
24335            entries[0].status,
24336        );
24337        assert_eq!(
24338            entries[0].diagnostic.as_deref(),
24339            Some("agent spawn failed: subprocess exited with code 2"),
24340            "diagnostic captures the ConnectionFailed message",
24341        );
24342    }
24343
24344    /// #146: non-worker session_key (e.g. a lead failure) returns
24345    /// false and changes nothing - the caller's existing
24346    /// ConnectionFailed flow proceeds unchanged.
24347    #[tokio::test]
24348    async fn async_failure_on_non_worker_session_is_no_op() {
24349        let (workspace, _update_rx) = Workspace::testing_stub();
24350        workspace.enable_test_dispatch_intercept();
24351        let unknown = SessionSlot::from_str_for_test("not-a-worker");
24352        let handled = workspace.handle_async_worker_spawn_failure(
24353            &unknown,
24354            "some unrelated error",
24355            SpawnFailureKind::Unclassified,
24356        );
24357        assert!(!handled);
24358        assert!(workspace.drain_test_dispatch_buffer().is_empty());
24359    }
24360
24361    /// #146: double-fire safety - a second ConnectionFailed for the
24362    /// same worker (after the first call removed its WorkerEntry on
24363    /// the WORKTREE-classified path) must be a no-op rather than
24364    /// re-dispatching the notice.
24365    ///
24366    /// The message used here MUST classify as worktree-creation
24367    /// failure - that's the path that still removes the entry under
24368    /// #245 Layer C. The non-worktree path transitions to Failed
24369    /// instead of removing, so it wouldn't exercise the
24370    /// double-fire-after-removal semantics this test is pinning.
24371    /// `classify_worker_spawn_failure` validates the predicate
24372    /// upfront so a future rewording that breaks the contract
24373    /// surfaces here instead of as a confusing failure further down.
24374    #[tokio::test]
24375    async fn async_failure_double_fire_is_no_op() {
24376        let (workspace, _update_rx) = Workspace::testing_stub();
24377        workspace.enable_test_dispatch_intercept();
24378
24379        let project_key = ProjectKey::new("proj-x");
24380        let worker_key = "worker-uuid";
24381        let lead_id = "lead-uuid";
24382        workspace
24383            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24384        install_lead_in_pool(&workspace, lead_id);
24385
24386        let session_key = SessionSlot::from_str_for_test(worker_key);
24387        let worktree_msg = "fatal: 'reviewer' is already used by worktree at /a";
24388        // Pin the test's premise: this message must classify as
24389        // WorktreeCreationFailed so we exercise the remove-then-no-op
24390        // path. If a future change to the classifier breaks this
24391        // assumption, the test below would change shape (transition-
24392        // to-Failed isn't a no-op on re-fire).
24393        assert!(
24394            matches!(
24395                crate::mcp::workers::facade::classify_worker_spawn_failure(
24396                    worktree_msg,
24397                    true,
24398                    SpawnFailureKind::Unclassified,
24399                ),
24400                crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { .. },
24401            ),
24402            "test fixture must classify as worktree failure to exercise the removal path",
24403        );
24404
24405        assert!(workspace.handle_async_worker_spawn_failure(
24406            &session_key,
24407            worktree_msg,
24408            SpawnFailureKind::Unclassified
24409        ));
24410        let _ = workspace.drain_test_dispatch_buffer();
24411
24412        // Second call: WorkerEntry already gone, returns false, no
24413        // new dispatch.
24414        assert!(!workspace.handle_async_worker_spawn_failure(
24415            &session_key,
24416            worktree_msg,
24417            SpawnFailureKind::Unclassified
24418        ));
24419        assert!(workspace.drain_test_dispatch_buffer().is_empty());
24420    }
24421
24422    fn install_db(workspace: &Arc<Workspace>) -> tempfile::TempDir {
24423        let dir = tempfile::tempdir().expect("tempdir");
24424        workspace
24425            .install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).expect("db"));
24426        dir
24427    }
24428
24429    /// The labels with a persisted row in `project_key`. The store is
24430    /// keyed by `(org, name, label)`, so the key has to resolve before
24431    /// the rows can be read.
24432    fn persisted_labels(workspace: &Arc<Workspace>, project_key: &ProjectKey) -> Vec<String> {
24433        let project = workspace.project_for_key(project_key).expect("seeded project");
24434        let guard = workspace.db.lock();
24435        crate::store::sessions::list_for_project(
24436            guard.as_ref().expect("db"),
24437            &project.org,
24438            &project.name,
24439        )
24440        .expect("list")
24441        .into_iter()
24442        .map(|w| w.label)
24443        .collect()
24444    }
24445
24446    /// #2: a worktree-creation failure is a hard removal (the worker
24447    /// never started), so it deletes the persisted dynamic-worker row -
24448    /// otherwise the row zombie-re-spawns every restart despite a
24449    /// visibly-failed spawn. Also mirrors the tag-rollback arm's
24450    /// `release_session`: the pool entry + command sender must not leak
24451    /// per failed fresh spawn.
24452    #[tokio::test]
24453    async fn worktree_failure_deletes_persisted_dynamic_worker_row() {
24454        let (workspace, _update_rx) = Workspace::testing_stub();
24455        let _dir = install_db(&workspace);
24456        workspace.enable_test_dispatch_intercept();
24457
24458        workspace.seed_test_project("proj-x", "/tmp/worktree-failure-proj");
24459        let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
24460        let worker_key = "worker-uuid";
24461        let lead_id = "lead-uuid";
24462        seed_worker_row(&workspace, &project_key, "reviewer");
24463        workspace
24464            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24465        install_lead_in_pool(&workspace, lead_id);
24466
24467        let session_key = SessionSlot::from_str_for_test(worker_key);
24468        let (handle, _agent_rx) = Workspace::testing_stub_handle();
24469        let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel::<Command>();
24470        workspace.pool.lock().insert(
24471            session_key.clone(),
24472            PooledAgent {
24473                handle: Arc::new(handle),
24474                account: AccountKey("test".to_owned()),
24475                permission_mode: None,
24476                registration: None,
24477                session_id: "pooled-session".to_owned(),
24478            },
24479        );
24480        workspace.command_senders.lock().insert(session_key.clone(), cmd_tx);
24481
24482        let worktree_msg = "fatal: 'reviewer' is already used by worktree at /a";
24483        assert!(workspace.handle_async_worker_spawn_failure(
24484            &session_key,
24485            worktree_msg,
24486            SpawnFailureKind::Unclassified
24487        ));
24488
24489        assert!(
24490            persisted_labels(&workspace, &project_key).is_empty(),
24491            "worktree-failure hard removal deletes the persisted row",
24492        );
24493        assert!(
24494            !workspace.pool.lock().contains_key(&session_key)
24495                && !workspace.command_senders.lock().contains_key(&session_key),
24496            "the failed spawn's session registrations are released",
24497        );
24498    }
24499
24500    /// A worktree-creation failure never got a worktree, so the
24501    /// `Removed` event the close toast is built from must not report one
24502    /// intact - `Intact` is the arm that names a path, and there is
24503    /// nothing at that path to preserve. The worker is a GIT worker,
24504    /// the only case that could report `Intact` at all.
24505    #[tokio::test]
24506    async fn worktree_creation_failure_reports_no_worktree_to_preserve() {
24507        let (workspace, mut update_rx) = Workspace::testing_stub();
24508
24509        let project_key = ProjectKey::new("proj-x");
24510        let worker_key = "worker-uuid";
24511        let lead_id = "lead-uuid";
24512        workspace
24513            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24514        install_lead_in_pool(&workspace, lead_id);
24515
24516        let session_key = SessionSlot::from_str_for_test(worker_key);
24517        let worktree_msg = "Error creating worktree: failed to resolve base branch";
24518        // The call below returns true on either classifier outcome, so
24519        // only this pins which path ran: the other transitions to
24520        // Failed and emits no Removed event at all.
24521        assert!(
24522            matches!(
24523                crate::mcp::workers::facade::classify_worker_spawn_failure(
24524                    worktree_msg,
24525                    true,
24526                    SpawnFailureKind::Unclassified,
24527                ),
24528                crate::mcp::workers::facade::WorkerSpawnError::WorktreeCreationFailed { .. },
24529            ),
24530            "the fixture must drive a real worktree-creation failure",
24531        );
24532        assert!(workspace.handle_async_worker_spawn_failure(
24533            &session_key,
24534            worktree_msg,
24535            SpawnFailureKind::Unclassified
24536        ));
24537
24538        let mut dispositions = Vec::new();
24539        while let Ok(frame) = update_rx.try_recv() {
24540            if let SessionUpdate::WorkerStatusChanged { action, worktree, .. } =
24541                std::sync::Arc::unwrap_or_clone(frame.update)
24542                && action == crate::protocol::WorkerStatusAction::Removed
24543            {
24544                dispositions.push(worktree);
24545            }
24546        }
24547        assert_eq!(
24548            dispositions,
24549            vec![crate::protocol::WorktreeDisposition::Absent],
24550            "a worktree that failed to be created must not be reported preserved",
24551        );
24552    }
24553
24554    /// #2: a non-worktree failure transitions the worker to Failed
24555    /// (visible) and KEEPS its row, so it re-spawns on the next restart
24556    /// to recover or re-fail visibly.
24557    #[tokio::test]
24558    async fn transition_to_failed_keeps_persisted_dynamic_worker_row() {
24559        let (workspace, _update_rx) = Workspace::testing_stub();
24560        let _dir = install_db(&workspace);
24561
24562        workspace.seed_test_project("proj-x", "/tmp/transition-failed-proj");
24563        let project_key = workspace.project_key_for_name("proj-x").expect("seeded project");
24564        let worker_key = "worker-uuid";
24565        seed_worker_row(&workspace, &project_key, "reviewer");
24566        // Non-git worker + a generic message classifies as DispatchFailed,
24567        // driving the transition-to-Failed (visible) path.
24568        workspace.insert_live_worker(
24569            &project_key,
24570            fake_worker("reviewer", worker_key, "lead-uuid", false),
24571        );
24572
24573        let session_key = SessionSlot::from_str_for_test(worker_key);
24574        assert!(workspace.handle_async_worker_spawn_failure(
24575            &session_key,
24576            "subprocess exited with code 2",
24577            SpawnFailureKind::Unclassified,
24578        ));
24579
24580        assert_eq!(
24581            persisted_labels(&workspace, &project_key),
24582            vec!["reviewer".to_owned()],
24583            "a Failed-but-visible worker keeps its row for re-spawn",
24584        );
24585    }
24586
24587    /// #245 Layer C test gap 12 + 11: direct unit coverage for
24588    /// [`transition_worker_to_failed`].
24589    ///
24590    /// Covers:
24591    /// - First call flips status + records diagnostic
24592    /// - Second call with identical diagnostic is a no-op (no
24593    ///   extra event emission)
24594    /// - Second call with a NEW diagnostic re-emits + records new
24595    ///   diagnostic
24596    /// - needs_tag is cleared on transition
24597    #[tokio::test]
24598    async fn transition_worker_to_failed_idempotent_for_identical_diagnostic() {
24599        let (workspace, mut update_rx) = Workspace::testing_stub();
24600        let project_key = ProjectKey::new("proj-x");
24601        let worker_key = "worker-uuid";
24602        let session_key = SessionSlot::from_str_for_test(worker_key);
24603        // Worker starts Spawning + needs_tag = true (mirrors
24604        // fresh-spawn state pre-Connected).
24605        workspace
24606            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, "lead", true));
24607
24608        // First call: flips to Failed, records diagnostic, clears
24609        // needs_tag, emits WorkerStatusChanged.
24610        transition_worker_to_failed(
24611            &workspace,
24612            &project_key,
24613            &session_key,
24614            Some("spawn failed".to_owned()),
24615        );
24616
24617        let entries = workspace.list_live_workers(&project_key);
24618        assert_eq!(entries.len(), 1);
24619        assert_eq!(entries[0].status, forge_primitives::WorkerLiveness::Failed);
24620        assert_eq!(entries[0].diagnostic.as_deref(), Some("spawn failed"));
24621        assert!(!entries[0].needs_tag, "needs_tag must be cleared on Failed transition");
24622
24623        // Drain the first event so the next assertion is unambiguous.
24624        let _ = update_rx.try_recv().expect("first transition emitted event");
24625
24626        // Second call with identical diagnostic: no-op, no new event.
24627        transition_worker_to_failed(
24628            &workspace,
24629            &project_key,
24630            &session_key,
24631            Some("spawn failed".to_owned()),
24632        );
24633        let second_event = update_rx.try_recv();
24634        assert!(
24635            second_event.is_err(),
24636            "identical diagnostic re-fire must NOT emit a new event; got {second_event:?}",
24637        );
24638
24639        // Third call with NEW diagnostic: re-emits + records new text.
24640        transition_worker_to_failed(
24641            &workspace,
24642            &project_key,
24643            &session_key,
24644            Some("more specific reason".to_owned()),
24645        );
24646        let third_event = update_rx.try_recv();
24647        assert!(third_event.is_ok(), "fresh diagnostic must re-emit");
24648        let entries = workspace.list_live_workers(&project_key);
24649        assert_eq!(entries[0].diagnostic.as_deref(), Some("more specific reason"));
24650    }
24651
24652    /// handle_async_worker_spawn_failure drops whatever was parked for the
24653    /// worker whose spawn died, and the message in it is acknowledged back
24654    /// to its sender: a spawn that never connected has no session left to
24655    /// deliver it to, and nothing re-delivers it later.
24656    #[tokio::test]
24657    async fn async_worker_spawn_failure_acknowledges_the_parked_message() {
24658        use crate::mcp::peers::types::{MessageId, WrappedKind, WrappedPrompt};
24659        let (workspace, mut update_rx) = Workspace::testing_stub();
24660        let project_key = ProjectKey::new("proj-x");
24661        let worker_key = "builder-uuid";
24662        let session_key = SessionSlot::from_str_for_test(worker_key);
24663        workspace
24664            .insert_live_worker(&project_key, fake_worker("builder", worker_key, "lead", true));
24665        workspace.enable_test_dispatch_intercept();
24666
24667        let sender = SessionSlot::from_str_for_test("sender-proj");
24668        workspace.park_peer_prompt(
24669            &session_key,
24670            &sender,
24671            WrappedPrompt {
24672                id: MessageId::mint(),
24673                kind: WrappedKind::Message,
24674                sender_name: "forge".to_owned(),
24675                sender_org: "Default".to_owned(),
24676                body: "ready?".to_owned(),
24677            },
24678        );
24679
24680        workspace.handle_async_worker_spawn_failure(
24681            &session_key,
24682            "resume failed: boom",
24683            SpawnFailureKind::Unclassified,
24684        );
24685
24686        let mut echo = None;
24687        while let Ok(frame) = update_rx.try_recv() {
24688            if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
24689                std::sync::Arc::unwrap_or_clone(frame.update)
24690            {
24691                echo = Some((key, wrapped));
24692            }
24693        }
24694        let (key, notice) = echo.expect("the parked message is acknowledged to its sender");
24695        assert_eq!(key, sender, "on the sender's slot");
24696        assert!(
24697            matches!(notice.kind, WrappedKind::DeliveryFailureNotice),
24698            "as a delivery failure, not a peer message"
24699        );
24700        assert!(
24701            workspace.take_parked_for_slot(&session_key).peer.is_empty(),
24702            "and the bucket is gone, so nothing re-delivers it"
24703        );
24704    }
24705
24706    /// #245 Layer C test gap 11: a worker that flipped to Failed
24707    /// then transitions back to Running (e.g. a successful resume
24708    /// after the user fixed the underlying problem) must clear the
24709    /// stale diagnostic field. Without this, the Projects pane
24710    /// would render a healthy Running worker with a phantom
24711    /// failure sub-row underneath.
24712    #[tokio::test]
24713    async fn transition_worker_to_running_clears_prior_failed_diagnostic() {
24714        let (workspace, _update_rx) = Workspace::testing_stub();
24715        let project_key = ProjectKey::new("proj-x");
24716        let worker_key = "worker-uuid";
24717        let session_key = SessionSlot::from_str_for_test(worker_key);
24718        workspace
24719            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, "lead", true));
24720
24721        // Flip to Failed with a diagnostic.
24722        transition_worker_to_failed(
24723            &workspace,
24724            &project_key,
24725            &session_key,
24726            Some("connection refused".to_owned()),
24727        );
24728        assert_eq!(
24729            workspace.list_live_workers(&project_key)[0].diagnostic.as_deref(),
24730            Some("connection refused"),
24731        );
24732
24733        // Transition back to Running. Diagnostic must clear.
24734        transition_worker_to_running(
24735            &workspace,
24736            &project_key,
24737            &session_key,
24738            TagWriteResult::Succeeded,
24739        );
24740        let entry = &workspace.list_live_workers(&project_key)[0];
24741        assert_eq!(entry.status, forge_primitives::WorkerLiveness::Running);
24742        assert!(
24743            entry.diagnostic.is_none(),
24744            "diagnostic must clear when worker transitions back to Running; got {:?}",
24745            entry.diagnostic,
24746        );
24747    }
24748
24749    /// #146: lead-session-gone path - the worker was spawned by a
24750    /// lead session that has since been released (e.g. /new flow).
24751    /// Notice dispatch is skipped (warn-logged) but the WorkerEntry
24752    /// still rolls back.
24753    #[tokio::test]
24754    async fn async_failure_when_lead_session_gone_drops_notice_but_rolls_back() {
24755        let (workspace, _update_rx) = Workspace::testing_stub();
24756        workspace.enable_test_dispatch_intercept();
24757
24758        let project_key = ProjectKey::new("proj-x");
24759        let worker_key = "worker-uuid";
24760        let lead_id = "lead-gone-uuid";
24761        workspace
24762            .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, lead_id, true));
24763        // DELIBERATELY skip install_lead_in_pool - lead is "gone".
24764
24765        let handled = workspace.handle_async_worker_spawn_failure(
24766            &SessionSlot::from_str_for_test(worker_key),
24767            "fatal: 'reviewer' is already used by worktree at /a",
24768            SpawnFailureKind::Unclassified,
24769        );
24770        assert!(handled, "still consumes the failure even when lead is gone");
24771
24772        let dispatched = workspace.drain_test_dispatch_buffer();
24773        let prompts: Vec<&Command> = dispatched
24774            .iter()
24775            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
24776            .collect();
24777        assert!(prompts.is_empty(), "no notice dispatched when lead session is gone");
24778        assert!(
24779            workspace.list_live_workers(&project_key).is_empty(),
24780            "WorkerEntry still rolls back even when notice is dropped",
24781        );
24782    }
24783}
24784
24785#[cfg(test)]
24786#[allow(clippy::expect_used, clippy::unwrap_used)]
24787mod git_scan_cwd_tests {
24788    use super::*;
24789    use crate::mcp::workers::types::WorkerEntry;
24790    use forge_primitives::WorkerLiveness;
24791    use forge_primitives::document::DocumentHandOffEnding;
24792    use std::time::SystemTime;
24793
24794    fn worker_entry(label: &str, session_key: &SessionSlot, is_git: bool) -> WorkerEntry {
24795        WorkerEntry {
24796            label: label.into(),
24797            charter: "test charter".into(),
24798            slot: session_key.clone(),
24799            session_id: None,
24800            status: WorkerLiveness::Running,
24801            spawned_at: SystemTime::UNIX_EPOCH,
24802            spawned_by: SessionSlot::from_str_for_test("lead-uuid"),
24803            needs_tag: false,
24804            is_git_repo_at_spawn: is_git,
24805            cwd: None,
24806            diagnostic: None,
24807            kick: None,
24808        }
24809    }
24810
24811    /// Seed a project + a worker for it. Returns the project_root
24812    /// path the project_key derives from, and the worker's session
24813    /// key for the caller to drive `git_scan_cwd_for_session`.
24814    fn seed_project_and_worker(
24815        ws: &Arc<Workspace>,
24816        project_name: &str,
24817        project_root: &str,
24818        worker_label: &str,
24819        worker_session: &str,
24820        is_git: bool,
24821    ) -> (std::path::PathBuf, SessionSlot) {
24822        ws.seed_test_project(project_name, project_root);
24823        let project_key = ProjectKey::new(
24824            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(project_root)),
24825        );
24826        let session_key = SessionSlot::from_str_for_test(worker_session);
24827        ws.insert_live_worker(&project_key, worker_entry(worker_label, &session_key, is_git));
24828        (std::path::PathBuf::from(project_root), session_key)
24829    }
24830
24831    #[test]
24832    fn git_scan_cwd_resolves_worktree_when_cwd_raw_is_project_root() {
24833        // Fresh-spawn path: `cwd_raw` is the project root (the value
24834        // claude sends in `AgentEvent::Connected.cwd` before it
24835        // chdirs into the worktree). The function must compose
24836        // `<project_root>/.claude/worktrees/<label>` for git workers.
24837        let (ws, _rx) = Workspace::testing_stub();
24838        let (project_root, session_key) = seed_project_and_worker(
24839            &ws,
24840            "forge",
24841            "/tmp/test-forge-fresh",
24842            "implementer",
24843            "worker-uuid-fresh",
24844            true,
24845        );
24846        let resolved = ws.git_scan_cwd_for_session(&session_key, &project_root);
24847        assert_eq!(
24848            resolved,
24849            project_root.join(".claude/worktrees").join("implementer"),
24850            "fresh-spawn cwd_raw must resolve to the worktree path"
24851        );
24852    }
24853
24854    #[test]
24855    fn git_scan_cwd_resolves_worktree_when_cwd_raw_is_already_worktree_path() {
24856        // Resumed-worker path: `cwd_raw` is already
24857        // `<project_root>/.claude/worktrees/<label>` because the
24858        // catalog row was written after claude chdir'd. Old behavior
24859        // composed `worker_tag_dir(cwd_raw, label, true)` and doubled
24860        // the suffix - new behavior anchors on the project_key, so
24861        // both inputs converge on the same final path.
24862        let (ws, _rx) = Workspace::testing_stub();
24863        let (project_root, session_key) = seed_project_and_worker(
24864            &ws,
24865            "forge",
24866            "/tmp/test-forge-resume",
24867            "implementer",
24868            "worker-uuid-resume",
24869            true,
24870        );
24871        let already_worktree = project_root.join(".claude/worktrees").join("implementer");
24872        let resolved = ws.git_scan_cwd_for_session(&session_key, &already_worktree);
24873        assert_eq!(
24874            resolved, already_worktree,
24875            "resume cwd_raw must NOT double the worktree suffix"
24876        );
24877    }
24878
24879    #[test]
24880    fn git_scan_cwd_returns_cwd_unchanged_for_lead_session() {
24881        // Non-worker sessions take the fall-through branch and the
24882        // raw cwd survives unchanged. No `live_workers` entry exists
24883        // for the lead.
24884        let (ws, _rx) = Workspace::testing_stub();
24885        ws.seed_test_project("forge", "/tmp/test-forge-lead");
24886        let lead_key = SessionSlot::from_str_for_test("lead-uuid");
24887        let lead_cwd = std::path::PathBuf::from("/tmp/test-forge-lead");
24888        let resolved = ws.git_scan_cwd_for_session(&lead_key, &lead_cwd);
24889        assert_eq!(resolved, lead_cwd, "lead sessions must get cwd_raw unchanged");
24890    }
24891
24892    #[test]
24893    fn git_scan_cwd_returns_cwd_unchanged_for_non_git_worker() {
24894        // Non-git workers don't have a worktree fork; they run in
24895        // the project root itself. Returning cwd_raw unchanged
24896        // matches the pre-fix behavior for this case.
24897        let (ws, _rx) = Workspace::testing_stub();
24898        let (project_root, session_key) = seed_project_and_worker(
24899            &ws,
24900            "forge",
24901            "/tmp/test-forge-nongit",
24902            "researcher",
24903            "worker-uuid-nongit",
24904            false,
24905        );
24906        let resolved = ws.git_scan_cwd_for_session(&session_key, &project_root);
24907        assert_eq!(resolved, project_root, "non-git worker must use cwd_raw unchanged");
24908    }
24909
24910    // ---------------------------------------------------------------
24911    // #245 Layer B: resume_cwd_for_slot falls back to the owning
24912    // worker's project_root when the catalog has no recorded cwd.
24913    // Without this, claude --resume inherits the forge binary's
24914    // process cwd and derives the JSONL location against the wrong
24915    // git root (the bug documented in #245).
24916    // ---------------------------------------------------------------
24917
24918    #[test]
24919    fn resume_cwd_for_slot_returns_worktree_for_git_worker_with_no_catalog_cwd() {
24920        // Git-repo worker (the data-modules babysitter / librarian
24921        // case from #245). Layer B composes the worker's worktree
24922        // path so claude resolves the JSONL on the first try -
24923        // passing just the project root would make claude look under
24924        // the wrong sanitised dir and surface "No conversation
24925        // found".
24926        let (ws, _rx) = Workspace::testing_stub();
24927        let (project_root, session_key) = seed_project_and_worker(
24928            &ws,
24929            "data-modules",
24930            "/tmp/test-data-modules",
24931            "babysitter",
24932            "worker-uuid-hub",
24933            true,
24934        );
24935        let resolved = ws.resume_cwd_for_slot(&session_key);
24936        assert_eq!(
24937            resolved,
24938            project_root.join(".claude/worktrees/babysitter").to_string_lossy(),
24939            "git-repo worker resume cwd must compose the worktree path via worker_tag_dir",
24940        );
24941    }
24942
24943    #[test]
24944    fn resume_cwd_for_slot_returns_project_root_for_non_git_worker() {
24945        // Non-git project: worker_tag_dir leaves the path as the
24946        // project root, so the fallback returns the root verbatim.
24947        let (ws, _rx) = Workspace::testing_stub();
24948        let (project_root, session_key) = seed_project_and_worker(
24949            &ws,
24950            "non-git-proj",
24951            "/tmp/test-non-git",
24952            "implementer",
24953            "worker-uuid-non-git",
24954            false,
24955        );
24956        let resolved = ws.resume_cwd_for_slot(&session_key);
24957        assert_eq!(
24958            resolved,
24959            project_root.to_string_lossy(),
24960            "non-git worker resume cwd must equal the project root (no worktree subdir)",
24961        );
24962    }
24963
24964    #[test]
24965    fn resume_cwd_for_slot_returns_empty_for_unknown_session() {
24966        // Non-worker, non-catalog session - the function returns
24967        // empty string and lets the bridge surface ConnectionFailed
24968        // (current behaviour for genuinely-orphan sessions).
24969        let (ws, _rx) = Workspace::testing_stub();
24970        let unknown = SessionSlot::from_str_for_test("not-a-known-session");
24971        assert_eq!(ws.resume_cwd_for_slot(&unknown), "");
24972    }
24973
24974    #[test]
24975    fn cwd_for_session_resolves_a_git_worker_with_no_catalog_row() {
24976        // The catalog holds no worker rows at all (the boot scan hides
24977        // worker-tagged sessions; the Connected handler skips the
24978        // mirror for workers), so every worker resolves through the
24979        // registry - not just a resume.
24980        let (ws, _rx) = Workspace::testing_stub();
24981        let (project_root, session_key) = seed_project_and_worker(
24982            &ws,
24983            "gateway-backend",
24984            "/tmp/test-gateway-cwd",
24985            "pyth-review-fixes",
24986            "worker-uuid-cwd",
24987            true,
24988        );
24989        assert_eq!(
24990            ws.cwd_for_session(&session_key).as_deref(),
24991            project_root.join(".claude/worktrees/pyth-review-fixes").to_str(),
24992        );
24993    }
24994
24995    /// A worker the spawn pointed at another tree reads back to THAT
24996    /// tree, not the project's: the seat's cwd, the resume cwd and the
24997    /// git scan all compose from the root the spawn resolved. Composing
24998    /// from the project path instead would put the worker's branch, its
24999    /// files and its resumed JSONL under a directory the worker never
25000    /// ran in.
25001    #[test]
25002    fn a_cwd_workers_read_backs_compose_its_own_tree() {
25003        let (ws, _rx) = Workspace::testing_stub();
25004        let (_project_root, session_key) = seed_project_and_worker(
25005            &ws,
25006            "playground",
25007            "/tmp/test-playground",
25008            "nazar-hand",
25009            "worker-uuid-nazar",
25010            true,
25011        );
25012        let project_key = ws.project_key_for_name("playground").expect("seeded project");
25013        let root = std::path::PathBuf::from("/tmp/test-nazar");
25014        let mut entry = worker_entry("nazar-hand", &session_key, true);
25015        entry.cwd = Some(root.clone());
25016        ws.remove_latest_worker(&project_key, "nazar-hand");
25017        ws.insert_live_worker(&project_key, entry);
25018        let tree = root.join(".claude/worktrees/nazar-hand");
25019
25020        assert_eq!(
25021            ws.cwd_for_session(&session_key).as_deref(),
25022            tree.to_str(),
25023            "the seat's tree is the worker's own, not the project's",
25024        );
25025        assert_eq!(
25026            ws.git_scan_cwd_for_session(&session_key, std::path::Path::new("/tmp/test-playground")),
25027            tree,
25028            "the branch and file counts read the worker's tree",
25029        );
25030        assert_eq!(
25031            ws.resume_cwd_for_slot(&session_key),
25032            tree.to_string_lossy(),
25033            "and a resume lands where the JSONL lives",
25034        );
25035
25036        // A non-git tree is its own directory, no worktree composed.
25037        let plain_root = std::path::PathBuf::from("/tmp/test-plain");
25038        let mut plain = worker_entry("nazar-hand", &session_key, false);
25039        plain.cwd = Some(plain_root.clone());
25040        ws.remove_latest_worker(&project_key, "nazar-hand");
25041        ws.insert_live_worker(&project_key, plain);
25042        assert_eq!(
25043            ws.cwd_for_session(&session_key).as_deref(),
25044            plain_root.to_str(),
25045            "a non-git worker's own directory is the tree itself",
25046        );
25047    }
25048
25049    /// The launchpad asks about the ROW's tree, not the project's: a
25050    /// worker pointed at another directory is offered on whether that
25051    /// directory's worktree is there. The project's own worktree stands
25052    /// in this fixture, so a launchpad reading the project path would
25053    /// offer a row the boot wave would skip.
25054    #[test]
25055    fn the_launchpad_asks_about_the_rows_own_tree() {
25056        let (workspace, _rx) = Workspace::testing_stub();
25057        let db_dir = tempfile::tempdir().expect("db dir");
25058        workspace.install_db_for_test(
25059            crate::store::Db::open(&db_dir.path().join("db.redb")).expect("open db"),
25060        );
25061        let repo = tempfile::tempdir().expect("git project dir");
25062        let status = std::process::Command::new("git")
25063            .args(["init", "-q"])
25064            .current_dir(repo.path())
25065            .status()
25066            .expect("spawn git");
25067        assert!(status.success(), "git init");
25068        workspace.seed_test_project("proj-x", repo.path().to_str().expect("utf8 repo path"));
25069        let proj_x = workspace.project_key_for_name("proj-x").expect("seeded project");
25070        // The project's own worktree stands, so the project path answers
25071        // "there is one" for anything that asks it.
25072        std::fs::create_dir_all(repo.path().join(".claude").join("worktrees").join("at-home"))
25073            .expect("create the project worktree");
25074
25075        let gone = tempfile::tempdir().expect("worker tree dir");
25076        let gone_root = std::fs::canonicalize(gone.path()).expect("canonical");
25077        workspace.seed_test_worker_row_at(&proj_x, "moved", &gone_root.to_string_lossy(), true);
25078        workspace.seed_test_worker_row_at(
25079            &proj_x,
25080            "at-home",
25081            repo.path().to_str().expect("utf8 repo path"),
25082            true,
25083        );
25084
25085        let offered = workspace.worker_labels_by_project();
25086        let labels = offered.get(&proj_x).cloned().unwrap_or_default();
25087        assert!(
25088            !labels.contains(&"moved".to_owned()),
25089            "a worker whose own tree has no worktree must not be offered, however the project \
25090             looks; offered {labels:?}",
25091        );
25092        assert!(
25093            labels.contains(&"at-home".to_owned()),
25094            "and the project's own worker is still offered; offered {labels:?}",
25095        );
25096    }
25097
25098    /// A hand-off reads the document out of the seat's OWN tree, asks the
25099    /// seat's clients to open it with the resolved path, and **parks until
25100    /// the reader answers** - the finish is what releases the call, and the
25101    /// answer carries what it filed.
25102    #[tokio::test]
25103    async fn a_document_hand_off_parks_on_the_ask_and_answers_with_the_ending() {
25104        let dir = tempfile::tempdir().expect("tempdir");
25105        let root = dir.path().to_str().expect("utf8").to_owned();
25106        let (ws, mut updates) = Workspace::testing_stub();
25107        let (project_root, session_key) = seed_project_and_worker(
25108            &ws,
25109            "forge",
25110            &root,
25111            "implementer",
25112            "worker-uuid-hand-off",
25113            true,
25114        );
25115        let cwd = project_root.join(".claude/worktrees/implementer");
25116        std::fs::create_dir_all(cwd.join("docs")).expect("the worktree");
25117        std::fs::write(cwd.join("docs/readme.md"), b"# hi\n").expect("the document");
25118        // The read answers the CANONICAL path (the `/var` -> `/private/var`
25119        // case on this machine), which is what a viewer deep-links by.
25120        let expected = cwd.canonicalize().expect("canonical").join("docs/readme.md");
25121        ws.note_client_attached();
25122
25123        let handed = {
25124            let ws = Arc::clone(&ws);
25125            let seat = session_key.clone();
25126            tokio::spawn(async move { ws.hand_off_document(&seat, "docs/readme.md", true).await })
25127        };
25128        // The ask crosses, carrying the id the answer will name.
25129        let ask = tokio::time::timeout(std::time::Duration::from_secs(5), async {
25130            loop {
25131                match updates
25132                    .recv()
25133                    .await
25134                    .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
25135                {
25136                    Some(SessionUpdate::DocumentHandOffPending {
25137                        id, path, reviewable, ..
25138                    }) => {
25139                        break (id, path, reviewable);
25140                    }
25141                    Some(_) => {}
25142                    None => panic!("the stream ended before the ask"),
25143                }
25144            }
25145        })
25146        .await
25147        .expect("the ask reaches the seat's stream");
25148        assert_eq!(ask.1, expected.to_string_lossy(), "with the resolved path - the address");
25149        assert!(ask.2, "and the flag the hand-off passed");
25150
25151        assert!(ws.resolve_document_hand_off(
25152            ask.0,
25153            &session_key,
25154            DocumentHandOffEnding::Approve { review_id: Some("r-1".to_owned()), note: None },
25155        ));
25156
25157        let (resolved, ending) = handed.await.expect("the task joins").expect("the hand-off lands");
25158        assert_eq!(resolved, expected.to_string_lossy(), "answered with the resolved path");
25159        assert_eq!(
25160            ending,
25161            DocumentHandOffEnding::Approve { review_id: Some("r-1".to_owned()), note: None }
25162        );
25163        assert!(
25164            std::iter::from_fn(|| updates.try_recv().ok()).any(|frame| matches!(
25165                &*frame.update,
25166                SessionUpdate::DocumentHandOffResolved { id, .. } if *id == ask.0
25167            )),
25168            "and the resolution crosses, so a view that did not answer closes its ask",
25169        );
25170    }
25171
25172    /// A refused read raises nothing, parks on nothing, and answers the
25173    /// read's own sentence.
25174    #[tokio::test]
25175    async fn a_refused_document_hand_off_raises_nothing() {
25176        let dir = tempfile::tempdir().expect("tempdir");
25177        let root = dir.path().to_str().expect("utf8").to_owned();
25178        let (ws, mut updates) = Workspace::testing_stub();
25179        let (project_root, session_key) = seed_project_and_worker(
25180            &ws,
25181            "forge",
25182            &root,
25183            "implementer",
25184            "worker-uuid-hand-off-refused",
25185            true,
25186        );
25187        let cwd = project_root.join(".claude/worktrees/implementer");
25188        std::fs::create_dir_all(&cwd).expect("the worktree");
25189
25190        let refused = ws
25191            .hand_off_document(&session_key, "gone.md", false)
25192            .await
25193            .expect_err("nothing to read");
25194
25195        assert!(refused.contains("no document at"), "{refused}");
25196        assert!(
25197            std::iter::from_fn(|| updates.try_recv().ok()).all(|frame| !matches!(
25198                &*frame.update,
25199                SessionUpdate::DocumentHandOffPending { .. }
25200            )),
25201            "a refusal is not a hand-off",
25202        );
25203    }
25204
25205    /// **A hand-off claims an open only where something can draw it.** With
25206    /// no client attached, the ask would cross the fan-out and reach nothing
25207    /// that draws a document viewer - so the tool refuses with
25208    /// the reason instead of parking on an ask nothing can draw. A client
25209    /// attaching (the transport notes each connection) is what makes the
25210    /// same hand-off land, and its leaving takes the claim back.
25211    #[tokio::test]
25212    async fn a_hand_off_claims_an_open_only_with_a_client_attached() {
25213        let dir = tempfile::tempdir().expect("tempdir");
25214        let root = dir.path().to_str().expect("utf8").to_owned();
25215        let (ws, mut updates) = Workspace::testing_stub();
25216        let (project_root, session_key) = seed_project_and_worker(
25217            &ws,
25218            "forge",
25219            &root,
25220            "implementer",
25221            "worker-uuid-hand-off-gate",
25222            true,
25223        );
25224        let cwd = project_root.join(".claude/worktrees/implementer");
25225        std::fs::create_dir_all(cwd.join("docs")).expect("the worktree");
25226        std::fs::write(cwd.join("docs/readme.md"), b"# hi\n").expect("the document");
25227
25228        let refused = ws
25229            .hand_off_document(&session_key, "docs/readme.md", false)
25230            .await
25231            .expect_err("no client, so nothing to open on");
25232        assert_eq!(refused, crate::documents::NO_VIEWER_CLIENT, "the refusal is the named one");
25233        assert!(
25234            std::iter::from_fn(|| updates.try_recv().ok()).all(|frame| !matches!(
25235                &*frame.update,
25236                SessionUpdate::DocumentHandOffPending { .. }
25237            )),
25238            "and nothing was asked of the terminal",
25239        );
25240
25241        ws.note_client_attached();
25242        let handed = {
25243            let ws = Arc::clone(&ws);
25244            let seat = session_key.clone();
25245            tokio::spawn(async move { ws.hand_off_document(&seat, "docs/readme.md", false).await })
25246        };
25247        let ask = tokio::time::timeout(std::time::Duration::from_secs(5), async {
25248            loop {
25249                match updates
25250                    .recv()
25251                    .await
25252                    .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
25253                {
25254                    Some(SessionUpdate::DocumentHandOffPending { id, .. }) => break id,
25255                    Some(_) => {}
25256                    None => panic!("the stream ended before the ask"),
25257                }
25258            }
25259        })
25260        .await
25261        .expect("an attached client is what the hand-off waits for");
25262        ws.resolve_document_hand_off(
25263            ask,
25264            &session_key,
25265            DocumentHandOffEnding::Approve { review_id: None, note: None },
25266        );
25267        handed.await.expect("the task joins").expect("and the park releases");
25268
25269        ws.note_client_detached();
25270        let refused = ws
25271            .hand_off_document(&session_key, "docs/readme.md", false)
25272            .await
25273            .expect_err("the client left, so the open is unclaimed again");
25274        assert_eq!(refused, crate::documents::NO_VIEWER_CLIENT);
25275    }
25276
25277    /// **A document deleted between the hand-off and a later open of its
25278    /// stable address refuses as missing - and its threads still read, with
25279    /// their captured lines.** The store was never about the file: deleting
25280    /// the document cannot touch what was written against it, which is what
25281    /// keeps a thread reachable from the review record after the file it
25282    /// reviewed is gone.
25283    #[tokio::test]
25284    async fn a_deleted_document_refuses_and_its_threads_still_read() {
25285        use forge_primitives::review::{
25286            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
25287        };
25288
25289        let dir = tempfile::tempdir().expect("tempdir");
25290        let root = dir.path().to_str().expect("utf8").to_owned();
25291        let (ws, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
25292        ws.install_db_for_test(
25293            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
25294        );
25295        let (project_root, session_key) = seed_project_and_worker(
25296            &ws,
25297            "forge",
25298            &root,
25299            "implementer",
25300            "worker-uuid-hand-off-deleted",
25301            true,
25302        );
25303        let cwd = project_root.join(".claude/worktrees/implementer");
25304        std::fs::create_dir_all(cwd.join("docs")).expect("the worktree");
25305        let document = cwd.join("docs/plan.md");
25306        std::fs::write(&document, b"# plan\n").expect("the document");
25307        let resolved = cwd.canonicalize().expect("canonical").join("docs/plan.md");
25308        let scope = forge_primitives::review::doc_scope_ref(&resolved.to_string_lossy());
25309        assert!(
25310            ws.upsert_review_thread(
25311                "forge",
25312                &scope,
25313                ReviewThread {
25314                    id: "d1".to_owned(),
25315                    anchor: ReviewAnchor {
25316                        path: resolved.to_string_lossy().into_owned(),
25317                        side: ReviewSide::New,
25318                        line: 1,
25319                        content_hash: 7,
25320                        context: vec!["# plan".to_owned()],
25321                        base_ref: scope.clone(),
25322                    },
25323                    comments: vec![ReviewComment {
25324                        author: ReviewAuthor::User,
25325                        text: "one line, please".to_owned(),
25326                        at: String::new(),
25327                        review_id: None,
25328                    }],
25329                    status: ReviewStatus::Open,
25330                    created_at: String::new(),
25331                    updated_at: String::new(),
25332                    commit: None,
25333                },
25334            ),
25335            "the thread was stored",
25336        );
25337
25338        std::fs::remove_file(&document).expect("the deletion");
25339
25340        let refused = ws
25341            .hand_off_document(&session_key, "docs/plan.md", false)
25342            .await
25343            .expect_err("the stable address now refuses");
25344        assert!(refused.contains("no document at"), "and says what it met: {refused}");
25345
25346        let threads = ws.load_review_threads("forge", &scope).expect("the scope still reads");
25347        assert_eq!(threads.len(), 1, "the deletion cannot touch the document's threads");
25348        assert_eq!(
25349            threads[0].anchor.context,
25350            vec!["# plan".to_owned()],
25351            "with their captured lines kept",
25352        );
25353    }
25354
25355    /// The project-root arm answers LEADS ONLY. `seed_project_and_worker`
25356    /// cannot show this: its slot names no loaded project, so
25357    /// `project_for_slot` misses and BOTH arms fall through to the
25358    /// registry's answer - which is how the shadowing regression passed
25359    /// every existing cwd test. The slot has to name a loaded project for
25360    /// the two arms to disagree, and the wrong one here sends a git
25361    /// worker's resume to the project root while its JSONL sits under
25362    /// the worktree (#245 Layer B).
25363    #[test]
25364    fn a_worker_slot_does_not_resolve_to_its_project_root() {
25365        let dir = tempfile::tempdir().expect("tempdir");
25366        std::fs::write(
25367            forge_toml_path(dir.path()),
25368            r#"
25369[[orgs]]
25370name = "Default"
25371accounts = ["Stargate"]
25372
25373[[orgs.projects]]
25374name = "forge"
25375path = "~/Projects/forge"
25376model = "claude-sonnet-5"
25377
25378[[accounts]]
25379display_name = "Stargate"
25380token = "t"
25381models = ["claude-sonnet-5"]
25382provider = "anthropic"
25383"#,
25384        )
25385        .expect("write forge.toml");
25386        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
25387        let view =
25388            ws.list_projects().into_iter().find(|v| v.name == "forge").expect("fixture project");
25389        let slot = SessionSlot::worker(&view.org, &view.name, "implementer");
25390        ws.insert_live_worker(&view.key, worker_entry("implementer", &slot, true));
25391
25392        let resolved = ws.cwd_for_session(&slot);
25393        let expected = view.path.join(".claude/worktrees/implementer");
25394        assert_eq!(
25395            resolved.as_deref(),
25396            expected.to_str(),
25397            "the registry composes the worktree; the project root must not shadow it",
25398        );
25399    }
25400
25401    /// The other arm of the same lookup, pinned next to it so the pair
25402    /// says what the split is: a lead's cwd IS its project root, read
25403    /// from the slot rather than from the catalog, because the catalog is
25404    /// keyed by the id the CLI adopted and `/new` replaces that.
25405    #[test]
25406    fn a_lead_slot_resolves_to_its_project_root() {
25407        let dir = tempfile::tempdir().expect("tempdir");
25408        std::fs::write(
25409            forge_toml_path(dir.path()),
25410            r#"
25411[[orgs]]
25412name = "Default"
25413accounts = ["Stargate"]
25414
25415[[orgs.projects]]
25416name = "forge"
25417path = "~/Projects/forge"
25418model = "claude-sonnet-5"
25419
25420[[accounts]]
25421display_name = "Stargate"
25422token = "t"
25423models = ["claude-sonnet-5"]
25424provider = "anthropic"
25425"#,
25426        )
25427        .expect("write forge.toml");
25428        let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
25429        let view =
25430            ws.list_projects().into_iter().find(|v| v.name == "forge").expect("fixture project");
25431
25432        let resolved = ws.cwd_for_session(&SessionSlot::lead(&view.org, &view.name));
25433        assert_eq!(resolved.as_deref(), view.path.to_str(), "a lead runs in its project root");
25434    }
25435
25436    #[test]
25437    fn cwd_for_session_is_none_when_the_workers_project_is_not_loaded() {
25438        // The contradiction the WARN names: the registry knows the
25439        // worker's project_key and label, but no loaded project
25440        // matches that key, so no path can be composed. Unreachable
25441        // while forge.toml and `live_workers` agree.
25442        let (ws, _rx) = Workspace::testing_stub();
25443        let session_key = SessionSlot::from_str_for_test("worker-uuid-orphan");
25444        ws.insert_live_worker(
25445            &ProjectKey::new("stale-key".to_owned()),
25446            worker_entry("implementer", &session_key, true),
25447        );
25448        assert!(ws.cwd_for_session(&session_key).is_none());
25449    }
25450
25451    /// The cwd read is not a catalog read any more: a slot that names
25452    /// no declared project resolves through the worker registry, and a
25453    /// catalog row for the same session carrying a different cwd is not
25454    /// consulted at all. Seeding BOTH with different paths is what makes
25455    /// the two rules distinguishable - under the old catalog-first
25456    /// precedence the row's path was the one that won.
25457    #[test]
25458    fn resume_cwd_for_slot_ignores_a_catalog_row_carrying_another_cwd() {
25459        let (ws, _rx) = Workspace::testing_stub();
25460        let session_id = "shared-session-uuid";
25461        let (project_root, session_key) = seed_project_and_worker(
25462            &ws,
25463            "precedence-proj",
25464            "/tmp/test-precedence",
25465            "implementer",
25466            session_id,
25467            true,
25468        );
25469        // A catalog row for the same session, pointing somewhere else:
25470        // it is not a cwd source for this lookup.
25471        ws.record_connected_session("/tmp/test-precedence-catalog-cwd", session_id, None);
25472        let resolved = ws.resume_cwd_for_slot(&session_key);
25473        assert_eq!(
25474            resolved,
25475            project_root.join(".claude/worktrees/implementer").to_string_lossy(),
25476            "the worker registry decides the cwd; the catalog row is not read",
25477        );
25478    }
25479
25480    /// Read/pick consistency: the cwd `resume_cwd_for_slot` hands
25481    /// `claude --resume` for a git worker encodes to the SAME storage
25482    /// key `build_resume_map_from_sessions` scopes candidates to (both
25483    /// go through `project_key_for_directory(worker_tag_dir(...))`), so
25484    /// a picked session always lives in the dir the resume read looks
25485    /// under - the pick and the read can't diverge the way the head-read
25486    /// cwd allowed.
25487    #[test]
25488    fn git_worker_resume_cwd_encodes_to_scoped_storage_key() {
25489        let (ws, _rx) = Workspace::testing_stub();
25490        let (project_root, session_key) = seed_project_and_worker(
25491            &ws,
25492            "playground",
25493            "/tmp/test-playground-consistency",
25494            "gpt-tutor",
25495            "worker-uuid-consistency",
25496            true,
25497        );
25498        let read_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
25499            &ws.resume_cwd_for_slot(&session_key),
25500        ));
25501        let scoped_key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
25502            crate::mcp::workers::types::worker_tag_dir(&project_root, "gpt-tutor", true)
25503                .to_string_lossy()
25504                .as_ref(),
25505        ));
25506        assert_eq!(
25507            read_key, scoped_key,
25508            "resume read dir and resume-map scope key must be the same storage folder",
25509        );
25510    }
25511
25512    // ---------------------------------------------------------------
25513    // The selection walk and `session_chip_for`. Build a real
25514    // workspace from the local `make_workspace_dir_246` helper (single
25515    // account "Stargate", single project "forge") + manually drive the
25516    // loading state via account_pool().set_*().
25517    // ---------------------------------------------------------------
25518
25519    fn make_workspace_dir_246() -> tempfile::TempDir {
25520        let dir = tempfile::tempdir().expect("tempdir");
25521        std::fs::write(
25522            forge_toml_path(dir.path()),
25523            r#"
25524[[orgs]]
25525name = "Default"
25526accounts = ["Stargate"]
25527
25528[[orgs.projects]]
25529name = "forge"
25530path = "~/Projects/forge"
25531model = "claude-sonnet-5"
25532
25533[[accounts]]
25534display_name = "Stargate"
25535token = "t"
25536models = ["claude-sonnet-5"]
25537provider = "anthropic"
25538"#,
25539        )
25540        .expect("write forge.toml");
25541        dir
25542    }
25543
25544    /// Two accounts serving one project's model, in pin order.
25545    fn make_two_account_workspace_dir() -> tempfile::TempDir {
25546        let dir = tempfile::tempdir().expect("tempdir");
25547        std::fs::write(
25548            forge_toml_path(dir.path()),
25549            r#"
25550[[orgs]]
25551name = "Default"
25552accounts = ["First", "Second"]
25553
25554[[orgs.projects]]
25555name = "forge"
25556path = "~/Projects/forge"
25557model = "claude-sonnet-5"
25558
25559[[accounts]]
25560display_name = "First"
25561token = "t"
25562models = ["claude-sonnet-5"]
25563provider = "anthropic"
25564
25565[[accounts]]
25566display_name = "Second"
25567token = "t"
25568models = ["claude-sonnet-5"]
25569provider = "anthropic"
25570"#,
25571        )
25572        .expect("write forge.toml");
25573        dir
25574    }
25575
25576    /// A project `model` fills the CLI's model slots: all five slot
25577    /// variables carry that one model on the spawned child.
25578    #[tokio::test]
25579    async fn a_project_model_fills_the_cli_model_slots() {
25580        let dir = tempfile::tempdir().expect("tempdir");
25581        std::fs::write(
25582            forge_toml_path(dir.path()),
25583            r#"
25584[[orgs]]
25585name = "Default"
25586accounts = ["Stargate"]
25587
25588[[orgs.projects]]
25589name = "forge"
25590path = "~/Projects/forge"
25591model = "claude-sonnet-5"
25592
25593[[accounts]]
25594display_name = "Stargate"
25595token = "t"
25596models = ["claude-sonnet-5"]
25597provider = "anthropic"
25598"#,
25599        )
25600        .expect("write forge.toml");
25601        let workspace =
25602            std::sync::Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25603        workspace.seed_test_gateway_ready(true);
25604        workspace.seed_test_ready_account("Stargate");
25605        let handle = workspace
25606            .get_agent_handle(
25607                SessionTarget::Default,
25608                SessionLaunchSettings::default(),
25609                &crate::protocol::SpawnRole::Lead,
25610            )
25611            .expect("spawn");
25612        let env = handle.env();
25613        for var in MODEL_SLOT_VARIABLES {
25614            assert_eq!(
25615                env.get(var).map(String::as_str),
25616                Some("claude-sonnet-5"),
25617                "{var} carries the project model",
25618            );
25619        }
25620    }
25621
25622    /// A spawn whose target resolves to no configured project is
25623    /// refused, naming the session and both ways out. It used to spawn
25624    /// unregistered, carrying the account's real credential and a
25625    /// direct base URL - a session the gateway could not see, rotate or
25626    /// cool, and one whose dir forge.toml does not describe.
25627    #[tokio::test]
25628    async fn a_spawn_resolving_to_no_project_is_refused_by_name() {
25629        let dir = tempfile::tempdir().expect("tempdir");
25630        std::fs::write(
25631            forge_toml_path(dir.path()),
25632            r#"
25633[[orgs]]
25634name = "Default"
25635accounts = ["Stargate"]
25636
25637[[orgs.projects]]
25638name = "forge"
25639path = "~/Projects/forge"
25640model = "claude-sonnet-5"
25641
25642[[accounts]]
25643display_name = "Stargate"
25644token = "t"
25645models = ["claude-sonnet-5"]
25646provider = "anthropic"
25647"#,
25648        )
25649        .expect("write forge.toml");
25650        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25651        workspace.seed_test_gateway_ready(true);
25652        workspace.seed_test_ready_account("Stargate");
25653        let target = SessionTarget::Session(SessionSlot::from_str_for_test("orphan-uuid"));
25654        let error = workspace
25655            .get_agent_handle(
25656                target,
25657                SessionLaunchSettings::default(),
25658                &crate::protocol::SpawnRole::Lead,
25659            )
25660            .err()
25661            .expect("a session mapping to no project must not spawn");
25662
25663        let message = format!("{error}");
25664        assert!(
25665            message.contains("orphan-uuid"),
25666            "the refusal names the session, so the row is identifiable: {message}",
25667        );
25668        assert!(message.contains("Add that project"), "and names the way forward: {message}");
25669        assert!(
25670            workspace.pool.lock().is_empty(),
25671            "nothing is pooled, so no unregistered child is running",
25672        );
25673    }
25674
25675    /// `project_would_bind` is what a project row's accounts refusal
25676    /// reads, so an always-false regression would refuse every project
25677    /// and nothing else would fail.
25678    #[tokio::test]
25679    async fn project_would_bind_tracks_the_walk() {
25680        let dir = make_workspace_dir_246();
25681        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25682        let project = workspace.list_projects().into_iter().next().expect("one project");
25683
25684        assert!(
25685            !workspace.project_would_bind(&project.key),
25686            "an account still resolving is nothing the walk can pick",
25687        );
25688
25689        workspace.seed_test_ready_account("Stargate");
25690        assert!(
25691            workspace.project_would_bind(&project.key),
25692            "a ready account declaring the project's model is what a spawn lands on",
25693        );
25694
25695        workspace.seed_test_account_state("Stargate", forge_gateway::LoadingState::Bailed);
25696        assert!(
25697            workspace.project_would_bind(&project.key),
25698            "a bailed account is still picked when nothing else declares the model, \
25699             so the row stays clickable",
25700        );
25701
25702        // Agent-cooling, which is the one settled state the walk refuses:
25703        // it is what draws a project row's refusal on a project that does
25704        // declare a model.
25705        workspace.seed_test_ready_account("Stargate");
25706        let an_hour_out = std::time::SystemTime::now()
25707            .duration_since(std::time::SystemTime::UNIX_EPOCH)
25708            .expect("clock after the epoch")
25709            .as_secs()
25710            + 3600;
25711        workspace.gateway.report_probe_limit(&AccountKey("Stargate".to_owned()), Some(an_hour_out));
25712        assert!(
25713            !workspace.project_would_bind(&project.key),
25714            "every declaring account cooling is nothing to spawn on, so the row blocks",
25715        );
25716    }
25717
25718    /// The spawn's notice: an account the walk had to take while
25719    /// saturated or bailed comes back named, one with room does not, so
25720    /// the tool result carries a notice exactly when there is something
25721    /// to warn about.
25722    #[tokio::test]
25723    async fn a_degraded_account_is_named_and_an_account_with_room_is_not() {
25724        let dir = make_workspace_dir_246();
25725        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25726        let key = AccountKey("Stargate".to_owned());
25727
25728        workspace.account_pool().set_usage(&key, usage_at(100.0));
25729        assert_eq!(
25730            workspace.degraded_account_name("Stargate").as_deref(),
25731            Some("Stargate"),
25732            "a saturated account is named, so the spawn warns the worker may throttle",
25733        );
25734
25735        workspace.account_pool().set_usage(&key, usage_at(10.0));
25736        assert_eq!(
25737            workspace.degraded_account_name("Stargate"),
25738            None,
25739            "an account with room is not named, so the result carries no notice",
25740        );
25741
25742        workspace.account_pool().set_loading(&key, forge_gateway::LoadingState::Bailed);
25743        assert_eq!(
25744            workspace.degraded_account_name("Stargate").as_deref(),
25745            Some("Stargate"),
25746            "a bailed account is named too",
25747        );
25748    }
25749
25750    /// Two accounts and a project that declares no model, which is the
25751    /// fixture for `apply_project_model`'s absent-model case.
25752    fn make_workspace_dir_lead_and_worker() -> tempfile::TempDir {
25753        let dir = tempfile::tempdir().expect("tempdir");
25754        std::fs::write(
25755            forge_toml_path(dir.path()),
25756            r#"
25757[[orgs]]
25758name = "Default"
25759accounts = ["Alpha", "Beta"]
25760
25761[[orgs.projects]]
25762name = "forge"
25763path = "~/Projects/forge"
25764
25765[[accounts]]
25766display_name = "Alpha"
25767token = "t"
25768models = ["claude-sonnet-5"]
25769provider = "anthropic"
25770
25771[[accounts]]
25772display_name = "Beta"
25773token = "t"
25774models = ["claude-sonnet-5"]
25775provider = "anthropic"
25776"#,
25777        )
25778        .expect("write forge.toml");
25779        dir
25780    }
25781
25782    /// The probe-glue edge: record_usage_success with any window at
25783    /// the cap reports the gateway, which rotates every session bound
25784    /// to that account. A probe under the cap reports nothing.
25785    #[tokio::test]
25786    async fn record_usage_success_reports_probe_exhaustion_to_the_gateway() {
25787        let dir = make_workspace_dir_246();
25788        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25789        let pool = workspace.account_pool();
25790        pool.set_usage(&AccountKey("Stargate".to_owned()), usage_at(10.0));
25791        workspace.gateway.bindings.bind("Org", "forge", "s1", AccountKey("Stargate".to_owned()));
25792        workspace.record_usage_success(&AccountKey("Stargate".to_owned()), usage_at(100.0));
25793        assert!(
25794            workspace.gateway.bindings.binding_for("Org", "forge", "s1").is_none(),
25795            "a probe verdict at the cap rotates the bound session",
25796        );
25797
25798        workspace.gateway.bindings.bind("Org", "forge", "s2", AccountKey("Stargate".to_owned()));
25799        workspace.record_usage_success(&AccountKey("Stargate".to_owned()), usage_at(10.0));
25800        assert!(
25801            workspace.gateway.bindings.binding_for("Org", "forge", "s2").is_some(),
25802            "a probe under the cap rotates nothing",
25803        );
25804    }
25805
25806    /// A write to the pool is announced.
25807    ///
25808    /// The home's account card is a snapshot field with no stream of its own:
25809    /// the per-account loading state, the settled flag and the listener's
25810    /// readiness all move without any seat's news, so a page that read them
25811    /// once drew `0 ready, probing` for the life of the connection while the
25812    /// pool had been ready for minutes. Nothing else in the stream says the
25813    /// pool moved, so the announcement is the only way a subscriber hears it.
25814    #[tokio::test]
25815    async fn a_probe_that_writes_the_pool_announces_it() {
25816        let dir = make_workspace_dir_246();
25817        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25818        let mut updates = workspace.subscribe_answerer();
25819        // The boot backlog, so what the assertion reads is this call's news
25820        // rather than something emitted before it.
25821        while updates.try_recv().is_ok() {}
25822
25823        workspace.record_usage_success(&AccountKey("Stargate".to_owned()), usage_at(10.0));
25824
25825        let frame = std::iter::from_fn(|| updates.try_recv().ok())
25826            .find(|frame| matches!(&*frame.update, SessionUpdate::AccountsChanged { .. }))
25827            .expect("a probe that wrote the pool announced nothing");
25828        let SessionUpdate::AccountsChanged { accounts } =
25829            std::sync::Arc::unwrap_or_clone(frame.update)
25830        else {
25831            unreachable!("the predicate above selected this variant")
25832        };
25833        // The frame and the snapshot's field are one builder, so a client that
25834        // folds it draws what a client that reads the home draws.
25835        assert_eq!(
25836            accounts,
25837            workspace.account_pool_view(),
25838            "the announcement carried a different pool than the snapshot states",
25839        );
25840        assert_eq!(accounts.loading.len(), 1, "the pool's configured account is in the frame");
25841        assert_eq!(accounts.loading[0].display_name, "Stargate");
25842        assert!(
25843            accounts.usage[0].snapshot.is_some(),
25844            "the usage row carries the snapshot the poll wrote",
25845        );
25846    }
25847
25848    /// The pool pairs each account's loading row with its own usage row.
25849    ///
25850    /// Two accounts with different figures: a builder keyed off the wrong
25851    /// name would hand one account the other's usage, and the band card and
25852    /// the rail chip would draw the wrong account's numbers.
25853    #[tokio::test]
25854    async fn the_pool_view_pairs_each_account_with_its_own_usage() {
25855        let dir = tempfile::tempdir().expect("tempdir");
25856        std::fs::write(
25857            forge_toml_path(dir.path()),
25858            r#"
25859[[orgs]]
25860name = "Default"
25861accounts = ["A", "B"]
25862
25863[[orgs.projects]]
25864name = "forge"
25865path = "~/Projects/forge"
25866model = "claude-sonnet-5"
25867
25868[[accounts]]
25869display_name = "A"
25870token = "t"
25871models = ["claude-sonnet-5"]
25872provider = "anthropic"
25873
25874[[accounts]]
25875display_name = "B"
25876token = "t"
25877models = ["claude-sonnet-5"]
25878provider = "anthropic"
25879"#,
25880        )
25881        .expect("write forge.toml");
25882        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25883
25884        workspace.record_usage_success(&AccountKey("A".to_owned()), usage_at(10.0));
25885        workspace.record_usage_success(&AccountKey("B".to_owned()), usage_at(20.0));
25886
25887        let pool = workspace.account_pool_view();
25888        let loading: Vec<&str> = pool.loading.iter().map(|row| row.display_name.as_str()).collect();
25889        assert_eq!(loading, ["A", "B"], "the loading rows keep declaration order");
25890        let usage: Vec<(&str, Option<f64>)> = pool
25891            .usage
25892            .iter()
25893            .map(|row| {
25894                (
25895                    row.display_name.as_str(),
25896                    row.snapshot
25897                        .as_ref()
25898                        .and_then(forge_primitives::usage::UsageSnapshot::five_hour_util),
25899                )
25900            })
25901            .collect();
25902        assert_eq!(
25903            usage,
25904            [("A", Some(10.0)), ("B", Some(20.0))],
25905            "each account carries its own snapshot",
25906        );
25907    }
25908
25909    /// A poll that found the pool as it already stood announces nothing.
25910    ///
25911    /// **`fetched_at` moves on every probe**, so a comparison against the
25912    /// whole snapshot makes every poll news: six idle accounts then send six
25913    /// pool frames a minute for figures nobody changed. What is news is the
25914    /// pool's loading state or its usage, and a probe that moved either still
25915    /// announces.
25916    #[tokio::test]
25917    async fn a_probe_announces_only_a_pool_that_moved() {
25918        fn announced(updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>) -> usize {
25919            std::iter::from_fn(|| updates.try_recv().ok())
25920                .filter(|frame| matches!(&*frame.update, SessionUpdate::AccountsChanged { .. }))
25921                .count()
25922        }
25923
25924        let dir = make_workspace_dir_246();
25925        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25926        let mut updates = workspace.subscribe_answerer();
25927        while updates.try_recv().is_ok() {}
25928
25929        let key = AccountKey("Stargate".to_owned());
25930        let first = usage_at(10.0);
25931        workspace.record_usage_success(&key, first.clone());
25932        assert_eq!(announced(&mut updates), 1, "the loading transition is news");
25933
25934        let mut later = first.clone();
25935        later.fetched_at = first.fetched_at + std::time::Duration::from_secs(60);
25936        workspace.record_usage_success(&key, later);
25937        assert_eq!(announced(&mut updates), 0, "an unchanged pool is no news");
25938
25939        workspace.record_usage_success(&key, usage_at(11.0));
25940        assert_eq!(announced(&mut updates), 1, "a window that moved is news");
25941    }
25942
25943    /// A probe that moves only the pool's loading state is news, even when
25944    /// its figures match the ones already held.
25945    ///
25946    /// The pool can hold a snapshot while an account is still `Loading` -
25947    /// the cache seeds usage before anything settles the state - and a poll
25948    /// that is what moves it to `Ready` changes nothing else. Without the
25949    /// loading clause the card keeps `0 ready, probing` until something else
25950    /// moves.
25951    #[tokio::test]
25952    async fn a_probe_that_only_settles_the_loading_state_is_news() {
25953        use forge_gateway::LoadingState;
25954
25955        let dir = make_workspace_dir_246();
25956        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25957        let mut updates = workspace.subscribe_answerer();
25958        while updates.try_recv().is_ok() {}
25959        let key = AccountKey("Stargate".to_owned());
25960
25961        let cached = usage_at(10.0);
25962        workspace.accounts.set_usage(&key, cached.clone());
25963        workspace.accounts.set_loading(&key, LoadingState::Loading);
25964
25965        let mut same = cached;
25966        same.fetched_at += std::time::Duration::from_secs(60);
25967        workspace.record_usage_success(&key, same);
25968
25969        let announced = std::iter::from_fn(|| updates.try_recv().ok())
25970            .filter(|frame| matches!(&*frame.update, SessionUpdate::AccountsChanged { .. }))
25971            .count();
25972        assert_eq!(announced, 1, "the Loading -> Ready move was not announced");
25973    }
25974
25975    /// A success after a transient failure is news even when its figures
25976    /// match the ones held.
25977    ///
25978    /// The loading row carries the last probe failure and its hold-down, and
25979    /// a clean probe clears both. With the usage identical that is the only
25980    /// change in the pool, so a comparison that looks at the figures alone
25981    /// drops the read and leaves a client drawing a failure the pool no
25982    /// longer holds.
25983    #[tokio::test]
25984    async fn a_probe_after_a_failure_is_news_even_when_the_figures_match() {
25985        use forge_gateway::UsageFetchStatus;
25986
25987        let dir = make_workspace_dir_246();
25988        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
25989        let mut updates = workspace.subscribe_answerer();
25990        while updates.try_recv().is_ok() {}
25991        let key = AccountKey("Stargate".to_owned());
25992
25993        let held = usage_at(10.0);
25994        workspace.accounts.set_usage(&key, held.clone());
25995        workspace.accounts.set_last_error(
25996            &key,
25997            UsageFetchStatus::RateLimited,
25998            Some(std::time::Duration::from_secs(120)),
25999        );
26000
26001        let mut same = held;
26002        same.fetched_at += std::time::Duration::from_secs(60);
26003        workspace.record_usage_success(&key, same);
26004
26005        let announced = std::iter::from_fn(|| updates.try_recv().ok())
26006            .filter(|frame| matches!(&*frame.update, SessionUpdate::AccountsChanged { .. }))
26007            .count();
26008        assert_eq!(announced, 1, "the cleared probe failure was not announced");
26009    }
26010
26011    /// An org whose primaries cannot serve the project's model and whose
26012    /// fallback can: the walk reaches the fallback because the primaries
26013    /// declare nothing it serves.
26014    fn make_workspace_dir_fallback_only_model() -> tempfile::TempDir {
26015        let dir = tempfile::tempdir().expect("tempdir");
26016        std::fs::write(
26017            forge_toml_path(dir.path()),
26018            r#"
26019[[orgs]]
26020name = "Default"
26021accounts = ["Personal"]
26022fallback_accounts = ["OpenRouter-TM"]
26023
26024[[orgs.projects]]
26025name = "forge"
26026path = "~/Projects/forge"
26027model = "deepseek-v4.1-flash"
26028
26029[[accounts]]
26030display_name = "Personal"
26031token = "t"
26032models = ["claude-opus-5"]
26033provider = "anthropic"
26034
26035[[accounts]]
26036display_name = "OpenRouter-TM"
26037token = "t"
26038models = ["deepseek-v4.1-flash"]
26039provider = "openrouter"
26040base_url = "https://openrouter.ai/api"
26041"#,
26042        )
26043        .expect("write forge.toml");
26044        dir
26045    }
26046
26047    /// Two primaries where only the second serves the project's model.
26048    fn make_workspace_dir_model_split() -> tempfile::TempDir {
26049        let dir = tempfile::tempdir().expect("tempdir");
26050        std::fs::write(
26051            forge_toml_path(dir.path()),
26052            r#"
26053[[orgs]]
26054name = "Default"
26055accounts = ["Personal", "OpenRouter-TM"]
26056
26057[[orgs.projects]]
26058name = "forge"
26059path = "~/Projects/forge"
26060model = "deepseek-v4.1-flash"
26061
26062[[accounts]]
26063display_name = "Personal"
26064token = "t"
26065models = ["claude-opus-5"]
26066provider = "anthropic"
26067
26068[[accounts]]
26069display_name = "OpenRouter-TM"
26070token = "t"
26071models = ["deepseek-v4.1-flash"]
26072provider = "openrouter"
26073base_url = "https://openrouter.ai/api"
26074"#,
26075        )
26076        .expect("write forge.toml");
26077        dir
26078    }
26079
26080    /// The project's model is served only by a fallback account: the walk
26081    /// reaches it, so the session lands on the declaring account rather
26082    /// than on a primary that cannot serve it.
26083    #[tokio::test]
26084    async fn a_model_declaring_project_spawns_on_the_fallback_that_serves_it() {
26085        let dir = make_workspace_dir_fallback_only_model();
26086        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26087        let pool = workspace.account_pool();
26088        pool.set_usage(&AccountKey("Personal".to_owned()), usage_at(10.0));
26089        pool.set_usage(&AccountKey("OpenRouter-TM".to_owned()), usage_at(10.0));
26090        let handle = workspace
26091            .get_agent_handle(
26092                SessionTarget::Named("forge".to_owned()),
26093                SessionLaunchSettings::default(),
26094                &crate::protocol::SpawnRole::Lead,
26095            )
26096            .expect("the declaring fallback serves the spawn");
26097        assert_eq!(
26098            handle.display_name().as_deref(),
26099            Some("OpenRouter-TM"),
26100            "the spawn lands on the account that declares the project's model",
26101        );
26102    }
26103
26104    /// The spawn pins the project's canonical model into the launch
26105    /// settings - which is how the session's model reaches the CLI - and
26106    /// a project that declares no model leaves the caller's pin alone.
26107    #[test]
26108    fn the_project_model_is_pinned_into_the_launch_settings() {
26109        let dir = make_workspace_dir_model_split();
26110        let config = crate::config::load_from_dir(dir.path()).expect("load");
26111        let project = config.projects.iter().find(|p| p.name == "forge").expect("forge project");
26112        let mut settings = SessionLaunchSettings {
26113            settings: Some(serde_json::json!({"effortLevel": "max", "model": "opus"})),
26114            ..SessionLaunchSettings::default()
26115        };
26116        apply_project_model(Some(project), &mut settings);
26117        let document = settings.settings.expect("document");
26118        assert_eq!(
26119            document["model"],
26120            serde_json::json!("deepseek-v4.1-flash"),
26121            "the canonical model replaces the caller's pin",
26122        );
26123        assert_eq!(
26124            document["effortLevel"],
26125            serde_json::json!("max"),
26126            "the other settings keys survive the pin",
26127        );
26128
26129        let bare_dir = make_workspace_dir_lead_and_worker();
26130        let bare_config = crate::config::load_from_dir(bare_dir.path()).expect("load");
26131        let mut bare_settings = SessionLaunchSettings {
26132            settings: Some(serde_json::json!({"model": "opus"})),
26133            ..SessionLaunchSettings::default()
26134        };
26135        apply_project_model(bare_config.projects.first(), &mut bare_settings);
26136        assert_eq!(
26137            bare_settings.settings.expect("document")["model"],
26138            serde_json::json!("opus"),
26139            "a project that declares no model leaves the caller's pin alone",
26140        );
26141    }
26142
26143    /// The refusal is a stated requirement: it names the project, the
26144    /// model, the org and the accounts considered.
26145    #[test]
26146    fn the_refusal_names_the_project_model_org_and_accounts_considered() {
26147        let error = WorkspaceError::NoAccountServesProjectModel {
26148            project: "steve".to_owned(),
26149            org: "Busytools".to_owned(),
26150            model: "glm-5.3-flash".to_owned(),
26151            accounts: "Zai, Personal".to_owned(),
26152        };
26153        let message = error.to_string();
26154        for needle in ["steve", "glm-5.3-flash", "Busytools", "Zai, Personal"] {
26155            assert!(message.contains(needle), "the refusal must name {needle}: {message}");
26156        }
26157    }
26158
26159    fn usage_at(five_hour_util: f64) -> forge_primitives::usage::UsageSnapshot {
26160        forge_primitives::usage::UsageSnapshot {
26161            source: forge_primitives::usage::UsageSourceKind::Oauth,
26162            fetched_at: std::time::SystemTime::UNIX_EPOCH,
26163            five_hour: Some(forge_primitives::usage::UsageWindow {
26164                utilization: five_hour_util,
26165                resets_at: Some(
26166                    std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
26167                ),
26168                reset_description: None,
26169                status: None,
26170            }),
26171            seven_day: None,
26172            seven_day_opus: None,
26173            seven_day_sonnet: None,
26174            extra_usage: None,
26175            spend: None,
26176            balance: None,
26177        }
26178    }
26179
26180    #[tokio::test]
26181    async fn a_chip_needs_an_account_the_walk_can_find() {
26182        let dir = make_workspace_dir_246();
26183        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26184        let project_key =
26185            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26186                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26187            )));
26188        assert!(
26189            workspace.session_chip_for(&project_key).is_none(),
26190            "nothing is Ready yet, so the walk finds no account to chip with",
26191        );
26192    }
26193
26194    #[tokio::test]
26195    async fn session_chip_for_normal_branch_for_ready_account() {
26196        let dir = make_workspace_dir_246();
26197        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26198        workspace.account_pool().set_usage(
26199            &AccountKey("Stargate".to_owned()),
26200            forge_primitives::usage::UsageSnapshot {
26201                source: forge_primitives::usage::UsageSourceKind::Oauth,
26202                fetched_at: std::time::SystemTime::UNIX_EPOCH,
26203                // 5h window not at cap -> Normal branch.
26204                five_hour: Some(forge_primitives::usage::UsageWindow {
26205                    utilization: 30.0,
26206                    resets_at: Some(
26207                        std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
26208                    ),
26209                    reset_description: None,
26210                    status: None,
26211                }),
26212                seven_day: None,
26213                seven_day_opus: None,
26214                seven_day_sonnet: None,
26215                extra_usage: None,
26216                spend: None,
26217                balance: None,
26218            },
26219        );
26220        let project_key =
26221            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26222                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26223            )));
26224        let chip = workspace.session_chip_for(&project_key).expect("chip");
26225        assert_eq!(chip.state, SessionChipState::Normal);
26226        assert_eq!(chip.account_name, "Stargate");
26227    }
26228
26229    #[tokio::test]
26230    async fn session_chip_for_at_cap_branch() {
26231        let dir = make_workspace_dir_246();
26232        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26233        workspace.account_pool().set_usage(
26234            &AccountKey("Stargate".to_owned()),
26235            forge_primitives::usage::UsageSnapshot {
26236                source: forge_primitives::usage::UsageSourceKind::Oauth,
26237                fetched_at: std::time::SystemTime::UNIX_EPOCH,
26238                // 5h window at 100% with future resets_at -> AtCap branch.
26239                five_hour: Some(forge_primitives::usage::UsageWindow {
26240                    utilization: 100.0,
26241                    resets_at: Some(
26242                        std::time::SystemTime::now() + std::time::Duration::from_secs(3600),
26243                    ),
26244                    reset_description: None,
26245                    status: None,
26246                }),
26247                seven_day: None,
26248                seven_day_opus: None,
26249                seven_day_sonnet: None,
26250                extra_usage: None,
26251                spend: None,
26252                balance: None,
26253            },
26254        );
26255        let project_key =
26256            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26257                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26258            )));
26259        let chip = workspace.session_chip_for(&project_key).expect("chip");
26260        assert_eq!(chip.state, SessionChipState::AtCap);
26261    }
26262
26263    #[tokio::test]
26264    async fn session_chip_for_at_cap_on_weekly_window() {
26265        // A weekly (7-day) cap alone, 5h window clear, must still flag
26266        // the chip AtCap - saturation is any-window, not 5h-only.
26267        let dir = make_workspace_dir_246();
26268        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26269        workspace.account_pool().set_usage(
26270            &AccountKey("Stargate".to_owned()),
26271            forge_primitives::usage::UsageSnapshot {
26272                source: forge_primitives::usage::UsageSourceKind::Oauth,
26273                fetched_at: std::time::SystemTime::UNIX_EPOCH,
26274                five_hour: None,
26275                seven_day: Some(forge_primitives::usage::UsageWindow {
26276                    utilization: 100.0,
26277                    resets_at: Some(
26278                        std::time::SystemTime::now() + std::time::Duration::from_secs(86_400),
26279                    ),
26280                    reset_description: None,
26281                    status: None,
26282                }),
26283                seven_day_opus: None,
26284                seven_day_sonnet: None,
26285                extra_usage: None,
26286                spend: None,
26287                balance: None,
26288            },
26289        );
26290        let project_key =
26291            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26292                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26293            )));
26294        let chip = workspace.session_chip_for(&project_key).expect("chip");
26295        assert_eq!(chip.state, SessionChipState::AtCap);
26296    }
26297
26298    /// **A chip resolves while the walk refuses.** A cooling account is the
26299    /// one a spawn would land on once its window lifts, so the read names
26300    /// it and says it is rate-limited; the walk and the spawn refusal are
26301    /// untouched.
26302    #[tokio::test]
26303    async fn session_chip_for_names_the_cooling_account() {
26304        let dir = make_workspace_dir_246();
26305        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26306        let key = AccountKey("Stargate".to_owned());
26307        workspace.account_pool().set_usage(&key, usage_at(100.0));
26308        let reset_at = std::time::SystemTime::now()
26309            .duration_since(std::time::SystemTime::UNIX_EPOCH)
26310            .expect("the test clock is past the epoch")
26311            .as_secs()
26312            + 3600;
26313        workspace.gateway.report_probe_limit(&key, Some(reset_at));
26314
26315        let project_key =
26316            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26317                workspace.config.read().projects[0].path.to_string_lossy().as_ref(),
26318            )));
26319        let chip = workspace
26320            .session_chip_for(&project_key)
26321            .expect("the read names the account a spawn would land on");
26322        assert_eq!(chip.account_name, "Stargate");
26323        assert_eq!(chip.state, SessionChipState::RateLimited);
26324
26325        assert!(
26326            matches!(
26327                workspace.gateway.select_for("Default", "claude-sonnet-5"),
26328                Err(forge_gateway::SelectFailure::BudgetExhausted { .. }),
26329            ),
26330            "the read surface must not have made the cooling account selectable",
26331        );
26332    }
26333
26334    /// **With two cooling accounts the chip follows the thaw, not pin
26335    /// order.** The first spawn after the cooldowns lift lands on the
26336    /// account whose reset is soonest, so that is the one whose windows and
26337    /// reset the footer must state.
26338    #[tokio::test]
26339    async fn session_chip_for_names_the_cooling_account_that_thaws_first() {
26340        let dir = make_two_account_workspace_dir();
26341        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26342        let first = AccountKey("First".to_owned());
26343        let second = AccountKey("Second".to_owned());
26344        workspace.account_pool().set_usage(&first, usage_at(42.0));
26345        workspace.account_pool().set_usage(&second, usage_at(100.0));
26346        let now = std::time::SystemTime::now()
26347            .duration_since(std::time::SystemTime::UNIX_EPOCH)
26348            .expect("the test clock is past the epoch")
26349            .as_secs();
26350        workspace.gateway.report_probe_limit(&first, Some(now + 2 * 3600));
26351        workspace.gateway.report_probe_limit(&second, Some(now + 10 * 60));
26352
26353        let project_key =
26354            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26355                workspace.config.read().projects[0].path.to_string_lossy().as_ref(),
26356            )));
26357        let chip = workspace
26358            .session_chip_for(&project_key)
26359            .expect("the read names the account a spawn would land on");
26360        assert_eq!(
26361            chip.account_name, "Second",
26362            "the chip named the first-pinned account instead of the one that thaws first",
26363        );
26364        assert_eq!(chip.state, SessionChipState::RateLimited);
26365    }
26366
26367    #[tokio::test]
26368    async fn session_chip_for_bailed_branch() {
26369        let dir = make_workspace_dir_246();
26370        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26371        // Snapshot first, then bail: the walk reads the loading state,
26372        // so a chip needs the account first made pickable.
26373        workspace.account_pool().set_usage(
26374            &AccountKey("Stargate".to_owned()),
26375            forge_primitives::usage::UsageSnapshot {
26376                source: forge_primitives::usage::UsageSourceKind::Oauth,
26377                fetched_at: std::time::SystemTime::UNIX_EPOCH,
26378                five_hour: None,
26379                seven_day: None,
26380                seven_day_opus: None,
26381                seven_day_sonnet: None,
26382                extra_usage: None,
26383                spend: None,
26384                balance: None,
26385            },
26386        );
26387        // Now flip to Bailed on an auth failure - the red class.
26388        workspace
26389            .account_pool()
26390            .set_loading(&AccountKey("Stargate".to_owned()), forge_gateway::LoadingState::Bailed);
26391        workspace.account_pool().set_last_error(
26392            &AccountKey("Stargate".to_owned()),
26393            forge_gateway::UsageFetchStatus::Unauthorized,
26394            None,
26395        );
26396        let project_key =
26397            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26398                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26399            )));
26400        let chip = workspace.session_chip_for(&project_key).expect("chip");
26401        assert_eq!(chip.state, SessionChipState::Bailed);
26402    }
26403
26404    #[tokio::test]
26405    async fn session_chip_for_degraded_branch() {
26406        // A Bailed account with no auth class recorded - the
26407        // shape-drift settle, or a rate limit - is the warning-yellow
26408        // Degraded chip, the same split the account rows render.
26409        let dir = make_workspace_dir_246();
26410        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26411        workspace.account_pool().set_usage(
26412            &AccountKey("Stargate".to_owned()),
26413            forge_primitives::usage::UsageSnapshot {
26414                source: forge_primitives::usage::UsageSourceKind::Oauth,
26415                fetched_at: std::time::SystemTime::UNIX_EPOCH,
26416                five_hour: None,
26417                seven_day: None,
26418                seven_day_opus: None,
26419                seven_day_sonnet: None,
26420                extra_usage: None,
26421                spend: None,
26422                balance: None,
26423            },
26424        );
26425        workspace
26426            .account_pool()
26427            .set_loading(&AccountKey("Stargate".to_owned()), forge_gateway::LoadingState::Bailed);
26428        let project_key =
26429            ProjectKey::new(forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
26430                workspace.config_arc().projects[0].path.to_string_lossy().as_ref(),
26431            )));
26432        let chip = workspace.session_chip_for(&project_key).expect("chip");
26433        assert_eq!(chip.state, SessionChipState::Degraded);
26434    }
26435
26436    /// One transcript under the storage key of `run_dir`, tagged for `label`
26437    /// when there is one, with its mtime pinned to `secs` so the newest-first
26438    /// order is a fact rather than the clock's granularity.
26439    fn write_picker_transcript(
26440        cfg: &tempfile::TempDir,
26441        run_dir: &std::path::Path,
26442        id: &str,
26443        label: Option<&str>,
26444        secs: u64,
26445    ) {
26446        let run_dir = run_dir.to_string_lossy().replace('\\', "/");
26447        let key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&run_dir));
26448        let dir = forge_sdk::projects_dir_for(cfg.path()).join(key);
26449        std::fs::create_dir_all(&dir).expect("transcript dir");
26450        let tag = label
26451            .map(|label| format!("{{\"type\":\"tag\",\"tag\":\"forge:worker:{label}\"}}\n"))
26452            .unwrap_or_default();
26453        let body = format!(
26454            "{{\"type\":\"user\",\"cwd\":\"{run_dir}\",\"message\":{{\"content\":\"session {id}\"}}}}\n{tag}"
26455        );
26456        let path = dir.join(format!("{id}.jsonl"));
26457        std::fs::write(&path, body).expect("write transcript");
26458        let file = std::fs::File::options().write(true).open(&path).expect("open the transcript");
26459        file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
26460            .expect("pin the transcript's mtime");
26461    }
26462
26463    /// A worker's list is only what its own label ran, and only in the
26464    /// directory its resume runs in: another worker's transcript and an
26465    /// untagged one in the same worktree are both out.
26466    #[tokio::test]
26467    async fn resume_sessions_of_a_worker_are_only_its_own_labels() {
26468        let project = tempfile::tempdir().expect("project dir");
26469        let cfg = tempfile::tempdir().expect("cfg dir");
26470        let (ws, _rx) = Workspace::testing_stub_with_config_dir(cfg.path().to_owned());
26471        let root = project.path().to_string_lossy().replace('\\', "/");
26472        ws.seed_test_project("demo", &root);
26473        let seat = SessionSlot::worker("TestOrg", "demo", "alpha");
26474        let project_key = ProjectKey::new(
26475            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(&root)),
26476        );
26477        ws.insert_live_worker(&project_key, worker_entry("alpha", &seat, true));
26478        let worktree = project.path().join(".claude/worktrees/alpha");
26479        std::fs::create_dir_all(&worktree).expect("worktree dir");
26480        write_picker_transcript(&cfg, &worktree, "alpha-new", Some("alpha"), 1_700_000_200);
26481        write_picker_transcript(&cfg, &worktree, "alpha-old", Some("alpha"), 1_700_000_100);
26482        write_picker_transcript(&cfg, &worktree, "beta", Some("beta"), 1_700_000_300);
26483        write_picker_transcript(&cfg, &worktree, "untagged", None, 1_700_000_400);
26484        // The same label's run from another tree, which `--resume` from this
26485        // seat's directory cannot find at all.
26486        let elsewhere = project.path().join("elsewhere");
26487        std::fs::create_dir_all(&elsewhere).expect("another tree");
26488        write_picker_transcript(&cfg, &elsewhere, "elsewhere", Some("alpha"), 1_700_000_500);
26489
26490        let found = ws.resumable_sessions(&seat).await;
26491
26492        assert_eq!(
26493            found.iter().map(|info| info.session_id.as_str()).collect::<Vec<_>>(),
26494            ["alpha-new", "alpha-old"],
26495            "its own label's runs in its own directory, newest first, and nothing else",
26496        );
26497    }
26498}
26499
26500#[cfg(test)]
26501#[allow(clippy::expect_used, clippy::unwrap_used)]
26502mod kick_dispatcher_tests {
26503    //! Cover for `start_kick_dispatcher` plus `enqueue_kick` (#259).
26504    //!
26505    //! Tests observe via `command_intercept` (`enable_test_dispatch_intercept`
26506    //! plus `drain_test_dispatch_buffer`); the drainer calls
26507    //! `Workspace::dispatch_forged_prompt` for each `KickRequest`, which the
26508    //! intercept buffer captures verbatim and whose prompt frame the update
26509    //! stream carries.
26510    //!
26511    //! Time is paused (`start_paused = true`) so the drainer's
26512    //! `tokio::time::sleep(KICK_DISPATCH_INTERVAL)` advances only when
26513    //! the test explicitly advances the clock. Without that, the
26514    //! drainer would race the assertions in real time.
26515    use super::*;
26516    use crate::protocol::Command;
26517    use std::time::Duration;
26518
26519    /// A drained kick draws the words it sends the worker.
26520    ///
26521    /// They reach the model on stdin and the CLI does not echo them, so the
26522    /// frame is the only thing a view can draw them from - and reverting the
26523    /// drainer to the delivery path leaves every dispatch-side test in here
26524    /// green, which is why this one asserts the frame.
26525    #[tokio::test(start_paused = true)]
26526    async fn a_drained_kick_draws_its_words() {
26527        let (workspace, mut update_rx) = Workspace::testing_stub();
26528        workspace.enable_test_dispatch_intercept();
26529        workspace.start_kick_dispatcher();
26530
26531        let key = sk("draws");
26532        workspace
26533            .enqueue_kick(KickRequest { slot: key.clone(), prompt_body: "get on with it".into() });
26534        tokio::task::yield_now().await;
26535        tokio::task::yield_now().await;
26536
26537        let mut echoed = None;
26538        while let Ok(frame) = update_rx.try_recv() {
26539            if let crate::protocol::SessionUpdate::ChatAppended { key: addressed, msg, .. } =
26540                std::sync::Arc::unwrap_or_clone(frame.update)
26541                && addressed == key
26542            {
26543                echoed = Some(msg);
26544            }
26545        }
26546        let frame = echoed;
26547        let Some(Message::User { message, .. }) = frame else {
26548            panic!("a drained kick draws the words the worker received")
26549        };
26550        assert!(
26551            matches!(
26552                message.content.first(),
26553                Some(forge_primitives::ContentBlock::Text { text, .. }) if text == "get on with it"
26554            ),
26555            "the frame carries the kick's prose: {message:?}",
26556        );
26557    }
26558
26559    /// Helper: a session key for kick tests.
26560    fn sk(name: &str) -> SessionSlot {
26561        SessionSlot::from_str_for_test(format!("kick-test-{name}"))
26562    }
26563
26564    /// Helper: assert the intercept buffer's Prompt commands match
26565    /// `expected` SessionSlots in order. Filters out any non-Prompt
26566    /// commands the dispatch path might queue.
26567    fn assert_dispatched_kick_keys(
26568        workspace: &Arc<Workspace>,
26569        expected: &[SessionSlot],
26570        context: &str,
26571    ) {
26572        let dispatched = workspace.drain_test_dispatch_buffer();
26573        let keys: Vec<SessionSlot> = dispatched
26574            .into_iter()
26575            .filter_map(|c| match c {
26576                Command::Prompt { key, .. } | Command::PromptUnder { key, .. } => Some(key),
26577                _ => None,
26578            })
26579            .collect();
26580        assert_eq!(keys, expected.to_vec(), "{context}: dispatched keys mismatch");
26581    }
26582
26583    /// First-kick latency is zero (drainer pulls immediately), and
26584    /// the SECOND kick waits `KICK_DISPATCH_INTERVAL` before firing.
26585    /// Pinning the stagger interval prevents a regression where the
26586    /// drainer sleeps before its first send (which would be a
26587    /// straightforward off-by-one bug given the loop's structure).
26588    #[tokio::test(start_paused = true)]
26589    async fn dispatcher_fires_first_kick_immediately_then_staggers() {
26590        let (workspace, _update_rx) = Workspace::testing_stub();
26591        workspace.enable_test_dispatch_intercept();
26592        workspace.start_kick_dispatcher();
26593
26594        let a = sk("a");
26595        let b = sk("b");
26596        workspace.enqueue_kick(KickRequest { slot: a.clone(), prompt_body: "kick a".into() });
26597        workspace.enqueue_kick(KickRequest { slot: b.clone(), prompt_body: "kick b".into() });
26598
26599        // Yield once so the drainer task gets a turn; it should fire
26600        // the first kick before sleeping. With paused time the sleep
26601        // doesn't advance, so the SECOND kick stays pending.
26602        tokio::task::yield_now().await;
26603        tokio::task::yield_now().await;
26604        assert_dispatched_kick_keys(&workspace, std::slice::from_ref(&a), "after first yield");
26605
26606        // Advance time past the interval; drainer wakes, fires the
26607        // second kick, then sleeps again with the queue now empty.
26608        tokio::time::advance(KICK_DISPATCH_INTERVAL).await;
26609        tokio::task::yield_now().await;
26610        tokio::task::yield_now().await;
26611        assert_dispatched_kick_keys(&workspace, std::slice::from_ref(&b), "after interval advance");
26612    }
26613
26614    /// Multi-worker burst: 7 simultaneous enqueues produce exactly
26615    /// one dispatch per interval, all 7 dispatched after 6 advances.
26616    /// Mirrors the issue's reproduction shape (forge boot with 5+
26617    /// team workers).
26618    #[tokio::test(start_paused = true)]
26619    async fn dispatcher_staggers_seven_simultaneous_kicks_one_per_interval() {
26620        let (workspace, _update_rx) = Workspace::testing_stub();
26621        workspace.enable_test_dispatch_intercept();
26622        workspace.start_kick_dispatcher();
26623
26624        let keys: Vec<SessionSlot> = (0..7).map(|i| sk(&format!("worker-{i}"))).collect();
26625        for key in &keys {
26626            workspace.enqueue_kick(KickRequest {
26627                slot: key.clone(),
26628                prompt_body: format!("kick {}", key.display()),
26629            });
26630        }
26631
26632        // First kick fires before any sleep.
26633        tokio::task::yield_now().await;
26634        tokio::task::yield_now().await;
26635        assert_dispatched_kick_keys(&workspace, &keys[0..1], "boot tick");
26636
26637        // Six more intervals → six more kicks → all 7 dispatched.
26638        for i in 1..7 {
26639            tokio::time::advance(KICK_DISPATCH_INTERVAL).await;
26640            tokio::task::yield_now().await;
26641            tokio::task::yield_now().await;
26642            assert_dispatched_kick_keys(
26643                &workspace,
26644                &keys[i..=i],
26645                &format!("after interval advance {i}"),
26646            );
26647        }
26648    }
26649
26650    /// `start_kick_dispatcher` is idempotent: a second call after the
26651    /// receiver has been taken finds the slot empty and no-ops. A
26652    /// regression that spawns two drainers would fire each kick TWICE
26653    /// (both drainers would race for the same receiver - actually
26654    /// only the first would get the item due to mpsc semantics, but
26655    /// the SECOND drainer would burn task slots forever waiting on
26656    /// an empty channel). Pin by counting dispatches against a known
26657    /// queue.
26658    #[tokio::test(start_paused = true)]
26659    async fn start_kick_dispatcher_is_idempotent() {
26660        let (workspace, _update_rx) = Workspace::testing_stub();
26661        workspace.enable_test_dispatch_intercept();
26662        workspace.start_kick_dispatcher();
26663        workspace.start_kick_dispatcher(); // no-op second call
26664
26665        workspace.enqueue_kick(KickRequest { slot: sk("only"), prompt_body: "k".into() });
26666        tokio::task::yield_now().await;
26667        tokio::task::yield_now().await;
26668        let dispatched = workspace.drain_test_dispatch_buffer();
26669        let prompts: Vec<&Command> = dispatched
26670            .iter()
26671            .filter(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. }))
26672            .collect();
26673        assert_eq!(prompts.len(), 1, "second start_kick_dispatcher must not duplicate dispatches");
26674    }
26675
26676    /// `enqueue_kick` after workspace drop is logged but doesn't
26677    /// panic. The channel's sender is held on `Workspace`, so dropping
26678    /// the workspace closes the channel; the drainer (if still alive)
26679    /// exits its recv loop. Verifying the no-panic shape protects the
26680    /// shutdown-race window where a final Connected event might queue
26681    /// a kick after the drop began.
26682    ///
26683    /// We don't drop the Arc here (testing_stub gives one out and the
26684    /// test holds it for the duration). What we DO verify: `enqueue_kick`
26685    /// returns successfully when the dispatcher hasn't been started -
26686    /// the message just sits in the channel until either the drainer
26687    /// is started or the workspace is dropped. Either way, no panic.
26688    #[tokio::test]
26689    async fn enqueue_kick_without_dispatcher_started_does_not_panic() {
26690        let (workspace, _update_rx) = Workspace::testing_stub();
26691        // Note: NOT calling start_kick_dispatcher.
26692        workspace.enqueue_kick(KickRequest { slot: sk("orphan"), prompt_body: "k".into() });
26693        // No assertion target other than "we got here without panicking".
26694        // A future change that makes enqueue_kick require a started
26695        // dispatcher would fail this test.
26696        let _ = Duration::from_millis(0); // touch Duration to keep the use site live
26697    }
26698}
26699
26700#[cfg(test)]
26701#[allow(clippy::expect_used, clippy::unwrap_used)]
26702mod catalog_scan_tests {
26703    use super::*;
26704    use crate::protocol::Command;
26705    use forge_agent::client::SessionLaunchSettings;
26706    use std::fs;
26707    use tempfile::tempdir;
26708
26709    const LEAD_UUID: &str = "00000000-0000-4000-8000-000000000001";
26710    const WORKER_UUID: &str = "00000000-0000-4000-8000-000000000002";
26711
26712    /// One project rooted inside the tempdir plus a transcript whose
26713    /// head carries that cwd, so the scan groups the session under the
26714    /// project's key the same way boot did.
26715    fn scan_fixture_dir() -> tempfile::TempDir {
26716        let dir = tempdir().expect("tempdir");
26717        let project_path = dir.path().join("proj");
26718        let toml = format!(
26719            r#"
26720[[orgs]]
26721name = "Default"
26722accounts = ["Stargate"]
26723[[orgs.projects]]
26724name = "proj"
26725path = "{}"
26726model = "claude-sonnet-5"
26727
26728[[accounts]]
26729display_name = "Stargate"
26730token = "t"
26731models = ["claude-sonnet-5"]
26732provider = "anthropic"
26733"#,
26734            project_path.display()
26735        );
26736        fs::write(forge_toml_path(dir.path()), toml).expect("write forge.toml");
26737        write_session_fixture(dir.path(), &project_path.display().to_string(), LEAD_UUID, None);
26738        dir
26739    }
26740
26741    fn write_session_fixture(
26742        config_dir: &std::path::Path,
26743        project_path: &str,
26744        session_id: &str,
26745        tag: Option<&str>,
26746    ) {
26747        let key =
26748            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(project_path));
26749        let project_dir = config_dir.join("projects").join(key);
26750        fs::create_dir_all(&project_dir).expect("project dir");
26751        let mut body = format!(
26752            "{{\"type\":\"user\",\"timestamp\":\"2026-09-05T00:00:00.000Z\",\"cwd\":\"{project_path}\",\"message\":{{\"content\":\"opening prompt\"}}}}\n"
26753        );
26754        if let Some(tag) = tag {
26755            body = format!(
26756                "{body}{{\"type\":\"tag\",\"tag\":\"{tag}\",\"sessionId\":\"{session_id}\"}}\n"
26757            );
26758        }
26759        fs::write(project_dir.join(format!("{session_id}.jsonl")), body).expect("write jsonl");
26760    }
26761
26762    /// A spawn does not park on the background catalog scan. It reaches
26763    /// the pool immediately against an unloaded catalog, keyed by an id
26764    /// forge minted rather than the lead transcript that catalog holds -
26765    /// the store is the resume source now, and it is empty here.
26766    #[tokio::test]
26767    async fn a_spawn_before_the_scan_does_not_wait_for_it() {
26768        let dir = scan_fixture_dir();
26769        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26770        workspace.seed_test_gateway_ready(true);
26771        workspace.seed_test_ready_account("Stargate");
26772        assert!(!workspace.catalog_ready(), "new_for_test leaves the scan unstarted");
26773
26774        workspace
26775            .dispatch(Command::SpawnProject {
26776                project_name: "proj".to_owned(),
26777                launch_settings: SessionLaunchSettings::default(),
26778            })
26779            .expect("dispatch");
26780
26781        let keys: Vec<String> =
26782            workspace.pool.lock().keys().map(forge_primitives::SessionSlot::display).collect();
26783        assert_eq!(keys.len(), 1, "the spawn reached the pool without waiting for the scan");
26784        assert_ne!(
26785            keys[0], LEAD_UUID,
26786            "and keys to an id forge minted, not the catalog's lead transcript",
26787        );
26788    }
26789
26790    /// The background scan swaps the catalog in and announces it: the
26791    /// update arrives, readiness flips, and the default catalog hides
26792    /// worker-tagged sessions exactly as the synchronous scan did.
26793    #[tokio::test]
26794    async fn catalog_scan_reports_loaded_and_fills_project_views() {
26795        let dir = scan_fixture_dir();
26796        write_session_fixture(
26797            dir.path(),
26798            &dir.path().join("proj").display().to_string(),
26799            WORKER_UUID,
26800            Some("forge:worker:implementer"),
26801        );
26802        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26803        let mut update_rx = workspace.subscribe_answerer();
26804        assert!(workspace.list_projects()[0].sessions.is_empty(), "catalog starts empty");
26805
26806        workspace.start_catalog_scan();
26807
26808        let update = tokio::time::timeout(Duration::from_secs(5), update_rx.recv())
26809            .await
26810            .expect("scan finishes")
26811            .expect("channel open");
26812        assert!(
26813            matches!(&*update.update, SessionUpdate::CatalogLoaded),
26814            "expected CatalogLoaded, got {update:?}"
26815        );
26816        assert!(workspace.catalog_ready());
26817
26818        let projects = workspace.list_projects();
26819        assert_eq!(projects.len(), 1);
26820        assert_eq!(
26821            projects[0].sessions.len(),
26822            1,
26823            "worker-tagged sessions stay hidden from the default catalog"
26824        );
26825        assert_eq!(projects[0].sessions[0].session.as_str(), LEAD_UUID);
26826    }
26827
26828    /// No spawn waits on the boot catalog scan any more: the lead's
26829    /// resume target comes from the store, so a spawn dispatched against
26830    /// an unloaded catalog lands immediately, keyed by the project's
26831    /// lead slot rather than by the lead transcript the catalog holds.
26832    #[tokio::test]
26833    async fn a_spawn_does_not_wait_for_the_catalog_scan() {
26834        let dir = scan_fixture_dir();
26835        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
26836        workspace.seed_test_ready_account("Stargate");
26837        assert!(!workspace.catalog_ready(), "the scan has not landed yet");
26838
26839        workspace
26840            .dispatch(Command::SpawnProject {
26841                project_name: "proj".to_owned(),
26842                launch_settings: SessionLaunchSettings::default(),
26843            })
26844            .expect("dispatch");
26845
26846        let keys: Vec<String> =
26847            workspace.pool.lock().keys().map(forge_primitives::SessionSlot::display).collect();
26848        assert_eq!(keys.len(), 1, "the spawn ran immediately, unparked");
26849        assert_eq!(
26850            keys[0], "Default/proj/lead",
26851            "and keyed by the project's lead slot: the catalog's lead transcript is not a \
26852             routing key, and the store this spawn reads is empty: {keys:?}",
26853        );
26854    }
26855
26856    /// The boot scan persists a tag-cache row per transcript even when
26857    /// the config dir is reached through a symlink, and the same pass
26858    /// prunes a seeded row whose transcript is gone - the prune is part
26859    /// of the scan, not only of the store fn.
26860    #[tokio::test]
26861    async fn boot_scan_warms_the_cache_and_prunes_stale_rows() {
26862        let real = scan_fixture_dir();
26863        write_session_fixture(
26864            real.path(),
26865            &real.path().join("proj").display().to_string(),
26866            WORKER_UUID,
26867            Some("forge:worker:implementer"),
26868        );
26869        let link_dir = tempfile::tempdir().expect("tempdir");
26870        let link = link_dir.path().join("cfg");
26871        std::os::unix::fs::symlink(real.path(), &link).expect("symlink config dir");
26872
26873        let workspace = Arc::new(Workspace::new_for_test(link).expect("new"));
26874        {
26875            let db = workspace.db.lock();
26876            crate::store::session_tags::store_all(
26877                db.as_ref().expect("test db present"),
26878                &[(
26879                    "/gone/deleted.jsonl".to_owned(),
26880                    forge_agent::userdata::catalog::scan::SessionTagScan {
26881                        tag: None,
26882                        project: None,
26883                        scanned_len: 10,
26884                    },
26885                )],
26886            )
26887            .expect("seed stale row");
26888        }
26889
26890        workspace.start_catalog_scan();
26891        tokio::time::timeout(Duration::from_secs(5), async {
26892            while !workspace.catalog_ready() {
26893                tokio::time::sleep(Duration::from_millis(10)).await;
26894            }
26895        })
26896        .await
26897        .expect("scan finishes");
26898
26899        let prior = {
26900            let db = workspace.db.lock();
26901            crate::store::session_tags::load_all(db.as_ref().expect("test db present"))
26902                .expect("load persisted cache")
26903        };
26904        assert!(!prior.is_empty(), "the boot scan persisted cache rows");
26905
26906        let after = {
26907            let db = workspace.db.lock();
26908            crate::store::session_tags::load_all(db.as_ref().expect("test db present"))
26909                .expect("reload cache")
26910        };
26911        assert!(!after.contains_key("/gone/deleted.jsonl"), "the boot scan pruned the stale row");
26912    }
26913
26914    /// The boot scan reaches a tree through an outbound `projects`
26915    /// symlink when that tree lives elsewhere.
26916    #[tokio::test]
26917    async fn boot_scan_reaches_an_outbound_projects_symlink() {
26918        let cfg = tempdir().expect("cfg tempdir");
26919        let tree = tempdir().expect("tree tempdir");
26920        let project_path = cfg.path().join("proj");
26921        fs::create_dir_all(&project_path).expect("project dir");
26922        let toml = format!(
26923            r#"
26924[[orgs]]
26925name = "Default"
26926accounts = ["Stargate"]
26927[[orgs.projects]]
26928name = "proj"
26929path = "{}"
26930model = "claude-sonnet-5"
26931
26932[[accounts]]
26933display_name = "Stargate"
26934token = "t"
26935models = ["claude-sonnet-5"]
26936provider = "anthropic"
26937"#,
26938            project_path.display()
26939        );
26940        fs::write(forge_toml_path(cfg.path()), toml).expect("write forge.toml");
26941        write_session_fixture(
26942            tree.path(),
26943            &project_path.display().to_string(),
26944            WORKER_UUID,
26945            Some("forge:worker:implementer"),
26946        );
26947        std::os::unix::fs::symlink(tree.path().join("projects"), cfg.path().join("projects"))
26948            .expect("outbound projects symlink");
26949
26950        let workspace = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("new"));
26951        workspace.start_catalog_scan();
26952        tokio::time::timeout(Duration::from_secs(5), async {
26953            while !workspace.catalog_ready() {
26954                tokio::time::sleep(Duration::from_millis(10)).await;
26955            }
26956        })
26957        .await
26958        .expect("scan finishes");
26959
26960        let prior = {
26961            let db = workspace.db.lock();
26962            crate::store::session_tags::load_all(db.as_ref().expect("test db present"))
26963                .expect("load persisted cache")
26964        };
26965        assert!(
26966            prior.keys().any(|key| key.contains("implementer") || !key.is_empty()),
26967            "the boot scan reached the external tree and persisted cache rows: {prior:?}",
26968        );
26969    }
26970
26971    /// An outbound symlink whose target is not named `projects` still
26972    /// scans: the walk descends into `<root>/projects`, so a root
26973    /// derived from the target's parent would miss the tree entirely
26974    /// and the catalog would sit empty. The derivation falls back to
26975    /// the config dir, whose `projects` link resolves to the tree.
26976    #[tokio::test]
26977    async fn boot_scan_reaches_an_outbound_tree_not_named_projects() {
26978        let cfg = tempdir().expect("cfg tempdir");
26979        let tree = tempdir().expect("tree tempdir");
26980        let project_path = cfg.path().join("proj");
26981        fs::create_dir_all(&project_path).expect("project dir");
26982        let toml = format!(
26983            r#"
26984[[orgs]]
26985name = "Default"
26986accounts = ["Stargate"]
26987[[orgs.projects]]
26988name = "proj"
26989path = "{}"
26990model = "claude-sonnet-5"
26991
26992[[accounts]]
26993display_name = "Stargate"
26994token = "t"
26995models = ["claude-sonnet-5"]
26996provider = "anthropic"
26997"#,
26998            project_path.display()
26999        );
27000        fs::write(forge_toml_path(cfg.path()), toml).expect("write forge.toml");
27001        write_session_fixture(tree.path(), &project_path.display().to_string(), LEAD_UUID, None);
27002        // The fixture writes a literal `projects` dir; the layout under
27003        // test is one that is NOT named that.
27004        fs::rename(tree.path().join("projects"), tree.path().join("sessions"))
27005            .expect("rename the tree to an odd name");
27006        std::os::unix::fs::symlink(tree.path().join("sessions"), cfg.path().join("projects"))
27007            .expect("outbound projects symlink to an oddly named dir");
27008
27009        let workspace = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("new"));
27010        workspace.start_catalog_scan();
27011        tokio::time::timeout(Duration::from_secs(5), async {
27012            while !workspace.catalog_ready() {
27013                tokio::time::sleep(Duration::from_millis(10)).await;
27014            }
27015        })
27016        .await
27017        .expect("scan finishes");
27018
27019        let sessions = &workspace.list_projects()[0].sessions;
27020        assert_eq!(
27021            sessions.first().map(|s| s.session.as_str()),
27022            Some(LEAD_UUID),
27023            "the session behind the oddly named link is in the catalog"
27024        );
27025    }
27026
27027    /// A session recorded live before the scan lands survives the
27028    /// scan's catalog swap: its transcript may not exist on disk yet,
27029    /// so the disk-built map absorbs the recorded rows rather than
27030    /// replacing them.
27031    #[tokio::test]
27032    async fn scan_swap_preserves_live_recorded_sessions() {
27033        let dir = scan_fixture_dir();
27034        let project_dir = dir.path().join("proj");
27035        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27036
27037        workspace.record_connected_session(&project_dir.display().to_string(), "live-uuid", None);
27038        workspace.start_catalog_scan();
27039        tokio::time::timeout(Duration::from_secs(5), async {
27040            while !workspace.catalog_ready() {
27041                tokio::time::sleep(Duration::from_millis(10)).await;
27042            }
27043        })
27044        .await
27045        .expect("scan finishes");
27046
27047        let sessions = &workspace.list_projects()[0].sessions;
27048        assert!(
27049            sessions.iter().any(|s| s.session.as_str() == "live-uuid"),
27050            "the live-recorded session survives the scan swap"
27051        );
27052        assert!(
27053            sessions.iter().any(|s| s.session.as_str() == LEAD_UUID),
27054            "the on-disk session is present alongside it"
27055        );
27056    }
27057
27058    /// Live-recorded sessions keep their recorded (newest-first) order
27059    /// at the head of the merged catalog. The Projects pane reads
27060    /// `sessions.first()` for the row's relative time, so a reversed
27061    /// head shows the oldest connection's recency.
27062    #[tokio::test]
27063    async fn scan_swap_keeps_recorded_sessions_newest_first() {
27064        let dir = scan_fixture_dir();
27065        let project_dir = dir.path().join("proj");
27066        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27067
27068        workspace.record_connected_session(&project_dir.display().to_string(), "live-older", None);
27069        workspace.record_connected_session(&project_dir.display().to_string(), "live-newer", None);
27070        workspace.start_catalog_scan();
27071        tokio::time::timeout(Duration::from_secs(5), async {
27072            while !workspace.catalog_ready() {
27073                tokio::time::sleep(Duration::from_millis(10)).await;
27074            }
27075        })
27076        .await
27077        .expect("scan finishes");
27078
27079        let sessions = &workspace.list_projects()[0].sessions;
27080        assert_eq!(
27081            sessions[0].session.as_str(),
27082            "live-newer",
27083            "the latest connection leads the project"
27084        );
27085        assert_eq!(sessions[1].session.as_str(), "live-older");
27086        assert_eq!(sessions[2].session.as_str(), LEAD_UUID);
27087    }
27088
27089    /// With no tokio runtime there is no scan to await, so readiness
27090    /// publishes immediately over an empty catalog and spawns fall
27091    /// through to fresh instead of parking forever.
27092    #[test]
27093    fn start_catalog_scan_without_a_runtime_publishes_readiness() {
27094        let dir = scan_fixture_dir();
27095        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("new");
27096
27097        workspace.start_catalog_scan();
27098
27099        assert!(workspace.catalog_ready(), "no runtime: readiness publishes immediately");
27100        assert!(
27101            workspace.list_projects()[0].sessions.is_empty(),
27102            "no runtime: no scan ran, the catalog stays empty"
27103        );
27104    }
27105
27106    // ---------------------------------------------------------------
27107    // `/resume`'s list is what `claude --resume` can find from the
27108    // directory the seat's own resume runs in.
27109    // ---------------------------------------------------------------
27110
27111    /// Run the boot catalog scan and wait for it, so the seat's read takes the
27112    /// catalog branch rather than the directory's own.
27113    async fn ready_catalog(workspace: &Arc<Workspace>) {
27114        let mut updates = workspace.subscribe_answerer();
27115        workspace.start_catalog_scan();
27116        loop {
27117            let update = tokio::time::timeout(std::time::Duration::from_secs(5), updates.recv())
27118                .await
27119                .expect("the scan finishes")
27120                .expect("the stream is open");
27121            if matches!(&*update.update, SessionUpdate::CatalogLoaded) {
27122                break;
27123            }
27124        }
27125        assert!(workspace.catalog_ready(), "the scan landed before the read");
27126    }
27127
27128    /// Pin a transcript's mtime to a fixed second, so a test that reads the
27129    /// newest first does not depend on the clock's granularity.
27130    fn pin_transcript(dir: &tempfile::TempDir, project_path: &str, id: &str, secs: u64) {
27131        let key =
27132            forge_agent::userdata::catalog::scan::project_key_for_directory(Some(project_path));
27133        let path = dir.path().join("projects").join(key).join(format!("{id}.jsonl"));
27134        let file = fs::File::options().write(true).open(&path).expect("open the transcript");
27135        file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
27136            .expect("pin the transcript's mtime");
27137    }
27138
27139    /// A lead's list is its project's own transcripts, newest first, and never
27140    /// a worker's: a lead's resume runs in the project root, where a non-git
27141    /// worker's transcript sits too, and picking it would put the lead's turns
27142    /// into a transcript tagged for that worker.
27143    ///
27144    /// **The newest transcript is the SECOND file written**, so a list that
27145    /// came back in directory order passes only if it also sorts.
27146    #[tokio::test]
27147    async fn resume_sessions_of_a_lead_are_its_projects_and_never_its_workers() {
27148        let dir = scan_fixture_dir();
27149        let project = dir.path().join("proj");
27150        let project_path = project.display().to_string();
27151        write_session_fixture(dir.path(), &project_path, "older", None);
27152        write_session_fixture(dir.path(), &project_path, WORKER_UUID, Some("forge:worker:alpha"));
27153        pin_transcript(&dir, &project_path, "older", 1_700_000_200);
27154        pin_transcript(&dir, &project_path, LEAD_UUID, 1_700_000_100);
27155        pin_transcript(&dir, &project_path, WORKER_UUID, 1_700_000_300);
27156        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27157
27158        let found = workspace.resumable_sessions(&SessionSlot::lead("Default", "proj")).await;
27159
27160        assert_eq!(
27161            found.iter().map(|info| info.session_id.as_str()).collect::<Vec<_>>(),
27162            ["older", LEAD_UUID],
27163            "the project's own transcripts, newest first, and never a worker's",
27164        );
27165        assert_eq!(found[1].summary, "opening prompt", "a row carries the transcript's title");
27166    }
27167
27168    /// The list is the sessions a seat can JUMP BACK TO, so the one it is
27169    /// already on is not one of them: a pick would respawn the seat onto
27170    /// itself.
27171    ///
27172    /// **A newer row is not the occupant**, so an exclusion that drops the
27173    /// list's head instead of the occupant loses a real jump and fails.
27174    #[tokio::test]
27175    async fn resume_sessions_leave_out_the_session_the_seat_is_already_on() {
27176        let dir = scan_fixture_dir();
27177        let project = dir.path().join("proj");
27178        let project_path = project.display().to_string();
27179        write_session_fixture(dir.path(), &project_path, "newer", None);
27180        pin_transcript(&dir, &project_path, "newer", 1_700_000_200);
27181        pin_transcript(&dir, &project_path, LEAD_UUID, 1_700_000_100);
27182        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27183        let seat = SessionSlot::lead("Default", "proj");
27184        workspace.record_session_id("Default", "proj", "lead", LEAD_UUID);
27185
27186        let found = workspace.resumable_sessions(&seat).await;
27187
27188        assert_eq!(
27189            found.iter().map(|info| info.session_id.as_str()).collect::<Vec<_>>(),
27190            ["newer"],
27191            "the occupant goes and the newer session survives",
27192        );
27193    }
27194
27195    /// The answer is bounded to the newest 50, which is the list a reader
27196    /// picks from: the record is re-encoded on every seat open and reread, so
27197    /// the payload cannot grow with the project's history. The catalog branch
27198    /// is the one that answers a lead in steady state, so that is the branch
27199    /// the cap and the worker filter are pinned on: the newest transcript here
27200    /// is a worker's, and it must be gone before the cap is spent.
27201    #[tokio::test]
27202    async fn resume_sessions_are_bounded_to_the_newest_fifty() {
27203        let dir = scan_fixture_dir();
27204        let project = dir.path().join("proj");
27205        let project_path = project.display().to_string();
27206        for n in 0..60_u64 {
27207            let id = format!("t{n:02}");
27208            write_session_fixture(dir.path(), &project_path, &id, None);
27209            pin_transcript(&dir, &project_path, &id, 1_700_000_000 + n);
27210        }
27211        write_session_fixture(dir.path(), &project_path, WORKER_UUID, Some("forge:worker:alpha"));
27212        pin_transcript(&dir, &project_path, WORKER_UUID, 1_700_000_500);
27213        pin_transcript(&dir, &project_path, LEAD_UUID, 1_699_999_999);
27214        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27215        ready_catalog(&workspace).await;
27216
27217        let found = workspace.resumable_sessions(&SessionSlot::lead("Default", "proj")).await;
27218
27219        assert!(
27220            found.iter().all(|info| info.session_id != WORKER_UUID),
27221            "a worker's transcript is never a lead's jump, even as the newest row: {found:?}",
27222        );
27223        assert_eq!(found.len(), 50, "the cap is the newest fifty rows");
27224        assert_eq!(found[0].session_id, "t59", "the newest of the lead's own leads");
27225        assert_eq!(found[49].session_id, "t10", "and the oldest ten are the ones cut");
27226        assert!(
27227            found.iter().all(|info| info.session_id != "t09" && info.session_id != LEAD_UUID),
27228            "nothing below the fiftieth-newest is carried",
27229        );
27230    }
27231
27232    /// A seat whose project forge has not loaded has nowhere to resume from,
27233    /// and the list is empty rather than another project's.
27234    #[tokio::test]
27235    async fn resume_sessions_of_an_unloaded_project_are_empty() {
27236        let (workspace, _updates) = Workspace::testing_stub();
27237        assert!(
27238            workspace.resumable_sessions(&SessionSlot::lead("TestOrg", "nothing")).await.is_empty(),
27239            "no loaded project, no cwd, no list",
27240        );
27241    }
27242
27243    /// **A session that has connected but written no turn is not a jump.**
27244    /// The catalog mirrors every Connected - the boot spawn's own session
27245    /// included - and that row has no transcript `claude --resume` can find;
27246    /// offering it lands the seat on "No conversation found" (measured on the
27247    /// dev stack). The read keeps a catalog row only when its transcript is on
27248    /// disk, which is the branch a lead takes once the boot scan has landed.
27249    #[tokio::test]
27250    async fn resume_sessions_leave_out_a_connected_session_with_no_transcript() {
27251        let dir = scan_fixture_dir();
27252        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27253        let seat = SessionSlot::lead("Default", "proj");
27254        ready_catalog(&workspace).await;
27255        let cwd = workspace.cwd_for_session(&seat).expect("the project's directory");
27256        workspace.record_connected_session(&cwd, "live-only", Some("new session".to_owned()));
27257
27258        let found = workspace.resumable_sessions(&seat).await;
27259
27260        assert!(
27261            found.iter().all(|info| info.session_id != "live-only"),
27262            "the catalog's row has no transcript behind it: {found:?}",
27263        );
27264        assert_eq!(found.len(), 1, "and the transcript that IS there is still offered");
27265    }
27266
27267    /// **A row the catalog mirrored at Connected states a placeholder title.**
27268    /// The session it names left a transcript behind, so the row is a real
27269    /// jump - and the picker reads the title off that transcript rather than
27270    /// offering `new session` for the one session a reader is most likely to
27271    /// want back.
27272    #[tokio::test]
27273    async fn resume_sessions_read_a_live_mirrors_title_from_its_transcript() {
27274        let dir = scan_fixture_dir();
27275        let project = dir.path().join("proj");
27276        let project_path = project.display().to_string();
27277        write_session_fixture(dir.path(), &project_path, "left-behind", None);
27278        pin_transcript(&dir, &project_path, "left-behind", 1_700_000_100);
27279        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("new"));
27280        let seat = SessionSlot::lead("Default", "proj");
27281        ready_catalog(&workspace).await;
27282        let cwd = workspace.cwd_for_session(&seat).expect("the project's directory");
27283        workspace.record_connected_session(&cwd, "left-behind", None);
27284
27285        let found = workspace.resumable_sessions(&seat).await;
27286
27287        let row = found
27288            .iter()
27289            .find(|info| info.session_id == "left-behind")
27290            .expect("a session that wrote a transcript is a jump");
27291        assert_eq!(
27292            row.summary, "opening prompt",
27293            "the title is the transcript's, not the mirror's placeholder",
27294        );
27295    }
27296
27297    /// **The store is the config dir's.** Two config dirs under one
27298    /// app-support base open side by side, each on its own file, so neither
27299    /// collides with the other's lock-backed writer nor inherits its crons,
27300    /// subscriptions and worker rows.
27301    #[test]
27302    fn each_config_dir_opens_its_own_store() {
27303        let dir = tempdir().expect("tempdir");
27304        let app_support = dir.path().join("app-support");
27305        let one = dir.path().join("config-one");
27306        let two = dir.path().join("config-two");
27307        // The boot creates the config dir before it opens the store, and the
27308        // store key is a digest of the canonical path.
27309        std::fs::create_dir_all(&one).expect("the first config dir");
27310        std::fs::create_dir_all(&two).expect("the second config dir");
27311
27312        let first = open_db(&app_support, &one).expect("the first config dir's store");
27313        let second = open_db(&app_support, &two).expect("the second config dir's own store");
27314
27315        assert_ne!(
27316            store_path(&app_support, &one),
27317            store_path(&app_support, &two),
27318            "the path carries the config dir, like the lock's",
27319        );
27320        assert!(store_path(&app_support, &one).is_file(), "the first store was created");
27321        assert!(store_path(&app_support, &two).is_file(), "and the second beside it");
27322        drop((first, second));
27323    }
27324
27325    /// **A machine-wide store from before the split is adopted once**, and
27326    /// only from the default config dir: a scratch or second config dir
27327    /// booting first must not take the user's state.
27328    #[test]
27329    fn the_default_config_dir_adopts_the_machine_wide_store_once() {
27330        let dir = tempdir().expect("tempdir");
27331        let app_support = dir.path().join("app-support");
27332        std::fs::create_dir_all(&app_support).expect("the app-support dir");
27333        let legacy = app_support.join("db.redb");
27334        drop(crate::store::Db::open(&legacy).expect("a store at the old path"));
27335        let home_config = dirs::home_dir().expect("a home dir").join(".claude");
27336        let target = app_support.join("stores").join("adopted.redb");
27337        std::fs::create_dir_all(target.parent().expect("the stores dir")).expect("stores dir");
27338
27339        let outcome = adopt_legacy_store(&app_support, &home_config, &target);
27340
27341        assert!(matches!(outcome, Adoption::Settled), "the home config dir adopts");
27342        assert!(target.is_file(), "the default config dir took it");
27343        assert!(!legacy.exists(), "and the machine-wide path is empty");
27344
27345        // Once: a target already present is the migration done, so a later
27346        // legacy file is not looked at again.
27347        let stray = app_support.join("db.redb");
27348        std::fs::write(&stray, b"not a store").expect("a stray file");
27349        let again = adopt_legacy_store(&app_support, &home_config, &target);
27350        assert!(matches!(again, Adoption::Settled), "nothing is left to do");
27351        assert!(stray.is_file(), "the once-guard leaves a later legacy file alone");
27352    }
27353
27354    /// **A scratch config dir leaves the file alone**, and a temp dir is not
27355    /// the default one: the adoption predicate answers for the home path and
27356    /// nothing else.
27357    #[test]
27358    fn a_scratch_config_dir_leaves_the_machine_wide_store_alone() {
27359        let dir = tempdir().expect("tempdir");
27360        let app_support = dir.path().join("app-support");
27361        std::fs::create_dir_all(&app_support).expect("the app-support dir");
27362        let legacy = app_support.join("db.redb");
27363        drop(crate::store::Db::open(&legacy).expect("a store at the old path"));
27364        let scratch = dir.path().join("scratch-config");
27365        let target = app_support.join("stores").join("scratch.redb");
27366        std::fs::create_dir_all(target.parent().expect("the stores dir")).expect("stores dir");
27367
27368        let outcome = adopt_legacy_store(&app_support, &scratch, &target);
27369
27370        assert!(matches!(outcome, Adoption::Settled), "a scratch dir has nothing to adopt");
27371        assert!(legacy.is_file(), "the user's store stays where it is");
27372        assert!(!target.exists(), "and no copy was made");
27373        assert!(!is_default_config_dir(&scratch), "a temp dir is not the default config dir");
27374        assert!(
27375            is_default_config_dir(&dirs::home_dir().expect("a home dir").join(".claude")),
27376            "the home directory's .claude is",
27377        );
27378    }
27379
27380    /// **A store another process holds defers the boot rather than hiding
27381    /// it.** Opening a fresh store would make the next boot see a target and
27382    /// never look again, stranding every row; memory-only leaves the retry
27383    /// for the next boot.
27384    #[test]
27385    fn a_machine_wide_store_in_use_defers_the_boot() {
27386        let dir = tempdir().expect("tempdir");
27387        let app_support = dir.path().join("app-support");
27388        std::fs::create_dir_all(&app_support).expect("the app-support dir");
27389        let legacy = app_support.join("db.redb");
27390        let held = crate::store::Db::open(&legacy).expect("a store at the old path");
27391        let home_config = dirs::home_dir().expect("a home dir").join(".claude");
27392        let target = app_support.join("stores").join("adopted.redb");
27393        std::fs::create_dir_all(target.parent().expect("the stores dir")).expect("stores dir");
27394
27395        let outcome = adopt_legacy_store(&app_support, &home_config, &target);
27396
27397        assert!(matches!(outcome, Adoption::Deferred), "a held store defers the boot");
27398        assert!(!target.exists(), "nothing was moved in");
27399        assert!(legacy.is_file(), "and the owner still has its file where it was");
27400        drop(held);
27401    }
27402
27403    /// **The boot adopts through `open_db` itself**, not only through the
27404    /// helper: the home config dir's first boot moves the legacy store in
27405    /// and opens it. Wiring the adoption out of `open_db` would create a
27406    /// fresh target first, and every later boot would never look again.
27407    #[test]
27408    fn open_db_adopts_the_machine_wide_store_for_the_home_config_dir() {
27409        let dir = tempdir().expect("tempdir");
27410        let app_support = dir.path().join("app-support");
27411        std::fs::create_dir_all(&app_support).expect("the app-support dir");
27412        let legacy = app_support.join("db.redb");
27413        {
27414            // A row in the legacy store, so the read below proves the DATA
27415            // moved and not just the file: a remove-file-and-open-fresh
27416            // would pass a file-existence check.
27417            let old = crate::store::Db::open(&legacy).expect("a store at the old path");
27418            crate::store::cron_names::put(
27419                &old,
27420                "p-adopt",
27421                &forge_primitives::cron::CronName {
27422                    cron_id: "c1".to_owned(),
27423                    description: Some("Morning summary".to_owned()),
27424                },
27425                std::time::SystemTime::now(),
27426            )
27427            .expect("seed a row in the legacy store");
27428        }
27429        let home_config = dirs::home_dir().expect("a home dir").join(".claude");
27430        // The boot creates the config dir before opening the store; a runner
27431        // without one gets an empty directory this test never writes into.
27432        std::fs::create_dir_all(&home_config).expect("the home config dir");
27433
27434        let opened = open_db(&app_support, &home_config).expect("the adopted store opens");
27435
27436        assert!(!legacy.exists(), "the machine-wide file moved");
27437        assert!(
27438            store_path(&app_support, &home_config).is_file(),
27439            "into this config dir's own store",
27440        );
27441        let rows = crate::store::cron_names::for_uuids(&opened, &["p-adopt"]).expect("read");
27442        assert_eq!(
27443            rows.get("p-adopt").and_then(|name| name.description.as_deref()),
27444            Some("Morning summary"),
27445            "the adopted store carries the legacy store's rows",
27446        );
27447        drop(opened);
27448    }
27449
27450    /// **A held store defers the boot at the `open_db` boundary too**: no
27451    /// fresh target is created, so the next boot can still adopt the legacy
27452    /// file instead of finding a target and never looking again.
27453    #[test]
27454    fn open_db_leaves_no_target_when_the_legacy_store_is_held() {
27455        let dir = tempdir().expect("tempdir");
27456        let app_support = dir.path().join("app-support");
27457        std::fs::create_dir_all(&app_support).expect("the app-support dir");
27458        let legacy = app_support.join("db.redb");
27459        let held = crate::store::Db::open(&legacy).expect("a store at the old path");
27460        let home_config = dirs::home_dir().expect("a home dir").join(".claude");
27461        std::fs::create_dir_all(&home_config).expect("the home config dir");
27462
27463        let opened = open_db(&app_support, &home_config);
27464
27465        assert!(opened.is_none(), "the boot runs without durable state this run");
27466        assert!(
27467            !store_path(&app_support, &home_config).exists(),
27468            "and creates no target that would hide the legacy file for good",
27469        );
27470        assert!(legacy.is_file(), "the held file is still where its owner has it");
27471        drop(held);
27472    }
27473
27474    /// **A peer's lock makes the machine shared.** A boot may prune shared
27475    /// app-support state only when this is false; a hardcoded true would let
27476    /// it delete a live peer's CEF bundle.
27477    #[test]
27478    fn sole_instance_sees_a_peer_lock() {
27479        let dir = tempdir().expect("tempdir");
27480        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_path_buf());
27481        assert!(ws.sole_instance(), "no peer is running in a fresh fixture");
27482
27483        let peer = dir.path().join("peer-config");
27484        std::fs::create_dir_all(&peer).expect("peer config dir");
27485        let base = dir.path().join("app-support");
27486        let held = crate::single_instance::acquire(&peer, &base)
27487            .expect("the peer acquires")
27488            .expect("the peer holds its lock");
27489
27490        assert!(!ws.sole_instance(), "a peer's held lock means the machine is shared");
27491        drop(held);
27492        assert!(ws.sole_instance(), "and it is the lock, not the file, that says so");
27493    }
27494}
27495
27496#[cfg(test)]
27497mod prompt_dispatch_tests {
27498    //! A prompt's dispatch wiring, at the layer that answers for it: the
27499    //! command carries the id its row is drawn from and the source a reader's
27500    //! follow arms on, and no frame is forged here - the session task draws
27501    //! the row when the CLI starts the prompt.
27502    use super::*;
27503
27504    fn a_prompt(key: &SessionSlot) -> Command {
27505        Command::Prompt { key: key.clone(), text: "hello".to_owned(), attachments: Vec::new() }
27506    }
27507
27508    /// A fleet with one seat, and this seat's stream off the core's fan-out.
27509    ///
27510    /// The prompt is ACCEPTED through the dispatch intercept, which is this
27511    /// crate's own way of saying "the core took it" without standing up a real
27512    /// subprocess - the stub alone leaves the seat with no command sender, so
27513    /// every dispatch is refused for a reason the fixture invented.
27514    fn a_fleet() -> (Arc<Workspace>, mpsc::UnboundedReceiver<SeatUpdate>, SessionSlot) {
27515        let (ws, rx) = Workspace::testing_stub();
27516        ws.seed_test_project("proj", "/tmp/prompt-frame-origin");
27517        let seat = SessionSlot::lead("TestOrg", "proj");
27518        ws.enable_test_dispatch_intercept();
27519        (ws, rx, seat)
27520    }
27521
27522    /// **The routed command carries the id its row is drawn from.** The
27523    /// session task announces this id and the CLI's lifecycle frames name it,
27524    /// so one id here is one id on the queue's row and the CLI's own copy of
27525    /// the message - a second id minted on the way would hold a row nothing
27526    /// can settle.
27527    #[test]
27528    fn a_routed_prompt_carries_the_id_its_lifecycle_frames_name() {
27529        let (ws, _rx, seat) = a_fleet();
27530
27531        let dispatched = ws.dispatch_from_view(a_prompt(&seat));
27532        assert!(dispatched.is_ok(), "precondition: the core took the prompt: {dispatched:?}");
27533
27534        let wired = ws.drain_test_dispatch_buffer();
27535        let [Command::PromptUnder { uuid, source, .. }] = wired.as_slice() else {
27536            panic!("a typed dispatch wires the prompt under a minted id: {wired:?}");
27537        };
27538        assert!(!uuid.is_empty(), "and a real one, not a blank");
27539        assert_eq!(*source, PromptSource::You, "and as a person's send");
27540    }
27541
27542    /// `/new` from any composer is forge's own command, never the CLI's. The
27543    /// CLI answers that name as its own `/clear`, which rotates a
27544    /// conversation forge never records - no id minted, no `SessionReplaced` -
27545    /// so the seat is restarted here instead, and the words never reach it.
27546    #[test]
27547    fn a_new_prompt_restarts_the_seat_rather_than_reaching_the_cli() {
27548        let (ws, _rx, seat) = a_fleet();
27549
27550        let dispatched = ws.dispatch_from_view(Command::Prompt {
27551            key: seat.clone(),
27552            text: "/new".to_owned(),
27553            attachments: Vec::new(),
27554        });
27555
27556        assert!(dispatched.is_ok(), "the core takes a /new prompt: {dispatched:?}");
27557        let commands = ws.drain_test_dispatch_buffer();
27558        assert!(
27559            commands.iter().any(|c| matches!(c, Command::NewSession { key, .. } if key == &seat)),
27560            "the seat is restarted: {commands:?}",
27561        );
27562        assert!(
27563            !commands
27564                .iter()
27565                .any(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. })),
27566            "and the words are never forwarded to the CLI: {commands:?}",
27567        );
27568    }
27569
27570    /// `/resume <id>` is forge's too, and for a harder reason than `/new`:
27571    /// the CLI classifies its own `/resume` as local-jsx, so it cannot run
27572    /// away from a terminal at all. The seat is re-spawned onto the named
27573    /// session, from the same settings a `/new` would carry.
27574    #[test]
27575    fn a_resume_prompt_resumes_the_seat_rather_than_reaching_the_cli() {
27576        let (ws, _rx, seat) = a_fleet();
27577
27578        let dispatched = ws.dispatch_from_view(Command::Prompt {
27579            key: seat.clone(),
27580            text: "/resume 7f3a92e0".to_owned(),
27581            attachments: Vec::new(),
27582        });
27583
27584        assert!(dispatched.is_ok(), "the core takes a /resume prompt: {dispatched:?}");
27585        let commands = ws.drain_test_dispatch_buffer();
27586        assert!(
27587            commands.iter().any(|c| matches!(
27588                c,
27589                Command::ResumeSession { key, session_id, .. }
27590                    if key == &seat && session_id == "7f3a92e0"
27591            )),
27592            "the named session is resumed: {commands:?}",
27593        );
27594        assert!(
27595            !commands
27596                .iter()
27597                .any(|c| matches!(c, Command::Prompt { .. } | Command::PromptUnder { .. })),
27598            "and the words are never forwarded to the CLI: {commands:?}",
27599        );
27600    }
27601
27602    /// `/mode <id>` and `/model <id>` dispatch their own commands on either
27603    /// entry, so a view neither of them can run still changes both.
27604    #[test]
27605    fn mode_and_model_prompts_dispatch_their_own_commands() {
27606        let (ws, _rx, seat) = a_fleet();
27607
27608        for (text, expected) in [
27609            (
27610                "/mode plan",
27611                Box::new(|command: &Command| {
27612                    matches!(
27613                        command,
27614                        Command::SetMode { mode, .. }
27615                            if *mode == forge_primitives::permission::PermissionMode::Plan
27616                    )
27617                }) as Box<dyn Fn(&Command) -> bool>,
27618            ),
27619            (
27620                "/model sonnet",
27621                Box::new(
27622                    |command: &Command| matches!(command, Command::SetModel { model, .. } if model == "sonnet"),
27623                ),
27624            ),
27625        ] {
27626            let dispatched = ws.dispatch_from_view(Command::Prompt {
27627                key: seat.clone(),
27628                text: text.to_owned(),
27629                attachments: Vec::new(),
27630            });
27631            assert!(dispatched.is_ok(), "{text}: {dispatched:?}");
27632            let commands = ws.drain_test_dispatch_buffer();
27633            assert!(
27634                matches!(commands.as_slice(), [command] if expected(command)),
27635                "{text} dispatches its own command: {commands:?}",
27636            );
27637        }
27638    }
27639
27640    /// A mode the CLI has no name for is answered rather than sent: the
27641    /// command carries the enum, so an unparsed one cannot be dispatched.
27642    #[test]
27643    fn a_mode_the_cli_does_not_have_is_answered() {
27644        let (ws, mut rx, seat) = a_fleet();
27645
27646        let dispatched = ws.dispatch_from_view(Command::Prompt {
27647            key: seat.clone(),
27648            text: "/mode sideways".to_owned(),
27649            attachments: Vec::new(),
27650        });
27651
27652        assert!(dispatched.is_ok(), "{dispatched:?}");
27653        // The echo of the reader's own words is one of these; the answer is
27654        // what this case is about (its ORDER is the case above, which does not
27655        // need the words' text).
27656        let notices: Vec<(NoticeSeverity, String)> = std::iter::from_fn(|| rx.try_recv().ok())
27657            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27658                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
27659                _ => None,
27660            })
27661            .collect();
27662        assert!(
27663            matches!(
27664                notices.as_slice(),
27665                [(NoticeSeverity::Error, text)] if text == "Unknown mode: sideways"
27666            ),
27667            "the reader is told the mode is not one the CLI has: {notices:?}",
27668        );
27669        assert!(ws.drain_test_dispatch_buffer().is_empty(), "and nothing is dispatched");
27670    }
27671
27672    /// A forge command answers without drawing its prompt.
27673    ///
27674    /// The prompt never reaches a model, so no turn of the transcript holds
27675    /// it and no row is drawn for it (#2816). The answer is the whole of what
27676    /// the reader sees.
27677    #[test]
27678    fn a_forge_command_answers_without_drawing_its_prompt() {
27679        let (ws, mut rx, seat) = a_fleet();
27680
27681        ws.dispatch_from_view(Command::Prompt {
27682            key: seat.clone(),
27683            text: "/mode sideways".to_owned(),
27684            attachments: vec![forge_primitives::ImageAttachment {
27685                data: "iVBORw0KGgo=".to_owned(),
27686                mime_type: "image/png".to_owned(),
27687            }],
27688        })
27689        .expect("the core takes it");
27690
27691        let updates: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
27692        assert!(
27693            !updates
27694                .iter()
27695                .any(|frame| matches!(&*frame.update, SessionUpdate::ChatAppended { .. })),
27696            "a prompt no model received draws no row: {updates:?}",
27697        );
27698        assert!(
27699            updates.iter().any(|frame| matches!(
27700                &*frame.update,
27701                SessionUpdate::Notice { severity: NoticeSeverity::Error, text, .. }
27702                    if text == "Unknown mode: sideways"
27703            )),
27704            "and the answer lands, where a client draws it: {updates:?}",
27705        );
27706    }
27707
27708    /// `/effort <level>` writes the document the next launch reads, rather
27709    /// than dispatching: the CLI carries no control request for effort.
27710    #[test]
27711    fn effort_writes_the_launch_document_and_answers() {
27712        let (ws, mut rx, seat) = a_fleet();
27713        // The pool's stub handle is bound to a scratch config dir, so this
27714        // write is a real one against a path no user reads.
27715        ws.seed_test_bound_session(&seat, "acct-a");
27716        let config_dir = ws.config_dir_for(&seat).expect("the pooled stub names a config dir");
27717        let path = config_dir.join("settings.json");
27718        let before = std::fs::read_to_string(&path).ok();
27719
27720        let dispatched = ws.dispatch_from_view(Command::Prompt {
27721            key: seat.clone(),
27722            text: "/effort high".to_owned(),
27723            attachments: Vec::new(),
27724        });
27725
27726        assert!(dispatched.is_ok(), "{dispatched:?}");
27727        let written: serde_json::Value = serde_json::from_str(
27728            &std::fs::read_to_string(&path).expect("the effort level lands in the document"),
27729        )
27730        .expect("the document parses");
27731        assert_eq!(written["effortLevel"], serde_json::json!("high"));
27732        let notices: Vec<(NoticeSeverity, String)> = std::iter::from_fn(|| rx.try_recv().ok())
27733            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27734                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
27735                _ => None,
27736            })
27737            .collect();
27738        assert!(
27739            matches!(
27740                notices.as_slice(),
27741                [(NoticeSeverity::Info, text)] if text.contains("Effort: High")
27742            ),
27743            "and the reader is told it took: {notices:?}",
27744        );
27745        assert!(ws.drain_test_dispatch_buffer().is_empty(), "nothing is dispatched");
27746
27747        match before {
27748            Some(contents) => {
27749                std::fs::write(&path, contents).expect("restore the stub's document");
27750            }
27751            None => {
27752                let _ = std::fs::remove_file(&path);
27753            }
27754        }
27755    }
27756
27757    /// A forge name invoked wrongly is answered with the command's own usage
27758    /// line, and dispatched nowhere: a mistyped command that reached the model
27759    /// would read as a question.
27760    #[test]
27761    fn a_wrong_forge_invocation_is_answered_rather_than_dispatched() {
27762        let (ws, mut rx, seat) = a_fleet();
27763
27764        let dispatched = ws.dispatch_from_view(Command::Prompt {
27765            key: seat.clone(),
27766            text: "/resume".to_owned(),
27767            // An attachment beside a wrongly invoked command never reaches a
27768            // model, so the echo draws the words alone.
27769            attachments: vec![forge_primitives::ImageAttachment {
27770                data: "iVBORw0KGgo=".to_owned(),
27771                mime_type: "image/png".to_owned(),
27772            }],
27773        });
27774
27775        assert!(dispatched.is_ok(), "the core takes it: {dispatched:?}");
27776        let updates: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
27777        assert!(
27778            !updates
27779                .iter()
27780                .any(|frame| matches!(&*frame.update, SessionUpdate::ChatAppended { .. })),
27781            "a prompt no model received draws no row: {updates:?}",
27782        );
27783        let notices: Vec<(SessionSlot, NoticeSeverity, String)> = updates
27784            .into_iter()
27785            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27786                SessionUpdate::Notice { key, severity, text } => Some((key, severity, text)),
27787                _ => None,
27788            })
27789            .collect();
27790        assert!(
27791            matches!(
27792                notices.as_slice(),
27793                [(key, NoticeSeverity::Error, text)]
27794                    if key == &seat && text == "Usage: /resume <session_id>"
27795            ),
27796            "the reader is told what the command wanted: {notices:?}",
27797        );
27798        let commands = ws.drain_test_dispatch_buffer();
27799        assert!(commands.is_empty(), "and nothing is dispatched: {commands:?}");
27800    }
27801
27802    /// One bare invocation, answered: what a view dispatches and the notices
27803    /// that answer it.
27804    fn bare(
27805        ws: &std::sync::Arc<Workspace>,
27806        rx: &mut mpsc::UnboundedReceiver<SeatUpdate>,
27807        seat: &SessionSlot,
27808        text: &str,
27809    ) -> Vec<(NoticeSeverity, String)> {
27810        let dispatched = ws.dispatch_from_view(Command::Prompt {
27811            key: seat.clone(),
27812            text: text.to_owned(),
27813            attachments: Vec::new(),
27814        });
27815        assert!(dispatched.is_ok(), "the core takes {text}: {dispatched:?}");
27816        std::iter::from_fn(|| rx.try_recv().ok())
27817            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27818                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
27819                _ => None,
27820            })
27821            .collect()
27822    }
27823
27824    /// **A bare `/mode` and `/effort` answer the value in force.** The core
27825    /// holds what the session reported, so the reader is told it rather than
27826    /// handed the usage line (#1626).
27827    #[test]
27828    fn a_bare_mode_and_effort_answer_the_value_in_force() {
27829        let (ws, mut rx, seat) = a_fleet();
27830        let domain = ws.register_domain_session(seat.clone(), None);
27831        {
27832            let mut domain = domain.lock();
27833            domain.observed_permission_mode =
27834                Some(forge_primitives::permission::PermissionMode::Plan);
27835            domain.observed_effort = Some(forge_primitives::EffortLevel::High);
27836        }
27837
27838        assert_eq!(
27839            bare(&ws, &mut rx, &seat, "/mode"),
27840            vec![(NoticeSeverity::Info, "Mode: plan".to_owned())],
27841            "the hook's observation is what the session runs",
27842        );
27843        assert_eq!(
27844            bare(&ws, &mut rx, &seat, "/effort"),
27845            vec![(NoticeSeverity::Info, "Effort: high".to_owned())],
27846        );
27847
27848        // **The launch's own setting answers where no hook has reported**,
27849        // which is the precedence the inspector draws - the two surfaces
27850        // cannot disagree about the same session.
27851        {
27852            let mut domain = domain.lock();
27853            domain.observed_permission_mode = None;
27854            domain.configured_permission_mode =
27855                Some(forge_primitives::permission::PermissionMode::Ask);
27856            domain.observed_effort = None;
27857            domain.configured_effort = forge_primitives::EffortLevel::Low;
27858        }
27859        assert_eq!(
27860            bare(&ws, &mut rx, &seat, "/mode"),
27861            vec![(NoticeSeverity::Info, "Mode: default".to_owned())],
27862            "with nothing observed the launch's mode answers",
27863        );
27864        assert_eq!(
27865            bare(&ws, &mut rx, &seat, "/effort"),
27866            vec![(NoticeSeverity::Info, "Effort: low".to_owned())],
27867        );
27868        let commands = ws.drain_test_dispatch_buffer();
27869        assert!(commands.is_empty(), "and neither read dispatches anything: {commands:?}");
27870    }
27871
27872    /// **A seat with no session at all answers the usage line it answered
27873    /// before** (#1626): the read is the domain's, and a slot no task holds
27874    /// has nothing to report.
27875    #[test]
27876    fn a_bare_mode_and_effort_with_no_session_answer_their_usage() {
27877        let (ws, mut rx, seat) = a_fleet();
27878
27879        assert_eq!(
27880            bare(&ws, &mut rx, &seat, "/mode"),
27881            vec![(NoticeSeverity::Error, "Usage: /mode <id>".to_owned())],
27882        );
27883        assert_eq!(
27884            bare(&ws, &mut rx, &seat, "/effort"),
27885            vec![(NoticeSeverity::Error, "Usage: /effort <level>".to_owned())],
27886        );
27887        let commands = ws.drain_test_dispatch_buffer();
27888        assert!(commands.is_empty(), "and neither read dispatches anything: {commands:?}");
27889    }
27890
27891    /// Slash text this session cannot run is answered where it is typed,
27892    /// rather than handed to the model as a question. What the session can
27893    /// run is what the CLI advertised, so the guard reads that and nothing
27894    /// else.
27895    #[test]
27896    fn a_slash_name_the_session_does_not_have_is_refused() {
27897        let (ws, mut rx, seat) = a_fleet();
27898        ws.seed_test_advertised_catalogues(
27899            &seat,
27900            vec![forge_primitives::AvailableCommand::new("compact", "Compact")],
27901            Vec::new(),
27902        );
27903
27904        let dispatched = ws.dispatch_from_view(Command::Prompt {
27905            key: seat.clone(),
27906            text: "/spinner now".to_owned(),
27907            attachments: vec![forge_primitives::ImageAttachment {
27908                data: "iVBORw0KGgo=".to_owned(),
27909                mime_type: "image/png".to_owned(),
27910            }],
27911        });
27912
27913        assert!(dispatched.is_ok(), "the core takes it: {dispatched:?}");
27914        let updates: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
27915        assert!(
27916            !updates
27917                .iter()
27918                .any(|frame| matches!(&*frame.update, SessionUpdate::ChatAppended { .. })),
27919            "a prompt no model received draws no row: {updates:?}",
27920        );
27921        let notices: Vec<(NoticeSeverity, String)> = updates
27922            .into_iter()
27923            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27924                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
27925                _ => None,
27926            })
27927            .collect();
27928        assert!(
27929            matches!(
27930                notices.as_slice(),
27931                [(NoticeSeverity::Error, text)] if text == "/spinner is not yet supported"
27932            ),
27933            "the reader is told the name is not one this session has: {notices:?}",
27934        );
27935        let commands = ws.drain_test_dispatch_buffer();
27936        assert!(commands.is_empty(), "and nothing reaches the model: {commands:?}");
27937    }
27938
27939    /// The guard's clauses, one row each: prose with no slash is the model's,
27940    /// a name the CLI resolves is the CLI's, an advertised name is the CLI's,
27941    /// and only a name nothing has is refused.
27942    ///
27943    /// **A slash-led first word is refused whether or not words follow it.**
27944    /// The two readings cannot be told
27945    /// apart at the first token, and the permissive one reopens the hole the
27946    /// guard exists for: `/spinner now`, a typo with an argument, would reach
27947    /// the model as a question.
27948    #[test]
27949    fn the_guard_refuses_only_a_name_nothing_has() {
27950        let (ws, mut rx, seat) = a_fleet();
27951        ws.seed_test_advertised_catalogues(
27952            &seat,
27953            vec![forge_primitives::AvailableCommand::new("compact", "Compact")],
27954            Vec::new(),
27955        );
27956
27957        for (text, refusal) in [
27958            ("hello", None),
27959            ("/tmp is full, why?", Some("/tmp is not yet supported")),
27960            ("/help", None),
27961            ("/compact 3", None),
27962            ("/compact3", Some("/compact3 is not yet supported")),
27963        ] {
27964            // The rows share one fleet, so each starts from an empty stream:
27965            // the frame the row above drew would otherwise answer for this one.
27966            while rx.try_recv().is_ok() {}
27967            let dispatched = ws.dispatch_from_view(Command::Prompt {
27968                key: seat.clone(),
27969                text: text.to_owned(),
27970                attachments: Vec::new(),
27971            });
27972            assert!(dispatched.is_ok(), "{text}: {dispatched:?}");
27973            if let Some(expected) = refusal {
27974                // The echo of the words is on the stream too, after the fix
27975                // that put the answer under them; the refusal is what this row
27976                // is about.
27977                let notices: Vec<(NoticeSeverity, String)> =
27978                    std::iter::from_fn(|| rx.try_recv().ok())
27979                        .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
27980                            SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
27981                            _ => None,
27982                        })
27983                        .collect();
27984                assert!(
27985                    matches!(
27986                        notices.as_slice(),
27987                        [(NoticeSeverity::Error, text)] if text == expected
27988                    ),
27989                    "{text} is refused with {expected:?}, got {notices:?}",
27990                );
27991                assert!(ws.drain_test_dispatch_buffer().is_empty(), "{text} reaches nothing");
27992            } else {
27993                let commands = ws.drain_test_dispatch_buffer();
27994                assert!(
27995                    matches!(
27996                        commands.as_slice(),
27997                        [Command::Prompt { .. } | Command::PromptUnder { .. }]
27998                    ),
27999                    "{text} is the CLI's: {commands:?}",
28000                );
28001            }
28002        }
28003    }
28004
28005    /// Effort is user-level, so it lands even when the seat has no session
28006    /// behind it: what it writes is the document a launch reads, not anything
28007    /// about a run.
28008    #[test]
28009    fn effort_lands_for_a_seat_with_no_live_session() {
28010        let dir = tempfile::tempdir().expect("tempdir");
28011        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
28012        ws.seed_test_project("proj", "/tmp/prompt-frame-origin");
28013        ws.enable_test_dispatch_intercept();
28014
28015        ws.dispatch_from_view(Command::Prompt {
28016            key: SessionSlot::lead("TestOrg", "proj"),
28017            text: "/effort low".to_owned(),
28018            attachments: Vec::new(),
28019        })
28020        .expect("the core takes it");
28021
28022        let written = std::fs::read_to_string(dir.path().join("settings.json"))
28023            .expect("the document is written");
28024        let document: serde_json::Value = serde_json::from_str(&written).expect("it parses");
28025        assert_eq!(document["effortLevel"], serde_json::json!("low"));
28026        let notices: Vec<(NoticeSeverity, String)> = std::iter::from_fn(|| rx.try_recv().ok())
28027            .filter_map(|frame| match std::sync::Arc::unwrap_or_clone(frame.update) {
28028                SessionUpdate::Notice { severity, text, .. } => Some((severity, text)),
28029                _ => None,
28030            })
28031            .collect();
28032        assert!(
28033            matches!(notices.as_slice(), [(NoticeSeverity::Info, _)]),
28034            "and the reader is told it took: {notices:?}",
28035        );
28036    }
28037
28038    /// A spawn a view could not complete is completed by the core: a client
28039    /// has no config to read, so what it sends carries none, and the settings
28040    /// the launch reads are built from the settings documents instead.
28041    #[test]
28042    fn a_spawn_without_settings_gets_them_from_the_documents() {
28043        // A config dir of its own, so the line the spawn carries is read from
28044        // a document this test wrote rather than from the machine's.
28045        let dir = tempfile::tempdir().expect("tempdir");
28046        std::fs::write(dir.path().join("settings.json"), br#"{ "effortLevel": "low" }"#)
28047            .expect("seed the document");
28048        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
28049        ws.enable_test_dispatch_intercept();
28050
28051        ws.dispatch_from_view(Command::SpawnProject {
28052            project_name: "proj".to_owned(),
28053            launch_settings: SessionLaunchSettings::default(),
28054        })
28055        .expect("the core takes it");
28056
28057        let commands = ws.drain_test_dispatch_buffer();
28058        let [Command::SpawnProject { launch_settings, .. }] = commands.as_slice() else {
28059            panic!("the spawn was not the command routed: {commands:?}");
28060        };
28061        let settings = launch_settings.settings.as_ref().expect("the documents filled it in");
28062        assert_eq!(
28063            settings["effortLevel"],
28064            serde_json::json!("low"),
28065            "the value the document holds, which a bare spawn would not have carried",
28066        );
28067    }
28068
28069    /// The mirror: a spawn that built its own settings keeps them, so the
28070    /// caller's snapshot is not silently replaced by the core's read.
28071    #[test]
28072    fn a_spawn_that_supplied_settings_keeps_them() {
28073        let (ws, _rx, _seat) = a_fleet();
28074        let supplied = SessionLaunchSettings {
28075            settings: Some(serde_json::json!({ "model": "haiku" })),
28076            ..SessionLaunchSettings::default()
28077        };
28078
28079        ws.dispatch_from_view(Command::SpawnProject {
28080            project_name: "proj".to_owned(),
28081            launch_settings: supplied,
28082        })
28083        .expect("the core takes it");
28084
28085        let commands = ws.drain_test_dispatch_buffer();
28086        let [Command::SpawnProject { launch_settings, .. }] = commands.as_slice() else {
28087            panic!("the spawn was not the command routed: {commands:?}");
28088        };
28089        assert_eq!(
28090            launch_settings.settings,
28091            Some(serde_json::json!({ "model": "haiku" })),
28092            "the caller's own settings are left alone",
28093        );
28094    }
28095
28096    /// A name the CLI advertised is the CLI's, and goes to it.
28097    #[test]
28098    fn a_slash_name_the_cli_advertised_is_forwarded() {
28099        let (ws, _rx, seat) = a_fleet();
28100        ws.seed_test_advertised_catalogues(
28101            &seat,
28102            vec![forge_primitives::AvailableCommand::new("compact", "Compact")],
28103            Vec::new(),
28104        );
28105
28106        let dispatched = ws.dispatch_from_view(Command::Prompt {
28107            key: seat.clone(),
28108            text: "/compact".to_owned(),
28109            attachments: Vec::new(),
28110        });
28111
28112        assert!(dispatched.is_ok(), "the core takes it: {dispatched:?}");
28113        let commands = ws.drain_test_dispatch_buffer();
28114        assert!(
28115            matches!(
28116                commands.as_slice(),
28117                [Command::Prompt { text, .. } | Command::PromptUnder { text, .. }]
28118                    if text == "/compact"
28119            ),
28120            "an advertised name is the CLI's: {commands:?}",
28121        );
28122    }
28123
28124    /// A session that has advertised nothing refuses nothing: an empty
28125    /// catalogue is not knowing what the CLI offers, and refusing on
28126    /// ignorance would drop names it does have.
28127    #[test]
28128    fn a_session_that_advertised_nothing_refuses_nothing() {
28129        let (ws, _rx, seat) = a_fleet();
28130
28131        let dispatched = ws.dispatch_from_view(Command::Prompt {
28132            key: seat.clone(),
28133            text: "/compact".to_owned(),
28134            attachments: Vec::new(),
28135        });
28136
28137        assert!(dispatched.is_ok(), "the core takes it: {dispatched:?}");
28138        let commands = ws.drain_test_dispatch_buffer();
28139        assert!(
28140            matches!(commands.as_slice(), [Command::Prompt { .. } | Command::PromptUnder { .. }]),
28141            "nothing is refused before the CLI has said what it has: {commands:?}",
28142        );
28143    }
28144
28145    /// A name that only LOOKS like a forge command is the reader's prose, and
28146    /// reaches the model as they typed it.
28147    #[test]
28148    fn a_prompt_that_only_starts_like_a_forge_command_is_still_a_prompt() {
28149        let (ws, _rx, seat) = a_fleet();
28150
28151        let dispatched = ws.dispatch_from_view(Command::Prompt {
28152            key: seat.clone(),
28153            text: "/newer please".to_owned(),
28154            attachments: Vec::new(),
28155        });
28156
28157        assert!(dispatched.is_ok(), "the core takes it: {dispatched:?}");
28158        let commands = ws.drain_test_dispatch_buffer();
28159        assert!(
28160            matches!(
28161                commands.as_slice(),
28162                [Command::Prompt { text, .. } | Command::PromptUnder { text, .. }]
28163                    if text == "/newer please"
28164            ),
28165            "a near miss is prose: {commands:?}",
28166        );
28167    }
28168
28169    /// **A routed prompt draws no row at dispatch.** The queue carries its
28170    /// words and the row lands when the CLI starts it (#2816); a frame forged
28171    /// here would sit above the answer the running turn was still writing.
28172    #[test]
28173    fn a_routed_prompt_draws_no_row_from_the_dispatcher() {
28174        let (ws, mut rx, seat) = a_fleet();
28175
28176        ws.dispatch_from_view(a_prompt(&seat)).expect("the core took the prompt");
28177
28178        let mut drawn = Vec::new();
28179        while let Ok(frame) = rx.try_recv() {
28180            if let SessionUpdate::ChatAppended { msg, .. } = &*frame.update {
28181                drawn.push(msg.clone());
28182            }
28183        }
28184        assert!(
28185            drawn.is_empty(),
28186            "the queue carries the words and the CLI's start draws the row, so dispatch forges \
28187             none: {drawn:?}",
28188        );
28189        assert!(!ws.drain_test_dispatch_buffer().is_empty(), "the prompt itself is wired");
28190    }
28191
28192    /// The delivery path, which already draws as an envelope turn of its own.
28193    #[test]
28194    fn a_delivery_emits_no_frame() {
28195        let (ws, mut rx, seat) = a_fleet();
28196
28197        let _ = ws.dispatch_workspace_prompt(&seat, "run the morning summary".to_owned());
28198
28199        assert!(
28200            rx.try_recv().is_err(),
28201            "a bare user turn beside a delivery's envelope turn draws the same words twice",
28202        );
28203    }
28204
28205    /// A prompt the core refuses draws nothing anywhere.
28206    ///
28207    /// It never reached a model, so there is no turn to draw - and a frame for
28208    /// one would open a live turn in every view but the sender, while the
28209    /// refusal is written to the asking socket alone. Those views cannot learn
28210    /// why, so the turn bar spins for the life of the connection.
28211    #[test]
28212    fn a_refused_prompt_emits_no_frame() {
28213        // No intercept: the dispatch takes the real path and is refused, which
28214        // is the case this pins.
28215        let (ws, mut rx) = Workspace::testing_stub();
28216        let nowhere = SessionSlot::lead("TestOrg", "no-such-project");
28217
28218        let refused = ws.dispatch(a_prompt(&nowhere));
28219
28220        assert!(refused.is_err(), "precondition: the core holds no session here: {refused:?}");
28221        assert!(
28222            rx.try_recv().is_err(),
28223            "a refused prompt opened a turn in every view but the sender",
28224        );
28225    }
28226
28227    /// **The config's profile names reach the fleet's resolver.** The boot
28228    /// copies this pair straight into `configure_profiles`; an org's fallback
28229    /// arrives keyed by its name, and a project's override by its
28230    /// `(org, project)`.
28231    #[test]
28232    fn the_configs_profile_names_reach_the_fleet_as_org_and_project_maps() {
28233        let dir = tempfile::tempdir().expect("tempdir");
28234        let forge = crate::config::ensure_forge_data_dir(dir.path()).expect("forge/ dir");
28235        std::fs::write(
28236            forge.join("forge.toml"),
28237            r#"
28238[[accounts]]
28239display_name = "main"
28240token = "t"
28241models = ["claude-sonnet-5"]
28242provider = "anthropic"
28243
28244[[orgs]]
28245name = "Busytools"
28246accounts = ["main"]
28247browser_profile = "work"
28248
28249  [[orgs.projects]]
28250  name = "forge"
28251  path = "/tmp/forge"
28252
28253  [[orgs.projects]]
28254  name = "apply"
28255  path = "/tmp/apply"
28256  browser_profile = "job"
28257
28258[[orgs]]
28259name = "Personal"
28260accounts = ["main"]
28261
28262  [[orgs.projects]]
28263  name = "notes"
28264  path = "/tmp/notes"
28265"#,
28266        )
28267        .expect("the config is written");
28268        let config = load_from_dir(dir.path()).expect("the config loads");
28269        let (ws, _rx) =
28270            Workspace::testing_stub_with_config(dir.path().to_owned(), config).expect("the stub");
28271        let (orgs, projects) = ws.browser_profile_config();
28272        assert_eq!(orgs.get("Busytools").map(String::as_str), Some("work"), "the org's key");
28273        assert_eq!(orgs.get("Personal").map(String::as_str), Some("default"), "no key is default");
28274        assert_eq!(
28275            projects.get(&("Busytools".to_owned(), "apply".to_owned())).map(String::as_str),
28276            Some("job"),
28277            "the project's override, keyed by its org and name",
28278        );
28279        assert_eq!(
28280            projects.get(&("Busytools".to_owned(), "forge".to_owned())).map(String::as_str),
28281            Some("work"),
28282            "a project with no key carries the org's",
28283        );
28284    }
28285}