Skip to main content

forge_server/
boot.rs

1//! The boot the server runs: the config dir, the workspace, the subsystem
2//! wave, the socket, and the boot spawn.
3//!
4//! Moved here from the view's `main.rs` so the core comes up under one
5//! function for a view and for no view alike, and so no view's removal can
6//! take the boot with it.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use forge_workspace::Workspace;
12
13/// Resolve the Claude config directory at the boot boundary: honour
14/// `$CLAUDE_CONFIG_DIR` (ignoring empty values), else fall back to
15/// `$HOME/.claude`. After this point, the resolved path is threaded as a
16/// typed `PathBuf` (via `Workspace::new`, which in turn binds each
17/// `Agent::spawn(config_dir)` to its account's path).
18///
19/// Per hard rule #14 - no cwd-derived fallbacks. When `$CLAUDE_CONFIG_DIR`
20/// is unset/empty AND `dirs::home_dir()` returns None, refuse to launch
21/// rather than substituting `./.claude`.
22pub fn resolve_config_dir() -> anyhow::Result<PathBuf> {
23    if let Ok(value) = std::env::var("CLAUDE_CONFIG_DIR") {
24        let trimmed = value.trim_end_matches('/');
25        if !trimmed.is_empty() {
26            return Ok(PathBuf::from(trimmed));
27        }
28    }
29    dirs::home_dir().map(|h| h.join(".claude")).ok_or_else(|| {
30        anyhow::anyhow!("$CLAUDE_CONFIG_DIR unset and could not resolve home directory")
31    })
32}
33
34/// One panic, as the hook records it.
35struct PanicRecord {
36    thread: String,
37    payload: String,
38    location: String,
39    backtrace: Option<String>,
40}
41
42/// Install the process-wide panic hook, once.
43///
44/// Every panic is one ERROR line in the tracing log, and the previous hook
45/// still runs so stderr keeps its behaviour. Installed after the tracing
46/// subscriber and before anything is spawned, so a panic in the boot wave is
47/// recorded rather than lost.
48pub fn install_panic_hook() {
49    static INSTALLED: std::sync::Once = std::sync::Once::new();
50    INSTALLED.call_once(|| {
51        let previous = std::panic::take_hook();
52        std::panic::set_hook(Box::new(move |info| {
53            let thread = std::thread::current();
54            let record =
55                panic_record(thread.name().unwrap_or("unnamed"), info.payload(), info.location());
56            tracing::error!(
57                target: crate::logging::targets::APP_LIFECYCLE,
58                event_name = "panic",
59                thread = %record.thread,
60                payload = %record.payload,
61                location = %record.location,
62                backtrace = %record.backtrace.as_deref().unwrap_or(""),
63                "a thread panicked",
64            );
65            previous(info);
66        }));
67    });
68}
69
70/// The record one panic writes: the thread, the payload as a string, the
71/// file and line, and the backtrace when `RUST_BACKTRACE` is set.
72fn panic_record(
73    thread: &str,
74    payload: &(dyn std::any::Any + Send),
75    location: Option<&std::panic::Location<'_>>,
76) -> PanicRecord {
77    let payload = payload
78        .downcast_ref::<&str>()
79        .map(|text| (*text).to_owned())
80        .or_else(|| payload.downcast_ref::<String>().cloned())
81        .unwrap_or_else(|| "a panic with no message".to_owned());
82    let location = location
83        .map_or_else(String::new, |location| format!("{}:{}", location.file(), location.line()));
84    let backtrace = std::env::var_os("RUST_BACKTRACE")
85        .filter(|value| value.to_str() != Some("0"))
86        .map(|_| std::backtrace::Backtrace::force_capture().to_string());
87    PanicRecord { thread: thread.to_owned(), payload, location, backtrace }
88}
89
90/// Install the process-level rustls crypto provider, before anything can
91/// build a TLS client.
92///
93/// The graph carries both `aws-lc-rs` and `ring`, so rustls will not choose
94/// and the first handshake panics. `install_default` returns `Err` when a
95/// provider is already in place, which is the idempotent success case: a
96/// second boot entry or an embedding caller may run it again.
97fn install_crypto_provider() {
98    let already_installed =
99        rustls::crypto::aws_lc_rs::default_provider().install_default().is_err();
100    tracing::debug!(
101        target: crate::logging::targets::APP_LIFECYCLE,
102        event_name = "rustls_crypto_provider",
103        already_installed,
104        "the process-level rustls crypto provider is in place",
105    );
106}
107
108/// Build the workspace and start the whole subsystem wave, in the order the
109/// boot has always started it.
110///
111/// `project` is the CLI's positional, validated here because a mistyped
112/// name has to fail the boot the same way for every caller; what each
113/// caller then DOES with it differs (the headless server starts that
114/// project's sessions).
115///
116/// Not `async`: every step is a spawn, a store write or the skill install, and the await on
117/// anything that actually does I/O belongs to `serve_socket`. Nothing here
118/// needs a `LocalSet` either - every task in this wave is a plain
119/// `tokio::spawn`, verified by the headless boot test exercising it
120/// without one.
121pub fn start_core(config_dir: &Path, project: Option<&str>) -> anyhow::Result<Arc<Workspace>> {
122    // **The provider first, the hook second.** The graph carries both rustls
123    // providers, so the first TLS handshake panics unless one is installed;
124    // the hook is what names that panic if it ever happens again.
125    install_crypto_provider();
126    install_panic_hook();
127    let workspace = match Workspace::new(config_dir.to_path_buf()) {
128        Ok(w) => Arc::new(w),
129        Err(err) => return Err(anyhow::anyhow!("forge: {err}")),
130    };
131    // The settings document carries the build that made it; this is the one
132    // place the server's own version reaches the workspace. The short stamp
133    // is the one the home's header draws as `server`.
134    workspace.set_settings_version(crate::FORGE_VERSION_SHORT);
135
136    // Validate the CLI's positional `<PROJECT>` arg, if any, before any
137    // view or spawn wave. This turns `forge xyz` (where `xyz` isn't in
138    // forge.toml) into a clean stderr error + non-zero exit instead of a
139    // boot that fails deeper with a worse message.
140    if let Some(name) = project
141        && let Err(err) = workspace.validate_project_name(name)
142    {
143        return Err(anyhow::anyhow!("forge: {err}"));
144    }
145
146    install_shipped_skills(config_dir);
147    start_subsystems(&workspace);
148    #[cfg(target_os = "macos")]
149    {
150        let fleet = install_browser(&workspace)?;
151        start_config_watch(&workspace, move |workspace| {
152            refresh_browser(workspace, &fleet);
153        });
154    }
155    #[cfg(not(target_os = "macos"))]
156    {
157        install_browser(&workspace);
158        start_config_watch(&workspace, |_workspace| {});
159    }
160
161    Ok(workspace)
162}
163
164/// Install the skills forge ships into the config dir.
165///
166/// **forge.toml is not written by this path; these skill directories are.**
167/// Each skill is forge's own text, compared byte for byte, so an upgrade
168/// refreshes what differs and a boot with identical copies touches nothing.
169/// One skill's failure is its own line: the rest still install.
170fn install_shipped_skills(config_dir: &Path) {
171    for skill in forge_workspace::shipped_skills() {
172        let dir = forge_workspace::skill_dir(config_dir, skill.name);
173        match forge_workspace::install_skill(config_dir, skill) {
174            Ok(outcome) => {
175                let Some((action, message)) = install_line(outcome) else { continue };
176                tracing::info!(
177                    target: "forge_workspace::skills",
178                    event_name = skill_installed_event(skill.name),
179                    skill = skill.name,
180                    path = %dir.display(),
181                    action,
182                    "{message}",
183                );
184            }
185            Err(error) => {
186                tracing::warn!(
187                    target: "forge_workspace::skills",
188                    event_name = skill_install_failed_event(skill.name),
189                    skill = skill.name,
190                    path = %dir.display(),
191                    consequence = skill_failure_consequence(skill.name),
192                    %error,
193                    "the skill could not be installed",
194                );
195            }
196        }
197    }
198}
199
200/// The line an install gets, or `None` when it changed nothing.
201///
202/// **A sweep is not a write**, so its line says what actually happened
203/// rather than claiming the skill was written.
204fn install_line(outcome: forge_workspace::SkillInstall) -> Option<(&'static str, &'static str)> {
205    match outcome {
206        forge_workspace::SkillInstall::Created => {
207            Some(("created", "the skill was written at boot"))
208        }
209        forge_workspace::SkillInstall::Replaced => {
210            Some(("replaced", "the skill was written at boot"))
211        }
212        forge_workspace::SkillInstall::Swept => {
213            // **Not "were removed":** a removal can fail and its WARN sits
214            // beside this line, so the INFO says what was owed, not what
215            // happened.
216            Some(("swept", "the skill's leftover files were due for removal at boot"))
217        }
218        forge_workspace::SkillInstall::Unchanged => None,
219    }
220}
221
222/// What a failed install leaves behind, per skill.
223///
224/// **Handoff degrades; the wizard can be left mixed.** A handoff turn
225/// carries its three steps in the judgment prompt as a fallback, while the
226/// wizard's two files can be split across a failed rename, so the line says
227/// which of the two happened rather than one sentence standing for both.
228fn skill_failure_consequence(name: &str) -> &'static str {
229    match name {
230        "handoff" => "a handoff turn falls back to the prompt's own steps",
231        _ => "the skill may be missing or partly updated until a later boot writes it",
232    }
233}
234
235/// The log event names stay per-skill: handoff's pair is the one existing
236/// traces and greps know, and a new skill gets its own rather than being
237/// folded into handoff's line.
238fn skill_installed_event(name: &str) -> &'static str {
239    match name {
240        "handoff" => "handoff_skill_installed",
241        "wizard" => "wizard_skill_installed",
242        _ => "skill_installed",
243    }
244}
245
246fn skill_install_failed_event(name: &str) -> &'static str {
247    match name {
248        "handoff" => "handoff_skill_install_failed",
249        "wizard" => "wizard_skill_install_failed",
250        _ => "skill_install_failed",
251    }
252}
253
254/// Watch `forge.toml` for the process's life and apply each valid edit.
255///
256/// The watch is a single file (`forge.toml`), never the config directory, so
257/// nothing else in that tree can trigger a reload. Each event is a whole-file
258/// read: a rejected file leaves the previous config live and logs one WARN,
259/// and an applied one logs the tables that moved and the keys that only a
260/// restart can take. `apply` runs for the subsystems the boot holds.
261fn start_config_watch<F>(workspace: &Arc<Workspace>, apply: F)
262where
263    F: Fn(&Workspace) + Send + Sync + 'static,
264{
265    let path = workspace.config_path();
266    let weak = Arc::downgrade(workspace);
267    let apply = Arc::new(apply);
268    // **The first watch is installed before the member starts.** An edit in
269    // the window between this call and the driver's first poll is not
270    // missed, and a failed install is one WARN rather than a restart
271    // ladder.
272    let (changed, edits) = tokio::sync::mpsc::unbounded_channel();
273    let first = match forge_workspace::env::config_watch::watch(path.clone(), changed) {
274        Ok(watch) => Some((watch, edits)),
275        Err(error) => {
276            tracing::warn!(
277                target: crate::logging::targets::APP_LIFECYCLE,
278                event_name = "config_watch_failed",
279                path = %path.display(),
280                error = %error,
281                "forge.toml is not being watched; an edit needs a restart this run",
282            );
283            return;
284        }
285    };
286    let first = Arc::new(std::sync::Mutex::new(first));
287    workspace.supervisor().spawn("config_watch", forge_supervise::Policy::backoff(), move || {
288        let path = path.clone();
289        let weak = weak.clone();
290        let apply = Arc::clone(&apply);
291        let first = Arc::clone(&first);
292        async move {
293            let first_watch =
294                first.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
295            let (watch, mut edits) = if let Some(pair) = first_watch {
296                pair
297            } else {
298                // A restart after the first install needs a fresh watch and
299                // channel.
300                let (changed, edits) = tokio::sync::mpsc::unbounded_channel();
301                match forge_workspace::env::config_watch::watch(path.clone(), changed) {
302                    Ok(watch) => (watch, edits),
303                    Err(error) => {
304                        tracing::warn!(
305                            target: crate::logging::targets::APP_LIFECYCLE,
306                            event_name = "config_watch_failed",
307                            path = %path.display(),
308                            error = %error,
309                            "forge.toml is not being watched; an edit needs a restart this run",
310                        );
311                        return;
312                    }
313                }
314            };
315            // The handle lives in this task, so the watch lives as long
316            // as the process (or, in a test, the runtime) without a
317            // handle outside.
318            let _watch = watch;
319            loop {
320                let _ = edits.recv().await;
321                let Some(workspace) = weak.upgrade() else { break };
322                match workspace.reload_config() {
323                    Ok(reload) => {
324                        // A save that changed nothing is not an apply.
325                        if reload.changed.is_empty() && reload.restart_only.is_empty() {
326                            continue;
327                        }
328                        tracing::info!(
329                            target: crate::logging::targets::APP_LIFECYCLE,
330                            event_name = "config_reloaded",
331                            changed = ?reload.changed,
332                            restart_only = ?reload.restart_only,
333                            "forge.toml applied to the running forge",
334                        );
335                        apply(&workspace);
336                    }
337                    Err(error) => {
338                        tracing::warn!(
339                            target: crate::logging::targets::APP_LIFECYCLE,
340                            event_name = "config_reload_rejected",
341                            path = %workspace.config_path().display(),
342                            error = %error,
343                            "forge.toml was not applied; the last good config stays live",
344                        );
345                    }
346                }
347            }
348        }
349    });
350}
351
352/// Push the reloaded `[browser]` knobs and profile names into the fleet.
353#[cfg(target_os = "macos")]
354fn refresh_browser(workspace: &Workspace, fleet: &Arc<forge_browser::service::BrowserService>) {
355    let browser = workspace.browser_settings();
356    fleet.configure_encoding(browser.fps, browser.quality);
357    fleet.configure_idle(browser.idle_minutes);
358    configure_fleet_profiles(workspace, fleet);
359}
360
361/// Install the server's browser fleet: constructed here, one host and one
362/// driver per profile spawned on first use.
363///
364/// **A missing app-support directory fails the boot**, the same resolution
365/// the durable store needs; the vendored driver stack is not required at
366/// boot, so a server without it boots and its browser calls name what is
367/// missing instead of the whole server refusing to start.
368/// **The join between the config and the fleet's resolver.** The workspace
369/// resolves `forge.toml`'s org and project names; the fleet resolves a seat
370/// against those maps. Both ends have tests, and this is the copy between
371/// them, so it is one named call rather than two lines inside a boot.
372#[cfg(target_os = "macos")]
373fn configure_fleet_profiles(workspace: &Workspace, fleet: &forge_browser::service::BrowserService) {
374    let (orgs, projects) = workspace.browser_profile_config();
375    fleet.configure_profiles(orgs, projects);
376}
377
378#[cfg(target_os = "macos")]
379fn install_browser(
380    workspace: &Arc<Workspace>,
381) -> anyhow::Result<Arc<forge_browser::service::BrowserService>> {
382    let fleet = forge_browser::service::BrowserService::installed()
383        .map_err(|why| anyhow::anyhow!("forge: {why}"))?;
384    // **The encoding and the idle clock are the `[browser]` keys**: a
385    // profile nobody has touched closes after the bound.
386    let browser = workspace.browser_settings();
387    fleet.configure_encoding(browser.fps, browser.quality);
388    fleet.configure_idle(browser.idle_minutes);
389    // **The profile names are config's, not a client's**: every seat resolves
390    // its profile from `forge.toml` here, at boot, rather than naming one.
391    configure_fleet_profiles(workspace, &fleet);
392    fleet.set_supervisor(Arc::clone(workspace.supervisor()));
393    fleet.start_idle();
394    fleet.start_pairing();
395    // **A host an unclean exit left behind is reaped before any profile is
396    // used.** It still holds the profile's singleton lock, and the first
397    // call would wedge on it; the reaper checks the process is really this
398    // bundle's host, and that its server is gone, before it signals anything.
399    //
400    // **The prune runs after the reap.** The reaper identifies an orphan by
401    // its executable's path, and a prune that unlinked a live host's binary
402    // first would take that identity away (`proc_pidpath` returns nothing for
403    // an unlinked binary). A peer's live hosts are spared by the reaper's
404    // parent check; the prune re-asks whether the machine is shared before
405    // every removal, because a peer can take its lock after the boot's first
406    // look. Off the boot thread: each prune is a CEF tree.
407    let reaper = Arc::clone(&fleet);
408    let prune_workspace = Arc::downgrade(workspace);
409    let reap = async move { reaper.reap_stale().await };
410    tokio::spawn(reap_then_prune(reap, move || {
411        forge_browser::host_bundle::prune_other_versions(&move || {
412            prune_workspace.upgrade().is_some_and(|workspace| workspace.sole_instance())
413        });
414    }));
415    workspace.install_browser_host(Arc::new(HostedBrowser(Arc::clone(&fleet))));
416    Ok(fleet)
417}
418
419/// Await the stale-host reap, then run the prune on a blocking thread.
420///
421/// **The order is the contract.** The reaper identifies an orphan by its
422/// executable's path, and `proc_pidpath` answers nothing for an unlinked
423/// binary, so a prune that removed a host's bundle first would take the
424/// identity away. Off the async workers because each prune is a CEF tree.
425#[cfg(target_os = "macos")]
426async fn reap_then_prune<F>(reap: impl std::future::Future<Output = ()>, prune: F)
427where
428    F: FnOnce() + Send + 'static,
429{
430    reap.await;
431    let _ = tokio::task::spawn_blocking(prune).await;
432}
433
434/// A platform with no CEF host: the browser tools answer the no-host refusal
435/// rather than a server that pretends to have a fleet.
436#[cfg(not(target_os = "macos"))]
437fn install_browser(_workspace: &Arc<Workspace>) {}
438
439/// The fleet behind the workspace's port.
440#[cfg(target_os = "macos")]
441struct HostedBrowser(Arc<forge_browser::service::BrowserService>);
442
443#[cfg(target_os = "macos")]
444#[async_trait::async_trait]
445impl forge_workspace::browser::BrowserHost for HostedBrowser {
446    async fn call(
447        &self,
448        seat: &forge_primitives::SessionSlot,
449        tool: &str,
450        args: serde_json::Value,
451    ) -> Result<Vec<forge_primitives::browser::BrowserPart>, String> {
452        self.0.call(seat, tool, args).await
453    }
454
455    async fn attach(
456        &self,
457        viewer: u64,
458        seat: &forge_primitives::SessionSlot,
459        tab: Option<u16>,
460    ) -> Result<forge_workspace::browser::BrowserAttachment, String> {
461        let attachment = self.0.attach(viewer, seat, tab).await?;
462        Ok(forge_workspace::browser::BrowserAttachment {
463            key: attachment.key,
464            id: attachment.id,
465            tab: attachment.tab,
466            frames: attachment.frames,
467            view: attachment.view,
468            tabs: attachment.tabs,
469            nav: attachment.nav,
470            failures: attachment.failures,
471            files: attachment.files,
472            downloads: attachment.downloads,
473            audio: attachment.audio,
474        })
475    }
476
477    async fn attach_page(
478        &self,
479        viewer: u64,
480        seat: &forge_primitives::SessionSlot,
481        target: &str,
482    ) -> Result<forge_workspace::browser::BrowserAttachment, String> {
483        let attachment = self.0.attach_page(viewer, seat, target).await?;
484        Ok(forge_workspace::browser::BrowserAttachment {
485            key: attachment.key,
486            id: attachment.id,
487            tab: attachment.tab,
488            frames: attachment.frames,
489            view: attachment.view,
490            tabs: attachment.tabs,
491            nav: attachment.nav,
492            failures: attachment.failures,
493            files: attachment.files,
494            downloads: attachment.downloads,
495            audio: attachment.audio,
496        })
497    }
498
499    async fn answer_files(
500        &self,
501        profile: &str,
502        tab: u16,
503        id: u64,
504        files: Vec<forge_primitives::browser::BrowserUpload>,
505    ) -> Result<(), String> {
506        self.0.answer_files(profile, tab, id, files).await
507    }
508
509    async fn detach(&self, viewer: u64) {
510        self.0.detach(viewer).await;
511    }
512
513    async fn input(
514        &self,
515        profile: &str,
516        tab: u16,
517        viewer: u64,
518        seq: u64,
519        event: &forge_primitives::browser::BrowserInputEvent,
520    ) -> Result<(), String> {
521        self.0.input(profile, tab, viewer, seq, event).await
522    }
523
524    async fn take_control(&self, profile: &str, tab: u16, viewer: u64) -> Result<(), String> {
525        self.0.take_control(profile, tab, viewer).await
526    }
527
528    async fn release_control(&self, profile: &str, tab: u16, viewer: u64) {
529        self.0.release_control(profile, tab, viewer).await;
530    }
531
532    async fn resize(
533        &self,
534        profile: &str,
535        tab: u16,
536        viewer: u64,
537        width: u32,
538        height: u32,
539        scale: f64,
540    ) {
541        self.0.resize(profile, tab, viewer, width, height, scale).await;
542    }
543
544    async fn nav(
545        &self,
546        profile: &str,
547        tab: u16,
548        viewer: u64,
549        back: bool,
550    ) -> Result<forge_primitives::browser::BrowserNavState, String> {
551        self.0.nav(profile, tab, viewer, back).await
552    }
553
554    async fn nav_state(
555        &self,
556        profile: &str,
557        tab: u16,
558    ) -> Option<forge_primitives::browser::BrowserNavState> {
559        self.0.nav_state(profile, tab).await
560    }
561
562    async fn tabs(
563        &self,
564        seat: &forge_primitives::SessionSlot,
565    ) -> Result<forge_primitives::browser::BrowserTabList, String> {
566        self.0.tabs(seat).await
567    }
568
569    async fn tab_stream(
570        &self,
571        seat: &forge_primitives::SessionSlot,
572    ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>
573    {
574        self.0.tab_stream(seat).await
575    }
576
577    async fn sweep_sessions(&self, seats: &[forge_primitives::SessionSlot], generation: u64) {
578        self.0.sweep_sessions(seats, generation).await;
579    }
580
581    async fn replace_seat(&self, seat: &forge_primitives::SessionSlot) {
582        self.0.replace_seat(seat).await;
583    }
584
585    fn session_ended(&self, seat: &forge_primitives::SessionSlot) {
586        self.0.session_ended(seat);
587    }
588
589    async fn hand_off_started(
590        &self,
591        seat: &forge_primitives::SessionSlot,
592        target: &str,
593    ) -> Result<u16, String> {
594        self.0.hand_off_started(seat, target).await
595    }
596
597    async fn hand_off_finished(&self, seat: &forge_primitives::SessionSlot, tab: u16) {
598        self.0.hand_off_finished(seat, tab).await;
599    }
600
601    async fn warm(&self, seat: &forge_primitives::SessionSlot) {
602        self.0.warm(seat).await;
603    }
604
605    async fn stop(&self) -> Vec<String> {
606        self.0.stop_all().await
607    }
608}
609
610/// The subsystem wave the boot starts, in the order it has always started it.
611///
612/// A function of its own so a test can run the same list the binary runs: a
613/// `start_*` call the wave drops is invisible to any test that starts its
614/// subsystem directly.
615fn start_subsystems(workspace: &Arc<Workspace>) {
616    // Kick off the per-account boot-time loading state machine (one task
617    // per `[[accounts]]` entry) in the background. Each task drives its
618    // account from `Loading` through to a terminal `Ready` or `Bailed`.
619    // The redb store has already seeded the in-memory map at
620    // `Workspace::new`.
621    workspace.start_account_loading_tasks();
622
623    // The 60 s usage poller, after the loading tasks: they probe each account
624    // once at boot and the poller skips its own immediate-fire tick, so this
625    // is what refreshes the account snapshots from then on. Without it they
626    // hold their boot values for the process's life.
627    workspace.start_usage_poller();
628
629    // Rotation cooldowns change what a project row's `would_bind` answers,
630    // on a write and again on the clock that thaws them; this is the one
631    // watcher that turns both into a roster announcement.
632    workspace.start_rotation_announcer();
633
634    // Bind the gateway's inference listener and open the boot gate for
635    // it. Every spawned session's base URL names this port: a failed bind
636    // holds the gate shut AND spawn attempts are refused at the spawn
637    // entries until forge restarts.
638    workspace.start_gateway_listener();
639
640    // Load the model catalogue's cache and refresh a stale feed in the
641    // background. No-op unless `[dictate] enabled` is set, and never on
642    // the boot path: the fetch is what the models page's freshness line
643    // reports on.
644    workspace.start_dictate_catalogue();
645
646    // Fetch, verify and load the dictation models, on the same screen the
647    // accounts appear on. No-op unless `[dictate] enabled` is set: a 3 GB
648    // download is opt-in.
649    workspace.start_dictate_preflight();
650
651    // Load the speaking engine beside it, so the first `say` pays no model
652    // load. No-op unless `[tts] enabled` is set.
653    workspace.start_voice_prewarm();
654
655    // Spawn the worker-kick drainer task (#259). Routes every
656    // `maybe_kick_worker_on_connected` enqueue through a single
657    // `KICK_DISPATCH_INTERVAL`-spaced dispatcher so multi-worker boots
658    // don't hit Anthropic's per-IP burst limit. Idempotent - the start
659    // helper no-ops a second call.
660    workspace.start_kick_dispatcher();
661
662    // Boot catch-up: one tick fires each overdue cron once, advancing
663    // past the missed slots (a cron missed while forge was down fires
664    // now, not once per skipped tick). Runs after Workspace::new so
665    // spawn-to-fire can spawn an asleep project's session.
666    workspace.fire_due_crons(std::time::SystemTime::now());
667
668    // Start the durable-cron scheduler: wakes every ~60s and fires
669    // crons as they come due, advancing/removing each.
670    workspace.start_cron_scheduler();
671
672    // Start the chase sweep: every ~30s the board's misses are derived
673    // and the crossings delivered - the pass that makes the task list
674    // drive instead of report.
675    workspace.start_chase_sweep();
676
677    // Start the idle sweep: every ~60s a lead with nothing in flight is
678    // handed the board sweep, once per two hours, with a six-hour
679    // fallback for a lead that never goes idle. Delivery never wakes a
680    // sleeping session.
681    workspace.start_idle_sweep();
682
683    // Start the Gotify subsystem when configured with at least one
684    // durable subscription loaded at boot; no-op otherwise.
685    workspace.start_gotify_subsystem();
686
687    // Prove each configured `[[slack]]` token with `auth.test`. A
688    // failure is logged and that workspace stays dormant; it never
689    // stops the boot. No-op with no `[[slack]]` entry.
690    workspace.start_slack_verification();
691
692    // Start one Slack pump per workspace with a durable subscription
693    // loaded at boot; no-op otherwise. The subscribe tool starts one
694    // for a workspace that gains its first.
695    workspace.start_slack_subsystem();
696
697    // Drop review state left behind by branches deleted since the
698    // last run. Worker teardown only catches a branch already gone at
699    // that moment, and a branch usually outlives its worker.
700    workspace.start_review_branch_sweep();
701}
702
703/// What [`serve_socket`] did.
704///
705/// Its own type rather than an `Option` so the caller decides what an
706/// unbound socket means to it: a headless server with no view serves
707/// nothing and treats the failure as fatal.
708pub enum SocketServing {
709    /// `[server] enabled = false`: nothing serves, by configuration.
710    Disabled,
711    /// The socket is listening; the serve task ends when the process does.
712    Bound,
713    /// The bind failed, its log line already written.
714    BindFailed,
715}
716
717/// Serve the socket on the address `[server]` names, registering the serve
718/// task with the workspace's supervisor and returning whether one is
719/// serving.
720///
721/// A bind that fails never fails the boot here: this is not on the boot
722/// path, and the failure is logged plus reported as
723/// [`SocketServing::BindFailed`] so the caller's boot cannot die of it.
724pub async fn serve_socket(workspace: &Arc<Workspace>) -> anyhow::Result<SocketServing> {
725    let server = workspace.server_config();
726    if !server.enabled {
727        tracing::debug!(
728            target: crate::logging::targets::APP_LIFECYCLE,
729            event_name = "server_socket_disabled",
730            "[server] enabled = false; nothing is serving this run",
731        );
732        return Ok(SocketServing::Disabled);
733    }
734    let addr = std::net::SocketAddr::new(server.bind, server.port);
735    // Built from the workspace so every piece the socket needs is derived
736    // where the workspace is in hand - the browser fleet above all, which
737    // must be the one the sessions' own tools drive.
738    let state = Arc::new(crate::transport::TransportState::for_workspace(workspace));
739    match tokio::net::TcpListener::bind(addr).await {
740        Ok(listener) => {
741            let bound = listener.local_addr().unwrap_or(addr);
742            tracing::info!(
743                target: crate::logging::targets::APP_LIFECYCLE,
744                event_name = "server_socket_listening",
745                %bound,
746                "the server's socket is listening",
747            );
748            // **Taken before the serve task is spawned**, so the fold is
749            // attached before this returns and the boot wave can spawn a
750            // session whose first `Connected` finds a copy to land on.
751            let updates = state.surface.subscribe_mirror();
752            // The listener and the fold's receiver are not `Clone`, and a
753            // `Never` member is started once, so the factory takes them from
754            // their slots.
755            let listener = Arc::new(std::sync::Mutex::new(Some(listener)));
756            let updates = Arc::new(std::sync::Mutex::new(Some(updates)));
757            workspace.supervisor().spawn(
758                "socket_serve",
759                forge_supervise::Policy::Never,
760                move || {
761                    let listener = Arc::clone(&listener);
762                    let updates = Arc::clone(&updates);
763                    let state = Arc::clone(&state);
764                    async move {
765                        let Some(listener) = listener
766                            .lock()
767                            .unwrap_or_else(std::sync::PoisonError::into_inner)
768                            .take()
769                        else {
770                            return;
771                        };
772                        let Some(updates) = updates
773                            .lock()
774                            .unwrap_or_else(std::sync::PoisonError::into_inner)
775                            .take()
776                        else {
777                            return;
778                        };
779                        if let Err(error) = crate::transport::serve(state, listener, updates).await
780                        {
781                            tracing::error!(
782                                target: crate::logging::targets::APP_LIFECYCLE,
783                                event_name = "server_socket_failed",
784                                error = %error,
785                                "the socket stopped serving",
786                            );
787                        }
788                    }
789                },
790            );
791            Ok(SocketServing::Bound)
792        }
793        Err(error) => {
794            tracing::error!(
795                target: crate::logging::targets::APP_LIFECYCLE,
796                event_name = "server_socket_bind_failed",
797                error = %error,
798                "the socket is not serving",
799            );
800            Ok(SocketServing::BindFailed)
801        }
802    }
803}
804
805/// How long the boot wave holds for the accounts and the listener before it
806/// fires anyway.
807///
808/// A view can hold for as long as the reader is on its preflight screen - a
809/// signal a headless process does not have. A listener that never binds - a
810/// port someone else holds is the case - would otherwise make
811/// `all_accounts_loaded` false forever and turn the wave into a spawn that
812/// never fires, silently.
813pub const BOOT_WAVE_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
814
815/// Hold until the accounts and the listener are ready, or `timeout` passes.
816///
817/// `true` when they settled, `false` at expiry; the caller decides what a
818/// hold that ran out means (the wave fires anyway and warns). The bound is
819/// the whole point - see [`start_boot_wave`] - so it is a parameter, which
820/// is what lets a test pin it at a millisecond instead of a minute.
821async fn wait_for_accounts(workspace: &Arc<Workspace>, timeout: std::time::Duration) -> bool {
822    let deadline = tokio::time::Instant::now() + timeout;
823    while !workspace.all_accounts_loaded() && tokio::time::Instant::now() < deadline {
824        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
825    }
826    workspace.all_accounts_loaded()
827}
828
829/// The boot spawn, for a process with no view: hold until the accounts
830/// settle, then dispatch the wave a view's connect path dispatches -
831/// the named project's lead, or every project whose lead row was left
832/// open, in forge.toml order. A seat whose last turn was cut off carries
833/// the restart note; a parked one is resumed with nothing.
834///
835/// Settling is terminal rather than `Ready` - a bailed account releases
836/// the spawn, because the walk skips it and the pollers keep re-probing
837/// it, so holding would wait on nothing.
838///
839/// **The hold is bounded** (`BOOT_WAVE_WAIT`). On expiry the wave goes
840/// out regardless and a WARN names what was not ready: a refused spawn
841/// carries its own reason (the spawn entries attach it), which beats a wave
842/// that never fires and says nothing.
843///
844/// Spawned rather than awaited: the boot must not block on accounts, and
845/// nothing downstream reads the result.
846pub fn start_boot_wave(workspace: Arc<Workspace>, project: Option<String>, force_new: bool) {
847    tokio::spawn(async move {
848        if !wait_for_accounts(&workspace, BOOT_WAVE_WAIT).await {
849            tracing::warn!(
850                target: crate::logging::targets::APP_LIFECYCLE,
851                event_name = "boot_wave_wait_expired",
852                gateway_ready = workspace.gateway_ready(),
853                gateway_bind_error = workspace.gateway_bind_error().as_deref().unwrap_or(""),
854                "the boot spawn is going out with the accounts or the gateway listener not ready \
855                 (a bound listener here means the account map is what held it); a refused spawn \
856                 carries its own reason",
857            );
858        }
859        // **The derived open set is read before any spawn runs**, so the
860        // `idle` write each spawn lands cannot hide the state it replaced.
861        // `--new` makes these leads start fresh instead of resuming (and
862        // cascades to their workers); a spawn that does resume takes the
863        // restart note as its first turn only when the row said a turn was
864        // in flight.
865        let targets = workspace.boot_wave_targets(project.as_deref());
866        for target in targets {
867            let launch_settings = forge_workspace::SessionLaunchSettings {
868                force_new,
869                resume_kick: target.resume_kick.clone(),
870                ..Default::default()
871            };
872            let project_name = target.project_name.clone();
873            let cmd = forge_workspace::Command::SpawnProject {
874                project_name: project_name.clone(),
875                launch_settings,
876            };
877            if let Err(err) = workspace.dispatch(cmd) {
878                tracing::error!(
879                    target: crate::logging::targets::APP_LIFECYCLE,
880                    event_name = "boot_wave_dispatch_failed",
881                    error = %err,
882                    project = %project_name,
883                    "boot wave dispatch failed",
884                );
885            }
886        }
887    });
888}
889
890/// The stop's exit: a teardown that did not finish every session leaves a
891/// process tree behind, and a supervisor must be able to tell that from a
892/// clean stop.
893///
894/// # Errors
895///
896/// A non-empty `unready` is an error naming the sessions that outlasted
897/// the wait.
898pub fn stop_result(unready: &[forge_primitives::SessionSlot]) -> anyhow::Result<()> {
899    if unready.is_empty() {
900        return Ok(());
901    }
902    let named: Vec<String> = unready.iter().map(forge_primitives::SessionSlot::display).collect();
903    let (subject, tail) = if unready.len() == 1 {
904        ("1 session".to_owned(), "its process tree may outlive")
905    } else {
906        (format!("{} sessions", unready.len()), "their process trees may outlive")
907    };
908    anyhow::bail!(
909        "forge: {subject} did not finish teardown before the wait ran out ({}); {tail} this exit",
910        named.join(", "),
911    );
912}
913
914#[cfg(test)]
915mod tests {
916    use super::{
917        BOOT_WAVE_WAIT, install_crypto_provider, install_shipped_skills, panic_record,
918        start_boot_wave, start_config_watch, start_subsystems, stop_result, wait_for_accounts,
919    };
920    #[cfg(target_os = "macos")]
921    use super::{configure_fleet_profiles, reap_then_prune};
922    use std::sync::Arc;
923    use std::time::Duration;
924
925    use forge_workspace::Workspace;
926
927    /// **The panic record names the thread and the payload.** The hook
928    /// itself is process-global, so the formatter is what a test can drive;
929    /// a record that dropped either half would leave a panic unattributable.
930    #[test]
931    fn the_panic_record_names_the_thread_and_the_payload() {
932        let record = panic_record("forge-worker", &"the pump died", None);
933        assert_eq!(record.thread, "forge-worker", "the thread is named");
934        assert_eq!(record.payload, "the pump died", "the payload is the panic's own words");
935    }
936
937    /// The install is idempotent: the second call finds a provider in
938    /// place and returns cleanly rather than failing, and the process
939    /// carries a default afterwards. Rustls refuses to pick one itself
940    /// while both `aws-lc-rs` and `ring` are in the graph, which is the
941    /// panic this exists to prevent.
942    #[test]
943    fn installing_the_rustls_provider_twice_is_clean() {
944        install_crypto_provider();
945
946        install_crypto_provider();
947
948        assert!(
949            rustls::crypto::CryptoProvider::get_default().is_some(),
950            "the boot's install leaves a process-level rustls provider in place",
951        );
952        // The call a TLS client makes: without a default it panics, which
953        // is the Gotify stream task's first handshake.
954        let _ = rustls::ClientConfig::builder();
955    }
956
957    /// The boot entry installs the provider before it builds anything: a
958    /// TLS client can be built from anywhere downstream, and this graph
959    /// carries two provider features. An empty config dir refuses at
960    /// `Workspace::new`, so the install is the only boot step that runs
961    /// and the test never reaches the machine's single-instance lock.
962    #[test]
963    fn the_boot_entry_installs_the_provider_before_it_builds_anything() {
964        let dir = tempfile::tempdir().expect("tempdir");
965
966        let events = crate::test_support::logged(|| {
967            let booted = super::start_core(dir.path(), None);
968            assert!(booted.is_err(), "an empty config dir does not build a workspace");
969        });
970
971        assert!(
972            events.iter().any(|(level, fields)| {
973                *level == tracing::Level::DEBUG
974                    && fields.contains("event_name=\"rustls_crypto_provider\"")
975            }),
976            "the boot entry logs the provider install before it builds anything: {events:?}",
977        );
978    }
979
980    /// **Each skill's install line keeps its own name.** Handoff's pair is
981    /// what existing traces and greps know, so it must not move; the wizard's
982    /// is its own rather than folded into handoff's line, and a boot with
983    /// nothing to write says nothing.
984    #[test]
985    fn the_boot_names_each_skills_install_event() {
986        let dir = tempfile::tempdir().expect("tempdir");
987        let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
988
989        for (skill, event) in
990            [("handoff", "handoff_skill_installed"), ("wizard", "wizard_skill_installed")]
991        {
992            assert!(
993                events.iter().any(|(level, fields)| {
994                    *level == tracing::Level::INFO
995                        && fields.contains(&format!("event_name=\"{event}\""))
996                        && fields.contains(&format!("skill=\"{skill}\""))
997                        && fields.contains("action=\"created\"")
998                }),
999                "{skill}'s install line is named {event}: {events:?}",
1000            );
1001        }
1002        assert_eq!(super::skill_install_failed_event("handoff"), "handoff_skill_install_failed");
1003        assert_eq!(super::skill_install_failed_event("wizard"), "wizard_skill_install_failed");
1004
1005        let second = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1006        assert!(
1007            !second.iter().any(|(_, fields)| fields.contains("_skill_installed")
1008                || fields.contains("_skill_install_failed")),
1009            "an unchanged boot writes no install line: {second:?}",
1010        );
1011    }
1012
1013    /// A rewrite is named as one; a boot that only swept a leftover says so
1014    /// rather than claiming it wrote the skill.
1015    #[test]
1016    fn a_replaced_install_names_its_action() {
1017        let dir = tempfile::tempdir().expect("tempdir");
1018        install_shipped_skills(dir.path());
1019        std::fs::write(
1020            forge_workspace::skill_dir(dir.path(), "wizard").join("SKILL.md"),
1021            b"edited",
1022        )
1023        .expect("edit");
1024
1025        let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1026        assert!(
1027            events.iter().any(|(level, fields)| {
1028                *level == tracing::Level::INFO
1029                    && fields.contains("event_name=\"wizard_skill_installed\"")
1030                    && fields.contains("action=\"replaced\"")
1031            }),
1032            "the rewrite is named: {events:?}",
1033        );
1034    }
1035
1036    /// **The sweep has its own records**: each removed path at debug, a count
1037    /// at info, and an install line that says `swept`, not `written`.
1038    #[test]
1039    fn a_sweep_names_what_it_removed() {
1040        let dir = tempfile::tempdir().expect("tempdir");
1041        install_shipped_skills(dir.path());
1042        std::fs::write(
1043            forge_workspace::skill_dir(dir.path(), "wizard").join("SKILL.md.tmp"),
1044            "half a write",
1045        )
1046        .expect("leftover");
1047
1048        let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1049        let has = |event: &str, level: tracing::Level| {
1050            events.iter().any(|(seen, fields)| {
1051                *seen == level && fields.contains(&format!("event_name=\"{event}\""))
1052            })
1053        };
1054        assert!(has("skill_file_removed", tracing::Level::DEBUG), "the path is named: {events:?}");
1055        assert!(has("skill_files_swept", tracing::Level::INFO), "the count is logged: {events:?}");
1056        assert!(
1057            events.iter().any(|(_, fields)| {
1058                fields.contains("event_name=\"wizard_skill_installed\"")
1059                    && fields.contains("action=\"swept\"")
1060            }),
1061            "a sweep is not a write: {events:?}",
1062        );
1063    }
1064
1065    /// A removal that fails is a WARN inside the install, and the boot's
1066    /// install line still reports the sweep.
1067    #[test]
1068    fn a_failed_removal_warns_and_the_install_still_reports() {
1069        use std::os::unix::fs::PermissionsExt;
1070        let dir = tempfile::tempdir().expect("tempdir");
1071        install_shipped_skills(dir.path());
1072        let skill_dir = forge_workspace::skill_dir(dir.path(), "wizard");
1073        std::fs::write(skill_dir.join("SKILL.md.tmp"), "half a write").expect("leftover");
1074        std::fs::set_permissions(&skill_dir, std::fs::Permissions::from_mode(0o555)).expect("mode");
1075        let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1076        std::fs::set_permissions(&skill_dir, std::fs::Permissions::from_mode(0o755)).expect("mode");
1077
1078        assert!(
1079            events.iter().any(|(level, fields)| {
1080                *level == tracing::Level::WARN
1081                    && fields.contains("event_name=\"skill_file_remove_failed\"")
1082            }),
1083            "the failed removal warns: {events:?}",
1084        );
1085        assert!(
1086            events.iter().any(|(level, fields)| {
1087                *level == tracing::Level::INFO
1088                    && fields.contains("event_name=\"wizard_skill_installed\"")
1089                    && fields.contains("action=\"swept\"")
1090            }),
1091            "the install still reports: {events:?}",
1092        );
1093    }
1094
1095    /// A symlinked skill directory is refused with a warning rather than
1096    /// written through.
1097    #[test]
1098    fn a_symlinked_skill_directory_is_refused_with_a_warning() {
1099        use std::os::unix::fs::symlink;
1100        let dir = tempfile::tempdir().expect("tempdir");
1101        let outside = tempfile::tempdir().expect("outside");
1102        std::fs::create_dir_all(dir.path().join("skills")).expect("skills dir");
1103        symlink(outside.path(), forge_workspace::skill_dir(dir.path(), "wizard")).expect("symlink");
1104
1105        let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1106        assert!(
1107            events.iter().any(|(level, fields)| {
1108                *level == tracing::Level::WARN
1109                    && fields.contains("event_name=\"skill_dir_is_a_symlink\"")
1110                    && fields.contains("skill=\"wizard\"")
1111            }),
1112            "the refusal is a warning naming the skill: {events:?}",
1113        );
1114        assert!(!outside.path().join("SKILL.md").exists(), "nothing is written through it");
1115    }
1116
1117    /// A stop whose teardown ran out of budget exits non-zero and names
1118    /// what was still running; a clean stop is a success.
1119    #[test]
1120    fn a_teardown_that_did_not_finish_stops_with_a_failure() {
1121        assert!(stop_result(&[]).is_ok(), "a clean stop is a success");
1122
1123        let slot = forge_primitives::SessionSlot::new("org", "proj", "slow");
1124        let error = stop_result(std::slice::from_ref(&slot)).expect_err("a left tree is a failure");
1125        let rendered = format!("{error}");
1126        assert!(rendered.contains("org/proj/slow"), "the failure names the session: {rendered}");
1127        assert!(rendered.contains("its process tree"), "one session reads as one: {rendered}");
1128
1129        let second = forge_primitives::SessionSlot::new("org", "proj", "slower");
1130        let both = [slot, second];
1131        let error = stop_result(&both).expect_err("two left trees are a failure");
1132        let rendered = format!("{error}");
1133        assert!(rendered.contains("2 sessions"), "two sessions read as two: {rendered}");
1134        assert!(rendered.contains("their process trees"), "and take the plural: {rendered}");
1135    }
1136
1137    /// A bare boot fixture: one project whose account is base-url'd at a dead
1138    /// local port (its boot probe fails in milliseconds rather than reaching
1139    /// an endpoint), dictation off and a gateway port the OS just handed out,
1140    /// so the wave's other steps no-op or park. The port is picked free and
1141    /// then handed on - a busy box can take it in between, and the listener's
1142    /// failed bind is logged rather than read here.
1143    fn boot_fixture() -> (Arc<Workspace>, tempfile::TempDir) {
1144        fixture(false)
1145    }
1146
1147    /// The same boot, with the socket serving on a port the OS hands out.
1148    fn socket_fixture() -> (Arc<Workspace>, tempfile::TempDir) {
1149        fixture(true)
1150    }
1151
1152    /// The bare boot over two projects, `forge` first and `notes` second,
1153    /// so a wave test can tell a seat's state from its position: neither
1154    /// carries a row until the test seeds one, so nothing spawns by
1155    /// default.
1156    fn fixture(serve: bool) -> (Arc<Workspace>, tempfile::TempDir) {
1157        let dir = tempfile::tempdir().expect("tempdir");
1158        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
1159        let port = listener.local_addr().expect("the port the OS handed out").port();
1160        drop(listener);
1161        let socket = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
1162        let socket_port = socket.local_addr().expect("the port the OS handed out").port();
1163        drop(socket);
1164        let forge = dir.path().join("forge");
1165        std::fs::create_dir_all(&forge).expect("forge/");
1166        std::fs::write(
1167            forge.join("forge.toml"),
1168            format!(
1169                "\
1170[[orgs]]
1171name = \"TestOrg\"
1172accounts = [\"Stub\"]
1173
1174[[orgs.projects]]
1175name = \"forge\"
1176path = \"/tmp\"
1177
1178[[orgs.projects]]
1179name = \"notes\"
1180path = \"/tmp\"
1181
1182[[accounts]]
1183display_name = \"Stub\"
1184token = \"t\"
1185provider = \"openrouter\"
1186base_url = \"http://127.0.0.1:9/api\"
1187models = [\"claude-sonnet-5\"]
1188
1189[server]
1190enabled = {serve}
1191port = {socket_port}
1192
1193[gateway]
1194port = {port}
1195",
1196            ),
1197        )
1198        .expect("write forge.toml");
1199        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1200        (Arc::new(workspace), dir)
1201    }
1202
1203    /// Drain the intercepted app-level commands until the wave's spawns
1204    /// arrive. The wave is a spawned task, so the command lands after
1205    /// `start_boot_wave` returns rather than inside it.
1206    async fn spawned_projects(
1207        workspace: &Arc<Workspace>,
1208    ) -> Vec<(String, forge_workspace::SessionLaunchSettings)> {
1209        tokio::time::timeout(Duration::from_secs(10), async {
1210            loop {
1211                let spawned: Vec<_> = workspace
1212                    .drain_test_dispatch_buffer()
1213                    .into_iter()
1214                    .filter_map(|command| match command {
1215                        forge_workspace::Command::SpawnProject {
1216                            project_name,
1217                            launch_settings,
1218                        } => Some((project_name, launch_settings)),
1219                        _ => None,
1220                    })
1221                    .collect();
1222                if !spawned.is_empty() {
1223                    return spawned;
1224                }
1225                tokio::task::yield_now().await;
1226            }
1227        })
1228        .await
1229        .expect("the boot wave dispatches its spawns")
1230    }
1231
1232    /// **The boot wave is the only production site that asks for the
1233    /// restart note, and only a seat that was mid-turn earns it.** The
1234    /// intercept replaces the spawn, so what is read here is the command
1235    /// the wave would send: both open seats are in the wave, in forge.toml
1236    /// order, and only the interrupted one is kicked.
1237    #[tokio::test]
1238    async fn the_boot_wave_opens_the_seats_that_were_open() {
1239        use forge_workspace::store::sessions::SeatState;
1240
1241        let (workspace, _dir) = fixture(false);
1242        workspace.enable_test_dispatch_intercept();
1243        workspace.seed_test_ready_account("Stub");
1244        workspace.seed_test_gateway_ready(true);
1245        assert!(
1246            wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1247            "the fixture's accounts settle before the wave",
1248        );
1249        workspace
1250            .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Idle)
1251            .expect("seed the parked seat");
1252        workspace
1253            .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::InTurn)
1254            .expect("seed the interrupted seat");
1255
1256        start_boot_wave(Arc::clone(&workspace), None, false);
1257
1258        let spawned = spawned_projects(&workspace).await;
1259        assert_eq!(spawned.len(), 2, "both open projects are in the wave: {spawned:?}");
1260        assert_eq!(spawned[0].0, "forge", "declaration order is the wave's order");
1261        assert_eq!(spawned[1].0, "notes");
1262        assert!(!spawned[0].1.force_new, "a normal launch is not a --new one");
1263        assert_eq!(
1264            spawned[0].1.resume_kick, None,
1265            "a seat that was parked is resumed with nothing to do",
1266        );
1267        assert_eq!(
1268            spawned[1].1.resume_kick.as_deref(),
1269            Some(forge_workspace::LEAD_RESTART_NOTE),
1270            "the seat whose turn was cut off takes the restart note",
1271        );
1272    }
1273
1274    /// **The close door keeps a project out of the wave.** A closed lead is
1275    /// not spawned, and its stored workers ride its Connected hook - which
1276    /// never runs for a lead nothing starts.
1277    #[tokio::test]
1278    async fn the_boot_wave_leaves_a_closed_project_shut() {
1279        use forge_workspace::store::sessions::SeatState;
1280
1281        let (workspace, _dir) = fixture(false);
1282        workspace.enable_test_dispatch_intercept();
1283        workspace.seed_test_ready_account("Stub");
1284        workspace.seed_test_gateway_ready(true);
1285        assert!(
1286            wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1287            "the fixture's accounts settle before the wave",
1288        );
1289        workspace
1290            .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Idle)
1291            .expect("seed the open seat");
1292        workspace
1293            .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::Closed)
1294            .expect("seed the closed seat");
1295
1296        start_boot_wave(Arc::clone(&workspace), None, false);
1297
1298        let dispatched = tokio::time::timeout(Duration::from_secs(10), async {
1299            loop {
1300                let drained = workspace.drain_test_dispatch_buffer();
1301                if drained
1302                    .iter()
1303                    .any(|command| matches!(command, forge_workspace::Command::SpawnProject { .. }))
1304                {
1305                    return drained;
1306                }
1307                tokio::task::yield_now().await;
1308            }
1309        })
1310        .await
1311        .expect("the boot wave dispatches its spawns");
1312        let lead_spawns: Vec<&String> = dispatched
1313            .iter()
1314            .filter_map(|command| match command {
1315                forge_workspace::Command::SpawnProject { project_name, .. } => Some(project_name),
1316                _ => None,
1317            })
1318            .collect();
1319        assert_eq!(lead_spawns, vec!["forge"], "the closed project's lead is not spawned");
1320    }
1321
1322    /// `--new` still asks for the note: the spawn's resume gate is what
1323    /// drops it, so the wave's own contract does not depend on the flag.
1324    #[tokio::test]
1325    async fn the_boot_wave_asks_for_the_restart_note_under_new_as_well() {
1326        use forge_workspace::store::sessions::SeatState;
1327
1328        let (workspace, _dir) = fixture(false);
1329        workspace.enable_test_dispatch_intercept();
1330        workspace.seed_test_ready_account("Stub");
1331        workspace.seed_test_gateway_ready(true);
1332        assert!(
1333            wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1334            "the fixture's accounts settle before the wave",
1335        );
1336        workspace
1337            .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::InTurn)
1338            .expect("seed the interrupted seat");
1339
1340        start_boot_wave(Arc::clone(&workspace), None, true);
1341
1342        let spawned = spawned_projects(&workspace).await;
1343        assert_eq!(spawned.len(), 1, "only the open project is in the wave: {spawned:?}");
1344        assert!(spawned[0].1.force_new, "--new reaches the command");
1345        assert_eq!(
1346            spawned[0].1.resume_kick.as_deref(),
1347            Some(forge_workspace::LEAD_RESTART_NOTE),
1348            "a --new wave still asks for the note; the spawn decides delivery",
1349        );
1350    }
1351
1352    /// **A project named on the command line starts whether or not it is
1353    /// closed**, because naming it is the user's own act and closing is not
1354    /// forgetting. A closed seat has no interrupted turn to report, so it
1355    /// is resumed with nothing.
1356    #[tokio::test]
1357    async fn the_boot_wave_starts_a_named_closed_project() {
1358        use forge_workspace::store::sessions::SeatState;
1359
1360        let (workspace, _dir) = fixture(false);
1361        workspace.enable_test_dispatch_intercept();
1362        workspace.seed_test_ready_account("Stub");
1363        workspace.seed_test_gateway_ready(true);
1364        assert!(
1365            wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1366            "the fixture's accounts settle before the wave",
1367        );
1368        workspace
1369            .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Closed)
1370            .expect("seed the closed seat");
1371
1372        start_boot_wave(Arc::clone(&workspace), Some("forge".to_owned()), false);
1373
1374        let spawned = spawned_projects(&workspace).await;
1375        assert_eq!(spawned.len(), 1, "the named project alone: {spawned:?}");
1376        assert_eq!(spawned[0].0, "forge", "the named project starts though it is closed");
1377        assert_eq!(
1378            spawned[0].1.resume_kick, None,
1379            "a closed seat has no interrupted turn to report",
1380        );
1381    }
1382
1383    /// **`serve_socket` returns with the fold already subscribed.** The boot
1384    /// wave runs after this returns, so a session spawned there emits its
1385    /// first `Connected` into a fan-out nobody is holding yet unless the
1386    /// subscription was taken before the serve task was spawned - and a
1387    /// dropped `Connected` is a seat whose conversation the transport never
1388    /// holds. Nothing has connected here: the subscriber is the fold's own.
1389    #[tokio::test]
1390    async fn the_socket_returns_with_the_fold_subscribed() {
1391        let (workspace, _dir) = socket_fixture();
1392        assert_eq!(workspace.test_subscriber_count(), 0, "nothing is attached before the socket");
1393
1394        let serving = super::serve_socket(&workspace).await.expect("the socket serves");
1395        let super::SocketServing::Bound = serving else {
1396            panic!("the socket binds on a port the OS handed out");
1397        };
1398        // Read before anything awaits: the claim is the order the call returns in.
1399        assert_eq!(
1400            workspace.test_subscriber_count(),
1401            1,
1402            "the fold's subscription is held when serve_socket returns",
1403        );
1404        // **And it is a stream something reads.** One subscriber taking the
1405        // frame is what tells this count from a subscription taken and then
1406        // dropped; nothing else has attached, so the reader is the fold's.
1407        assert!(
1408            workspace.emit_for_test_reported(forge_workspace::SessionUpdate::CatalogLoaded),
1409            "the fold's own stream takes the frame the boot wave would emit",
1410        );
1411        workspace.supervisor().stop("socket_serve");
1412    }
1413
1414    /// The wait ends when the watch reports, so this bound is reached when no
1415    /// report arrives within it: a stuck watch, or a delivery slower still.
1416    /// It sits under nextest's 120 s kill, so that failure names the property
1417    /// instead of dying as a bare timeout.
1418    const CONFIG_EDIT_WEDGE: Duration = Duration::from_secs(100);
1419
1420    /// A write to the watched file applies to the running workspace, with no
1421    /// restart and no call from the test: the watch is the only path.
1422    ///
1423    /// macOS ignores it locally for the same reason `config_watch`'s delivery
1424    /// tests are: fsevents stalled a report for 407 s under a full gate. The
1425    /// macOS CI job runs it with `--run-ignored ignored-only`, and Ubuntu runs
1426    /// it on inotify.
1427    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
1428    #[tokio::test]
1429    async fn a_config_edit_reaches_the_running_workspace() {
1430        let (workspace, _dir) = boot_fixture();
1431        let mut updates = workspace.subscribe_answerer();
1432        start_config_watch(&workspace, |_workspace| {});
1433
1434        let path = workspace.config_path();
1435        let raw = std::fs::read_to_string(&path).expect("read");
1436        std::fs::write(&path, raw.replace("Stub", "Renamed")).expect("write");
1437
1438        // The wait is the update stream, not a clock: fsevents schedules the
1439        // delivery, so only a stuck watch, or a delivery slower than the
1440        // bound, reaches the wedge.
1441        let applied = tokio::time::timeout(CONFIG_EDIT_WEDGE, async {
1442            loop {
1443                let frame = updates.recv().await.expect("the update stream stays open");
1444                if matches!(&*frame.update, forge_workspace::SessionUpdate::AccountsChanged { .. })
1445                {
1446                    break;
1447                }
1448            }
1449        })
1450        .await;
1451        assert!(
1452            applied.is_ok(),
1453            "the watched file's edit reaches the running workspace within {CONFIG_EDIT_WEDGE:?}",
1454        );
1455        assert_eq!(
1456            workspace.list_projects()[0].accounts,
1457            vec!["Renamed".to_owned()],
1458            "the running workspace holds the edited org pin",
1459        );
1460    }
1461
1462    /// **The config's names are the fleet's maps.** The workspace's own
1463    /// resolution and the fleet's lookup both have tests; this is the boot's
1464    /// copy between them, so a rename on either side fails here.
1465    #[cfg(target_os = "macos")]
1466    #[test]
1467    fn the_fleets_profile_names_are_the_configs() {
1468        use forge_primitives::SessionSlot;
1469
1470        let dir = tempfile::tempdir().expect("tempdir");
1471        let forge = dir.path().join("forge");
1472        std::fs::create_dir_all(&forge).expect("forge/");
1473        std::fs::write(
1474            forge.join("forge.toml"),
1475            "\
1476[[orgs]]
1477name = \"Busytools\"
1478accounts = [\"Stub\"]
1479browser_profile = \"work\"
1480
1481[[orgs.projects]]
1482name = \"forge\"
1483path = \"/tmp\"
1484
1485[[orgs.projects]]
1486name = \"apply\"
1487path = \"/tmp\"
1488browser_profile = \"job\"
1489
1490[[accounts]]
1491display_name = \"Stub\"
1492token = \"t\"
1493provider = \"openrouter\"
1494base_url = \"http://127.0.0.1:9/api\"
1495models = [\"claude-sonnet-5\"]
1496
1497[server]
1498enabled = false
1499
1500[gateway]
1501port = 49201
1502",
1503        )
1504        .expect("write forge.toml");
1505        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1506        let fleet = forge_browser::service::BrowserService::at(
1507            dir.path().join("browser"),
1508            dir.path().join("browser/stack"),
1509        );
1510
1511        configure_fleet_profiles(&workspace, &fleet);
1512
1513        let key = |project: &str| {
1514            fleet
1515                .profile_for(&SessionSlot::lead("Busytools", project))
1516                .expect("a seat resolves")
1517                .as_str()
1518                .to_owned()
1519        };
1520        assert_eq!(key("forge"), "Busytools/work", "the org's name arrives");
1521        assert_eq!(key("apply"), "Busytools/job", "the project's override arrives");
1522    }
1523
1524    /// The wave starts the 60 s usage poller. Its first tick is a minute out
1525    /// and its pass reaches the network, so the swap-once guard is what a
1526    /// test can read - a wave without it leaves the account snapshots frozen
1527    /// at their boot values and the session page's 5h/7d bars never move.
1528    ///
1529    /// The wave is driven directly here: `start_core` reaches the machine's
1530    /// single-instance lock, so the one call from it into this function is the
1531    /// boundary this test does not cover.
1532    #[tokio::test]
1533    async fn the_boot_wave_starts_the_usage_poller() {
1534        let (workspace, _dir) = boot_fixture();
1535        assert!(
1536            !workspace.test_usage_poller_started(),
1537            "the fixture starts with the poller unstarted, or this test proves nothing",
1538        );
1539
1540        start_subsystems(&workspace);
1541
1542        assert!(workspace.test_usage_poller_started(), "the boot wave starts the usage poller");
1543    }
1544
1545    /// **The prune runs after the reap.** Reversing them is invisible to
1546    /// every behavioural test that does not need the identity a prune can
1547    /// unlink, so the order itself is pinned.
1548    #[cfg(target_os = "macos")]
1549    #[tokio::test]
1550    async fn the_prune_runs_after_the_reap() {
1551        let order = Arc::new(std::sync::Mutex::new(Vec::new()));
1552        let reaped = Arc::clone(&order);
1553        let pruned = Arc::clone(&order);
1554        reap_then_prune(
1555            async move {
1556                reaped.lock().expect("the order").push("reap");
1557            },
1558            move || {
1559                pruned.lock().expect("the order").push("prune");
1560            },
1561        )
1562        .await;
1563        assert_eq!(*order.lock().expect("the order"), vec!["reap", "prune"]);
1564    }
1565
1566    /// **The wave starts the rotation announcer**: the one watcher that turns
1567    /// a cooldown write and its clock-driven thaw into a roster frame. Dropping
1568    /// the call leaves a home row's `would_bind` stale with every test green.
1569    #[tokio::test]
1570    async fn the_boot_wave_starts_the_rotation_announcer() {
1571        let (workspace, _dir) = boot_fixture();
1572        assert!(
1573            !workspace.test_rotation_announcer_started(),
1574            "the fixture starts with the announcer unstarted, or this test proves nothing",
1575        );
1576
1577        start_subsystems(&workspace);
1578
1579        assert!(
1580            workspace.test_rotation_announcer_started(),
1581            "the boot wave starts the rotation announcer",
1582        );
1583    }
1584
1585    /// **The wave starts the idle sweep**: without the call the feature is
1586    /// dark in every real boot while every unit test stays green.
1587    #[tokio::test]
1588    async fn the_boot_wave_starts_the_idle_sweep() {
1589        let (workspace, _dir) = boot_fixture();
1590        assert!(
1591            !workspace.supervisor().snapshot().iter().any(|member| member.name == "idle_sweep"),
1592            "the fixture starts with the idle sweep unstarted, or this test proves nothing",
1593        );
1594
1595        start_subsystems(&workspace);
1596
1597        assert!(
1598            workspace.supervisor().snapshot().iter().any(|member| member.name == "idle_sweep"),
1599            "the boot wave starts the idle sweep",
1600        );
1601    }
1602
1603    /// The wave starts the dictate prewarm, which is what keeps a take from
1604    /// paying the model load: started at boot rather than at the first press,
1605    /// on a task of its own so the fetch, the verify and the load are not on
1606    /// the boot path. The fixture has dictation switched off, so the guard is
1607    /// what the test reads; the prewarm's own fetch and load need the real
1608    /// weights and are exercised by forge-dictate's own gates.
1609    #[tokio::test]
1610    async fn the_boot_wave_starts_the_dictate_prewarm() {
1611        let (workspace, _dir) = boot_fixture();
1612        assert!(
1613            !workspace.test_dictate_preflight_started(),
1614            "the fixture starts with the prewarm unstarted, or this test proves nothing",
1615        );
1616
1617        start_subsystems(&workspace);
1618
1619        assert!(
1620            workspace.test_dictate_preflight_started(),
1621            "the boot wave asks for the prewarm; dropping the call leaves every take \
1622             paying the load",
1623        );
1624    }
1625
1626    /// The wave asks for the speaking engine's prewarm, which is what keeps
1627    /// the first `say` from paying the model load: started at boot rather
1628    /// than on the first listener, on a task of its own so the
1629    /// multi-gigabyte load is not on the boot path. The fixture has `[tts]`
1630    /// off, so this reads the wave-asked guard only; that the dispatch then
1631    /// loads the engine is pinned by
1632    /// `the_boot_prewarm_loads_the_engine_when_tts_is_on` in
1633    /// forge-workspace, which drives a stub load.
1634    #[tokio::test]
1635    async fn the_boot_wave_starts_the_voice_prewarm() {
1636        let (workspace, _dir) = boot_fixture();
1637        assert!(
1638            !workspace.test_voice_prewarm_started(),
1639            "the fixture starts with the prewarm unstarted, or this test proves nothing",
1640        );
1641
1642        start_subsystems(&workspace);
1643
1644        assert!(
1645            workspace.test_voice_prewarm_started(),
1646            "the boot wave asks for the prewarm; dropping the call leaves the first say \
1647             paying the load",
1648        );
1649    }
1650
1651    /// The hold gives up at its deadline rather than waiting for ever - the
1652    /// behaviour a listener that never binds would otherwise hang on. Pinned
1653    /// against the production bound's VALUE as well, so raising it is a
1654    /// change this test has to be told about.
1655    #[tokio::test]
1656    async fn the_boot_wave_hold_expires_rather_than_waiting_for_ever() {
1657        let (workspace, _dir) = crate::surface::testing::workspace();
1658        assert!(
1659            !workspace.all_accounts_loaded(),
1660            "the fixture's accounts start unsettled, or this test proves nothing",
1661        );
1662        assert_eq!(BOOT_WAVE_WAIT, Duration::from_secs(60), "the production bound");
1663
1664        let settled = wait_for_accounts(&workspace, Duration::from_millis(1)).await;
1665        assert!(!settled, "the hold gives up at its deadline instead of waiting for ever");
1666    }
1667
1668    /// And it releases the moment the accounts settle, without waiting the
1669    /// bound out.
1670    #[tokio::test]
1671    async fn the_boot_wave_hold_releases_once_the_accounts_settle() {
1672        let (workspace, _dir) = crate::surface::testing::workspace();
1673        workspace.seed_test_ready_account("Granite");
1674        workspace.seed_test_ready_account("OpenRouter-TM");
1675        assert!(workspace.all_accounts_loaded(), "both accounts terminal and the listener ready");
1676
1677        let started = std::time::Instant::now();
1678        let settled = wait_for_accounts(&workspace, Duration::from_secs(60)).await;
1679        assert!(settled, "a settled boot is released at once");
1680        assert!(
1681            started.elapsed() < Duration::from_secs(1),
1682            "and it does not wait the bound out: {:?}",
1683            started.elapsed(),
1684        );
1685    }
1686}