1use 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
24fn 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#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum SettingsSaveError {
52 Stale,
54 Invalid { message: String, key_path: Option<String> },
57 Refused { message: String, key_path: Option<String> },
60 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
81const SPAWNED_SEAT_STATE: crate::store::sessions::SeatState =
85 crate::store::sessions::SeatState::Idle;
86
87struct 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
113fn 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 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#[derive(Debug)]
226enum WriteRefusal {
227 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 if let Some(expected) = expected {
262 let current = std::fs::read_to_string(&target).map_err(WriteRefusal::Io)?;
263 if crate::config::fingerprint_of(¤t) != expected {
264 return Err(WriteRefusal::Stale);
265 }
266 }
267 std::fs::rename(&temporary, &target).map_err(WriteRefusal::Io)?;
268 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
305const CLI_VERSION_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
309
310const 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
321const AUTO_COMPACT_WINDOW_ENV: &str = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
324
325pub(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
334const WORKER_TAG_RETRY_ATTEMPTS: u32 = 30;
344
345const WORKER_TAG_RETRY_DELAY: Duration = Duration::from_millis(100);
350
351const KICK_DISPATCH_INTERVAL: Duration = Duration::from_millis(750);
362
363fn 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
375const 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
423pub(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
429pub 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#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct BootWaveTarget {
442 pub project_name: String,
443 pub resume_kick: Option<String>,
444}
445
446fn resume_kick_for(resumed: bool, requested: Option<String>) -> Option<String> {
451 if resumed { requested } else { None }
452}
453
454#[derive(Debug)]
463pub(crate) struct KickRequest {
464 pub slot: SessionSlot,
465 pub prompt_body: String,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
472#[serde(rename_all = "snake_case")]
473pub struct SessionChipInfo {
474 pub account_name: String,
476 pub state: SessionChipState,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum SessionChipState {
486 Normal,
488 AtCap,
494 Bailed,
498 Degraded,
502 RateLimited,
507}
508
509#[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
537pub(crate) type SlackDeliveryKey = (String, Option<String>, String, String, Option<String>);
544
545pub(crate) type ParkedSlackDraft = (
554 SessionSlot,
555 forge_primitives::slack::SlackDraft,
556 tokio::sync::oneshot::Sender<forge_primitives::slack::SlackDraftAnswer>,
557);
558
559pub(crate) type ParkedBrowserHandOff = (
567 SessionSlot,
568 forge_primitives::browser::HandOff,
569 tokio::sync::oneshot::Sender<forge_primitives::browser::HandOffEnding>,
570);
571
572pub(crate) type ParkedTerminalHandOff = (
576 SessionSlot,
577 forge_primitives::terminal::TerminalHandOff,
578 tokio::sync::oneshot::Sender<forge_primitives::browser::HandOffEnding>,
579);
580
581struct SeatDeath {
588 occupant: SessionId,
590 reason: String,
593 announced: bool,
595 shown: bool,
597}
598
599#[derive(Clone, Copy, Debug, PartialEq, Eq)]
602pub struct HealProgress {
603 pub attempt: u32,
604 pub next_at: SystemTime,
605}
606
607#[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#[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 claim.count = claim.count.saturating_sub(1);
641 if claim.count == 0 {
642 claims.remove(&self.path);
643 }
644 }
645}
646
647#[cfg(any(test, feature = "testing"))]
650type LiveSeatsGate = (std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>);
651
652pub struct Workspace {
658 config_dir: PathBuf,
662 pub(crate) config: parking_lot::RwLock<Arc<LoadedConfig>>,
666 settings: tokio::sync::watch::Sender<Option<Arc<forge_primitives::SettingsDocument>>>,
670 settings_version: std::sync::OnceLock<String>,
673 settings_write: parking_lot::Mutex<()>,
676 reload_generation: std::sync::atomic::AtomicU64,
681 reload_gate: std::sync::Mutex<()>,
684 pub(crate) systemone: parking_lot::RwLock<Option<Arc<forge_system_one::SystemOneClient>>>,
689 pub(crate) vault: parking_lot::RwLock<Option<Arc<crate::vault::VaultSession>>>,
695 pub(crate) voice: std::sync::OnceLock<Arc<crate::voice::VoiceService>>,
699 pub(crate) voice_preview_error: std::sync::Mutex<Option<String>>,
702 catalog: Arc<Mutex<HashMap<ProjectKey, Vec<SDKSessionInfo>>>>,
712 pub(crate) app_support: Option<PathBuf>,
718 pub(crate) pool: Mutex<HashMap<SessionSlot, PooledAgent>>,
725 #[cfg(any(test, feature = "testing"))]
731 pub(crate) test_spawn_handle: Mutex<Option<forge_agent::AgentHandle>>,
732 #[cfg(any(test, feature = "testing"))]
738 pub(crate) test_spawn_server: Mutex<Option<forge_sdk::mcp::server::McpServer>>,
739 pub(crate) accounts: Arc<forge_gateway::AccountPool>,
744 pub(crate) dictate: Arc<crate::dictate::DictateState>,
749 pub(crate) dictate_runtime: Mutex<crate::dictate::DictateRuntime>,
753 pub(crate) browser_host: std::sync::OnceLock<Arc<dyn crate::browser::BrowserHost>>,
757 live_seats_lock: std::sync::Mutex<()>,
760 live_seats_generation: std::sync::atomic::AtomicU64,
763 #[cfg(any(test, feature = "testing"))]
766 live_seats_gate: Mutex<Option<LiveSeatsGate>>,
767 pub(crate) attached_clients: std::sync::atomic::AtomicUsize,
771 pub(crate) document_handoffs:
774 parking_lot::Mutex<HashMap<uuid::Uuid, crate::documents::ParkedDocument>>,
775 pub(crate) dictate_catalogue: Mutex<crate::catalogue::CatalogueState>,
780 pub(crate) dictate_install: Mutex<crate::install::InstallState>,
784 pub(crate) plugin_cli: Mutex<crate::plugins::PluginCli>,
788 pub(crate) plugin_run: Mutex<Option<forge_primitives::plugins::PluginUpdateRun>>,
792 pub(crate) plugin_action_running: std::sync::atomic::AtomicBool,
795 pub(crate) dictate_activate: Mutex<crate::install::ActivateState>,
799 pub(crate) dictate_bench: Mutex<crate::bench::BenchState>,
802 pub(crate) dictate_bench_cancel: std::sync::atomic::AtomicBool,
803 pub(crate) speaking_bench: Mutex<crate::bench::SpeakingBenchState>,
806 pub(crate) speaking_bench_cancel: std::sync::atomic::AtomicBool,
807 pub(crate) bench_claim: std::sync::atomic::AtomicBool,
811 pub(crate) read_aloud_error: Mutex<Option<String>>,
815 #[cfg(any(test, feature = "testing"))]
819 pub(crate) test_catalogue_source: Mutex<Option<forge_dictate::catalogue::CatalogueSource>>,
820 #[cfg(any(test, feature = "testing"))]
822 pub(crate) test_cleanup_source: Mutex<Option<forge_dictate::cleanup::CleanupSource>>,
823 #[cfg(any(test, feature = "testing"))]
826 pub(crate) test_catalogue_dir: Mutex<Option<std::path::PathBuf>>,
827 #[cfg(any(test, feature = "testing"))]
830 pub(crate) test_read_aloud_dir: Mutex<Option<std::path::PathBuf>>,
831 pub(crate) update_tx: UpdateFanout,
835 #[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 session_teardowns: Mutex<HashMap<SessionSlot, tokio::sync::watch::Sender<bool>>>,
852 shutting_down: std::sync::atomic::AtomicBool,
857 live_workers: Mutex<HashMap<ProjectKey, Vec<crate::mcp::workers::types::WorkerEntry>>>,
862 pub(crate) idle_sweeps: Mutex<HashMap<SessionSlot, crate::idle_sweep::SweepClock>>,
868 worktree_claims: Arc<parking_lot::Mutex<HashMap<PathBuf, WorktreeClaim>>>,
873 despawn_cleanups: Mutex<HashSet<(ProjectKey, String)>>,
881 spawn_failures: Mutex<HashMap<SessionSlot, String>>,
889 deaths: Mutex<HashMap<SessionSlot, SeatDeath>>,
897 heal_progress: Mutex<HashMap<SessionSlot, HealProgress>>,
902 healing: std::sync::OnceLock<Arc<crate::healing::Healing>>,
906 pub(crate) held_work_seats: crate::work::HeldSeats,
909 pub(crate) domain_handles: Mutex<HashMap<SessionSlot, Arc<Mutex<DomainSession>>>>,
913 pub(crate) review_origin: Mutex<HashMap<(String, String), SessionSlot>>,
919 pub(crate) review_activity:
925 Mutex<HashMap<SessionSlot, Vec<crate::mcp::review::ReviewActivity>>>,
926 pub(crate) supervisor: Arc<forge_supervise::Supervisor>,
929 dictate_preflight_started: std::sync::atomic::AtomicBool,
935 pub(crate) voice_prewarm_started: std::sync::atomic::AtomicBool,
940 pub(crate) voice_prewarm_dispatched: std::sync::atomic::AtomicBool,
943 pub(crate) gateway: Arc<forge_gateway::forward::Gateway>,
947 gateway_ready: std::sync::atomic::AtomicBool,
950 gateway_port: u16,
952 gateway_url: Mutex<Option<String>>,
956 gateway_bind_error: Mutex<Option<String>>,
959 kick_dispatcher_tx: mpsc::UnboundedSender<KickRequest>,
965 kick_dispatcher_rx_slot: Mutex<Option<mpsc::UnboundedReceiver<KickRequest>>>,
971 _single_instance_lock: Option<std::fs::File>,
979 pub(crate) crons: Mutex<Vec<forge_primitives::CronEntry>>,
988 pub(crate) crons_load_failed: std::sync::atomic::AtomicBool,
1000 pub(crate) tasks: Mutex<Vec<forge_primitives::tasks::Task>>,
1006 pub(crate) parked_by_slot: Mutex<crate::parked::ParkedMap>,
1013 pub(crate) pending_handoffs:
1018 Mutex<std::collections::HashMap<SessionSlot, crate::handoff::PendingBrief>>,
1019 pub(crate) swapping: Mutex<std::collections::HashMap<SessionSlot, u64>>,
1023 pub(crate) swap_epochs: std::sync::atomic::AtomicU64,
1025 pub(crate) held_prompts:
1028 Mutex<std::collections::HashMap<SessionSlot, Vec<crate::handoff::HeldPrompt>>>,
1029 pub(crate) gotify_subs: Mutex<Vec<forge_primitives::GotifySubscription>>,
1035 pub(crate) db: Arc<Mutex<Option<crate::store::Db>>>,
1043 catalog_loaded: Arc<std::sync::atomic::AtomicBool>,
1047 pricing_fetcher: PricingFetcher,
1051 catalog_scan_started: std::sync::atomic::AtomicBool,
1053 cli_version: Arc<Mutex<Option<CliVersionInfo>>>,
1058 last_fatal_error: Mutex<Option<forge_primitives::error::AppError>>,
1062 service_status: Arc<Mutex<Option<ServiceIssue>>>,
1066 service_status_probe_started: std::sync::atomic::AtomicBool,
1068 pub(crate) gotify_connected: Mutex<bool>,
1074 pub(crate) gotify_app_index: Mutex<HashMap<String, u64>>,
1080 pub(crate) gotify_subsystem: Mutex<Option<GotifySubsystem>>,
1086 pub(crate) slack: Arc<crate::slack::SlackWorkspaces>,
1090 pub(crate) slack_subs: Mutex<Vec<forge_primitives::slack::SlackSubscription>>,
1095 pub(crate) slack_subsystem:
1099 Mutex<std::collections::BTreeMap<String, tokio::sync::oneshot::Sender<()>>>,
1100 pub(crate) slack_connected: Mutex<std::collections::BTreeMap<String, bool>>,
1103 pub(crate) slack_inflight:
1111 Mutex<std::collections::HashMap<(String, String), Vec<crate::slack::SlackPostStamp>>>,
1112 pub(crate) slack_user_ids: Mutex<std::collections::BTreeMap<String, String>>,
1115 pub(crate) slack_user_names: Mutex<std::collections::BTreeMap<(String, String), String>>,
1118 pub(crate) slack_author_failures: Mutex<std::collections::HashSet<(String, String)>>,
1121 pub(crate) slack_draft_user_failures: Mutex<std::collections::HashSet<(String, String)>>,
1125 pub(crate) slack_thread_failures: Mutex<std::collections::HashSet<(String, String, String)>>,
1129 pub(crate) slack_thread_full_reads:
1133 Mutex<std::collections::HashMap<(String, String, String), std::time::Instant>>,
1134 pub(crate) slack_thread_first_walks: Mutex<std::collections::HashSet<(String, String, String)>>,
1137 pub(crate) slack_thread_edit_watermarks:
1141 Mutex<std::collections::HashMap<(String, String, String), String>>,
1142 pub(crate) slack_ledger_failures: Mutex<std::collections::HashSet<(String, String)>>,
1147 pub(crate) slack_drafts: Mutex<HashMap<uuid::Uuid, ParkedSlackDraft>>,
1152 pub(crate) browser_handoffs: Mutex<HashMap<uuid::Uuid, ParkedBrowserHandOff>>,
1158 pub(crate) terminals: Mutex<HashMap<SessionSlot, Arc<crate::terminal::TerminalEntry>>>,
1163 pub(crate) terminal_handoffs: Mutex<HashMap<uuid::Uuid, ParkedTerminalHandOff>>,
1168 pub(crate) next_terminal_id: std::sync::atomic::AtomicU16,
1172 pub(crate) settings_writes:
1178 Mutex<HashMap<uuid::Uuid, crate::settings_ask::ParkedSettingsWrite>>,
1179 pub(crate) parked_vault_asks: Mutex<HashMap<uuid::Uuid, crate::vault_ask::ParkedVaultAsk>>,
1185 pending_seq: Mutex<HashMap<SessionSlot, u64>>,
1189 pub(crate) slack_recently_delivered: Mutex<HashMap<SlackDeliveryKey, std::time::Instant>>,
1195 pub(crate) slack_load_failed: std::sync::atomic::AtomicBool,
1199 pub(crate) slack_user_id_retries: Mutex<std::collections::BTreeMap<String, SystemTime>>,
1202 pub(crate) slack_verification_started: std::sync::atomic::AtomicBool,
1205 respawn_in_flight: Mutex<std::collections::HashSet<ProjectKey>>,
1215 unwakeable_crons: Mutex<std::collections::HashSet<forge_primitives::CronId>>,
1226 cooling_crons: Mutex<std::collections::HashMap<forge_primitives::CronId, SystemTime>>,
1231 #[cfg(any(test, feature = "testing"))]
1238 command_intercept: Mutex<Option<Vec<Command>>>,
1239 #[cfg(any(test, feature = "testing"))]
1245 test_extra_projects: Mutex<Vec<LoadedProject>>,
1246 #[cfg(any(test, feature = "testing"))]
1251 test_user_preferences: Mutex<Option<serde_json::Value>>,
1252 #[cfg(any(test, feature = "testing"))]
1256 test_claude_pid: Mutex<std::collections::HashMap<SessionSlot, u32>>,
1257 #[cfg(any(test, feature = "testing"))]
1261 pub(crate) test_scan_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
1262}
1263
1264pub(crate) struct PooledAgent {
1268 pub handle: Arc<AgentHandle>,
1269 pub account: AccountKey,
1271 pub permission_mode: Option<forge_primitives::permission::PermissionMode>,
1276 pub registration: Option<forge_gateway::binding::Registration>,
1280 pub session_id: String,
1285}
1286
1287#[derive(Debug)]
1291pub enum LiveWorkerRefusal {
1292 LabelLive(SessionSlot),
1294 CleanupPending,
1298 AtCap { live: usize, cap: usize },
1301}
1302
1303pub(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#[derive(Debug, Clone, PartialEq, Eq)]
1327pub(crate) enum ResumeTarget {
1328 Found {
1330 session_id: String,
1331 restamp: bool,
1335 },
1336 None,
1338}
1339
1340fn 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
1387type CliVersionProbe = std::pin::Pin<Box<dyn std::future::Future<Output = CliVersionInfo> + Send>>;
1390
1391type CliVersionProber = Box<dyn Fn() -> CliVersionProbe + Send + Sync>;
1396
1397fn real_cli_version_prober() -> CliVersionProber {
1399 Box::new(|| Box::pin(forge_agent::env::cli_version::fetch_info()))
1400}
1401
1402type ServiceStatusProbe =
1405 std::pin::Pin<Box<dyn std::future::Future<Output = Option<ServiceIssue>> + Send>>;
1406
1407type ServiceStatusProber = Box<dyn Fn() -> ServiceStatusProbe + Send + Sync>;
1411
1412fn real_service_status_prober() -> ServiceStatusProber {
1414 Box::new(|| Box::pin(forge_agent::cloud::service_status::fetch_service_status()))
1415}
1416
1417pub type PricingFetch = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
1420
1421pub type PricingFetcher = std::sync::Arc<dyn Fn() -> PricingFetch + Send + Sync>;
1425
1426fn real_pricing_fetcher() -> PricingFetcher {
1428 std::sync::Arc::new(|| Box::pin(forge_agent::env::token_usage::pricing::fetch_litellm()))
1429}
1430
1431#[cfg(any(test, feature = "testing"))]
1435fn inert_pricing_fetcher() -> PricingFetcher {
1436 std::sync::Arc::new(|| Box::pin(async { None }))
1437}
1438
1439#[cfg(any(test, feature = "testing"))]
1441fn default_pricing_fetcher() -> PricingFetcher {
1442 inert_pricing_fetcher()
1443}
1444
1445#[cfg(not(any(test, feature = "testing")))]
1447fn default_pricing_fetcher() -> PricingFetcher {
1448 real_pricing_fetcher()
1449}
1450
1451fn 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 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
1483async 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
1500async 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 interval.tick().await;
1513 let snapshot = prober().await;
1514 store_cli_version(&cli_version, &update_tx, snapshot);
1515 }
1516}
1517
1518fn 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 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
1545fn 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
1556async 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 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, None, 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 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 {
1608 let mut current = catalog.lock();
1609 for (key, entries) in current.drain() {
1610 let slot = grouped.entry(key).or_default();
1611 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
1642fn 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
1678fn store_path(app_support: &Path, config_dir: &Path) -> PathBuf {
1684 crate::store::path_for(app_support, config_dir)
1685}
1686
1687fn 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
1702enum Adoption {
1704 Settled,
1707 Deferred,
1712}
1713
1714fn 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
1738fn adopt_legacy_store_from(legacy: &Path, path: &Path) -> Adoption {
1740 match crate::store::Db::open(legacy) {
1741 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
1782fn open_db(app_support: &Path, config_dir: &Path) -> Option<crate::store::Db> {
1792 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
1837fn 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
1854fn 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
1870fn 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
1903type ForgeAnswer = (NoticeSeverity, String);
1911
1912fn forge_misuse(usage: &str) -> ForgeAnswer {
1917 (NoticeSeverity::Error, usage.to_owned())
1918}
1919
1920fn 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 #[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 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 #[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 #[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 #[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 workspace.gateway_ready.store(true, std::sync::atomic::Ordering::Release);
2106 Ok(workspace)
2107 }
2108
2109 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 pub fn catalog_ready(&self) -> bool {
2135 self.catalog_loaded.load(std::sync::atomic::Ordering::Acquire)
2136 }
2137
2138 pub fn supervisor(&self) -> &Arc<forge_supervise::Supervisor> {
2141 &self.supervisor
2142 }
2143
2144 fn start_cli_version_probe(&self, prober: CliVersionProber) {
2150 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 pub fn cli_version(&self) -> Option<CliVersionInfo> {
2180 self.cli_version.lock().clone()
2181 }
2182
2183 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 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 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 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 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 let mut db = app_support.as_deref().and_then(|base| open_db(base, &config_dir));
2264
2265 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 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 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 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 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 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 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 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 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 let catalog = Arc::new(Mutex::new(HashMap::new()));
2522
2523 let accounts = Arc::new(forge_gateway::AccountPool::new(&config.accounts));
2524 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 gateway.set_org_pins(org_pins(&config));
2542 gateway.set_rotation_numbers(config.gateway_rotation);
2543
2544 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 let cli_version = Arc::new(Mutex::new(None));
2577 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 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 pub fn server_config(&self) -> forge_primitives::ServerConfig {
2741 self.config_arc().server.clone()
2742 }
2743
2744 pub(crate) fn config_arc(&self) -> Arc<LoadedConfig> {
2748 Arc::clone(&self.config.read())
2749 }
2750
2751 fn resolution_held(generation: u64, settled: u64) -> bool {
2755 generation.is_multiple_of(2) && generation == settled
2756 }
2757
2758 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 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 {
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 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 self.publish_settings();
2843
2844 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 if reload.moved("accounts.") {
2859 self.announce_accounts_changed();
2860 }
2861 Ok(reload)
2862 }
2863
2864 pub fn client_config(&self) -> forge_primitives::ClientConfig {
2868 self.config_arc().client.clone()
2869 }
2870
2871 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 pub fn subscribe_settings(
2884 &self,
2885 ) -> tokio::sync::watch::Receiver<Option<Arc<forge_primitives::SettingsDocument>>> {
2886 self.settings.subscribe()
2887 }
2888
2889 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 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 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 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(¤t) != 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 let (departed_projects, departed_accounts) =
2959 departures_from_edits(&config, edits, &document);
2960 self.refuse_departures(&config, &departed_projects, &departed_accounts)?;
2961
2962 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(¤t, 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 for edit in edits {
3002 let Ok(one) = crate::settings_write::apply(
3003 ¤t,
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 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 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 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 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 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 pub fn browser_settings(&self) -> crate::BrowserSettings {
3144 self.config_arc().browser
3145 }
3146
3147 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 pub fn document_max_bytes(&self) -> u64 {
3176 self.config_arc().document_max_bytes
3177 }
3178
3179 pub fn note_client_attached(&self) {
3182 self.attached_clients.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3183 }
3184
3185 pub fn note_client_detached(&self) {
3187 self.attached_clients.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
3188 }
3189
3190 pub(crate) fn client_attached(&self) -> bool {
3193 self.attached_clients.load(std::sync::atomic::Ordering::Relaxed) > 0
3194 }
3195
3196 pub fn install_browser_host(&self, host: Arc<dyn crate::browser::BrowserHost>) {
3200 let _ = self.browser_host.set(host);
3201 }
3202
3203 pub fn browser_host(&self) -> Option<Arc<dyn crate::browser::BrowserHost>> {
3205 self.browser_host.get().cloned()
3206 }
3207
3208 pub fn dictate_bind(&self) -> crate::dictate::DictateBind {
3212 self.config_arc().dictate.bind
3213 }
3214
3215 pub fn dictate_mode(&self) -> crate::dictate::DictateMode {
3219 self.config_arc().dictate.mode
3220 }
3221
3222 pub fn dictate_enabled(&self) -> bool {
3227 self.config_arc().dictate.enabled
3228 }
3229
3230 pub fn plugin_settings(&self) -> crate::config::PluginSettings {
3234 self.config_arc().plugins.clone()
3235 }
3236
3237 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 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 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 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 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 targets.push(BootWaveTarget { project_name: name.to_owned(), resume_kick: None });
3326 }
3327 targets
3328 }
3329
3330 pub fn list_projects(&self) -> Vec<ProjectView> {
3335 let open_sessions: std::collections::HashSet<String> =
3339 self.pool.lock().values().map(|entry| entry.session_id.clone()).collect();
3340
3341 let catalog = self.catalog.lock();
3348 #[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 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 pub fn validate_project_name(&self, name: &str) -> Result<(), WorkspaceError> {
3418 self.find_project_by_name(name).map(|_| ())
3419 }
3420
3421 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 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 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 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 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 crate::spawn::apply_blocked_tools(&mut settings, spawn_kind, true);
3514 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 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 let session_slot = match resolved_key {
3540 Some(slot) => slot,
3541 None => self.resolve_slot(&target)?,
3542 };
3543 let stored = self.stored_resume_id(&session_slot, settings.force_new)?;
3548 let resuming = stored.is_some();
3549 let account_dir = self.config_dir.clone();
3553
3554 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 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 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 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 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 let mut rollback = SpawnRollback {
3643 workspace: Arc::clone(self),
3644 slot: session_slot.clone(),
3645 stored: stored.clone(),
3646 prior_seat_state: self.stored_seat_state(&session_slot),
3649 domain_created: false,
3650 armed: true,
3651 };
3652 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 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 let merged_env = session_env_for(&project, &account_env);
3680 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 ®istration {
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 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 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 apply_project_model(Some(&project), &mut settings);
3726
3727 let (domain_arc, domain_created) = {
3735 let mut handles = self.domain_handles.lock();
3736 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 domain_arc.lock().spawned_force_new = settings.force_new;
3756 domain_arc.lock().pending_lead_resume_note =
3760 resume_kick_for(resuming, settings.resume_kick.take());
3761 domain_arc.lock().configured_effort =
3764 crate::domain_session::configured_effort_from_settings(&settings);
3765 domain_arc.lock().configured_permission_mode =
3769 crate::domain_session::configured_permission_mode_from_settings(&settings);
3770 domain_arc.lock().spawn_wrote_row =
3774 matches!(role, crate::protocol::SpawnRole::Worker { wrote_row: true, .. });
3775
3776 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 *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 #[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 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 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 let arc = Arc::new(handle);
3911
3912 let (cmd_rx, teardown_done, replaced_sender) = {
3926 let _settings = self.settings_write.lock();
3927 let held = self.config_arc();
3928 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 return Err(WorkspaceError::SpawnResolvesToNoProject {
3955 slot: session_slot.display(),
3956 }
3957 .into());
3958 }
3959 let mut pool = self.pool.lock();
3960 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 if self.pooled_handle_has_task(&session_slot) {
3987 rollback.armed = false;
3988 return Ok(Arc::clone(&existing.handle));
3989 }
3990 }
3991 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 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.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 connected_once: false,
4051 workspace: Arc::downgrade(self),
4052 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 pub(crate) fn account_pool(&self) -> &forge_gateway::AccountPool {
4073 &self.accounts
4074 }
4075
4076 pub fn gateway_bind_error(&self) -> Option<String> {
4080 self.gateway_bind_error.lock().clone()
4081 }
4082
4083 pub fn gateway_ready(&self) -> bool {
4086 self.gateway_ready.load(std::sync::atomic::Ordering::Acquire)
4087 }
4088
4089 pub fn gateway_port(&self) -> u16 {
4091 self.gateway_port
4092 }
4093
4094 fn gateway_listener_url(&self) -> String {
4099 match self.gateway_url.lock().clone() {
4100 Some(url) => url,
4101 None => format!("http://127.0.0.1:{}", self.gateway_port),
4104 }
4105 }
4106
4107 pub fn all_accounts_loaded(&self) -> bool {
4116 self.accounts.all_loaded() && self.gateway_ready.load(std::sync::atomic::Ordering::Acquire)
4117 }
4118
4119 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 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 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 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 pub fn account_auth_for(&self, display_name: &str) -> Option<crate::views::AccountAuth> {
4213 self.accounts.auth(display_name)
4214 }
4215
4216 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 pub fn config_path(&self) -> PathBuf {
4233 crate::config::forge_data_dir(&self.config_dir).join("forge.toml")
4234 }
4235
4236 pub fn config_dir(&self) -> &Path {
4240 &self.config_dir
4241 }
4242
4243 pub fn dictate_snapshot(&self) -> crate::dictate::DictateSnapshot {
4247 self.dictate.snapshot.lock().clone()
4248 }
4249
4250 pub fn dictate_models_dir(&self) -> Option<PathBuf> {
4253 self.config_arc().dictate.models_dir()
4254 }
4255
4256 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 let _ = take.stop.try_send(false);
4282 return true;
4283 }
4284 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 pub fn dictate_axes(&self) -> crate::dictate::DictateAxes {
4300 self.config_arc().dictate.axes()
4301 }
4302
4303 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 pub fn cancel_dictate_preflight(&self) {
4326 self.dictate.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
4327 }
4328
4329 pub fn start_dictate_preflight(self: &Arc<Self>) {
4344 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 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 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 this.record_resolved_installs(&resolved);
4398 let _ = updates.send(SessionUpdate::DictateAvailability);
4399 } else if let Some(failure) = &state.snapshot.lock().failure {
4400 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 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 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 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 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 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; };
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(workspace);
4606 tokio::time::sleep(KICK_DISPATCH_INTERVAL).await;
4607 }
4608 }
4609 .instrument(span),
4610 );
4611 }
4612
4613 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 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 pub fn start_rotation_announcer(self: &Arc<Self>) {
4680 let weak = Arc::downgrade(self);
4681 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 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 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 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 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; };
4766 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 pub async fn refresh_account_usage_once(self: &Arc<Self>) {
4793 let entries = self.accounts.probe_entries();
4802
4803 let due: Vec<AccountKey> = entries.iter().map(|(key, _, _)| key.clone()).collect();
4810 self.accounts.disarm_backoff_overrides(&due);
4811 let mut any_success = false;
4818 for (key, provider, env) in entries {
4819 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 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 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 if let Some(snapshot) = err.rate_limited_snapshot() {
4864 self.accounts.set_limit_reading(&key, snapshot.clone());
4865 }
4866 self.announce_accounts_changed();
4867 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 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 if any_success {
4922 let snapshots = self.accounts.snapshots_for_cache();
4923 let account_count = snapshots.len();
4924 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 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 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 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 pub fn usage_for(&self, display_name: &str) -> Option<forge_primitives::usage::UsageSnapshot> {
5001 self.accounts.latest_reading(display_name)
5002 }
5003
5004 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 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 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 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 ®istration,
5073 &replaced,
5074 &self.gateway_listener_url(),
5075 );
5076 }
5077
5078 pub fn usage_error_for(&self, display_name: &str) -> Option<forge_gateway::UsageFetchStatus> {
5084 self.accounts.usage_error(display_name)
5085 }
5086
5087 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(crate) fn session_is_pooled(&self, slot: &SessionSlot) -> bool {
5455 self.pool.lock().contains_key(slot)
5456 }
5457
5458 fn pooled_handle_has_task(&self, slot: &SessionSlot) -> bool {
5463 self.session_teardown(slot).is_some()
5464 }
5465
5466 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 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 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 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 const RESUME_CANDIDATES: usize = 50;
5546
5547 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 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 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 async fn scan_sessions(
5589 &self,
5590 cwd: &str,
5591 workers: forge_agent::userdata::catalog::scan::Workers,
5592 ) -> Vec<SDKSessionInfo> {
5593 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 pub(crate) fn fresh_session_id_for(&self, slot: &SessionSlot) -> String {
5610 self.record_spawn_id(slot, None, false)
5613 }
5614
5615 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 return Ok(None);
5634 };
5635 Ok(crate::store::sessions::get(db, org, project, label)?.and_then(|row| row.session_id))
5636 }
5637
5638 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 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 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 pub(crate) fn update_tx(&self) -> &UpdateFanout {
5695 &self.update_tx
5696 }
5697
5698 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 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 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 pub fn subscribe_mirror(&self) -> mpsc::UnboundedReceiver<SeatUpdate> {
5795 self.update_tx.subscribe(SubscriberRole::Observing)
5796 }
5797
5798 pub fn subscribe_answerer(&self) -> mpsc::UnboundedReceiver<SeatUpdate> {
5804 self.update_tx.subscribe(SubscriberRole::Answering)
5805 }
5806
5807 pub(crate) fn update_sender(&self) -> UpdateFanout {
5812 self.update_tx.clone()
5813 }
5814
5815 pub fn seat_next_seq(&self, key: &SessionSlot) -> u64 {
5819 self.update_tx.next_seq(key)
5820 }
5821
5822 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 pub fn seat_oldest_kept_seq(&self, key: &SessionSlot) -> Option<u64> {
5833 self.update_tx.oldest_kept(key)
5834 }
5835
5836 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 pub fn seat_epoch_opened_with_rows(&self, key: &SessionSlot) -> bool {
5848 self.update_tx.epoch_opened_with_rows(key)
5849 }
5850
5851 pub fn domain_session_for(&self, key: &SessionSlot) -> Option<Arc<Mutex<DomainSession>>> {
5862 self.domain_handles.lock().get(key).cloned()
5863 }
5864
5865 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 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 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 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 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 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 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 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 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 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 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 let Some(pending) = guard.auto_continue.take() else {
6040 return;
6041 };
6042 guard.auto_continue_spent = true;
6043 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 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 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 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 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 let mut frame = Message::display_only_user(text.clone(), uuid.clone());
6140 frame.stamp_if_absent(&crate::install::rfc3339_now());
6141 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 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 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 pub fn dispatch(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6218 self.dispatch_answered(cmd)
6219 }
6220
6221 pub fn dispatch_from_view(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6231 self.dispatch_answered(cmd)
6232 }
6233
6234 fn dispatch_answered(self: &Arc<Self>, cmd: Command) -> Result<(), DispatchError> {
6245 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 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 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 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 let command = match prompt {
6327 ForgePrompt::NewSession => {
6328 let (cwd, launch_settings) = self.spawn_inputs(key);
6329 self.replace_browser_seat(key);
6332 Command::NewSession { key: key.clone(), cwd, launch_settings, session_id: None }
6333 }
6334 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 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 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 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 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 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 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 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 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 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 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 from_boot_respawn: true,
6513 return_to: Some(return_to),
6514 });
6515 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 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 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 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 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 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 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 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 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 pub(crate) fn route(self: &Arc<Self>, mut cmd: Command) -> Result<(), DispatchError> {
6664 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 #[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 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 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 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 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 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 if matches!(&cmd, Command::NewSession { .. } | Command::ResumeSession { .. }) {
6853 self.abandon_terminal_hand_offs(&key);
6854 }
6855 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 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 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 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 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 if committed {
6949 self.record_seat_state(&key, crate::store::sessions::SeatState::InTurn);
6950 }
6951 if let Command::PromptUnder { source: crate::protocol::PromptSource::You, .. } =
6957 &cmd
6958 {
6959 self.spend_death(&key);
6960 }
6961 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 Ok(Some(task)) => {
7669 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 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 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 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 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 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 Command::VaultLock => {
7896 self.lock_vault();
7897 }
7898 Command::VaultEnrolmentMaterial { respond } => {
7899 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 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 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 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 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 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 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 resume_kick: None,
8040 mcp_families: worker.mcp_families.clone(),
8043 interactive: worker.interactive.unwrap_or(false),
8044 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 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 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 fn try_claim_respawn(&self, project_key: &crate::target::ProjectKey) -> bool {
8125 self.respawn_in_flight.lock().insert(project_key.clone())
8126 }
8127
8128 pub(crate) fn cron_unwakeable_is_new(&self, id: &forge_primitives::CronId) -> bool {
8132 self.unwakeable_crons.lock().insert(id.clone())
8133 }
8134
8135 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 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 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 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 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 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 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 fn projects_composing(&self, run_dir: &std::path::Path, label: &str) -> Vec<(String, String)> {
8264 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 fn release_respawn(&self, project_key: &crate::target::ProjectKey) {
8300 self.respawn_in_flight.lock().remove(project_key);
8301 }
8302
8303 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 pub async fn shutdown(&self) -> Vec<SessionSlot> {
8347 self.shutdown_within(crate::spawn::TEARDOWN_WAIT).await
8348 }
8349
8350 pub(crate) async fn shutdown_within(&self, wait: std::time::Duration) -> Vec<SessionSlot> {
8354 self.shutting_down.store(true, std::sync::atomic::Ordering::Release);
8357 self.stop_gotify_subsystem();
8360 crate::dictate::teardown_all(self);
8364 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 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 let _ = tokio::time::timeout(remaining, async {
8391 for (_, rx) in &mut receivers {
8392 wait_for_teardown(rx).await;
8393 }
8394 })
8395 .await;
8396 let unready = unready_slots(&receivers);
8399 if !unready.is_empty() {
8400 warn_shutdown_expired(&unready, wait);
8401 return unready;
8402 }
8403 }
8406 }
8407
8408 pub fn release_session_with_cascade(self: &Arc<Self>, session_key: &SessionSlot) {
8431 if session_key.is_lead() {
8437 self.record_seat_state(session_key, crate::store::sessions::SeatState::Closed);
8438 }
8439 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 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 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 self.clear_spawn_failure(&entry.slot);
8471 self.spend_death(&entry.slot);
8472 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 self.release_session(&entry.slot);
8486 }
8487 }
8488 self.release_session(session_key);
8489 }
8490
8491 pub(crate) fn announce_release(self: &Arc<Self>, key: &SessionSlot) {
8505 self.clear_spawn_failure(key);
8506 self.spend_death(key);
8507 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 pub(crate) fn release_session(self: &Arc<Self>, session_key: &SessionSlot) {
8530 self.release_browser_seat(session_key);
8535 self.close_terminal(session_key);
8539 crate::dictate::teardown_for_closed_session(self, session_key);
8540 self.abandon_settings_writes(session_key);
8543 self.abandon_vault_asks(session_key);
8546 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 }
8561
8562 pub(crate) fn session_is_live(&self, key: &SessionSlot) -> bool {
8565 self.command_senders.lock().contains_key(key)
8566 }
8567
8568 pub fn is_shutting_down(&self) -> bool {
8570 self.shutting_down.load(std::sync::atomic::Ordering::Acquire)
8571 }
8572
8573 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 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 if *tx.borrow() {
8611 return None;
8612 }
8613 Some(tx.subscribe())
8614 }
8615
8616 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 }
8644
8645 pub fn live_seats(&self) -> Option<crate::browser::LiveSeats> {
8670 use std::collections::BTreeSet;
8671
8672 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 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 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 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 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 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 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 pub fn pending_interaction_depth(&self, slot: &SessionSlot) -> usize {
8809 self.pending_ask_ledger(slot).iter().map(|(_, held)| held).sum()
8810 }
8811
8812 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 (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 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 pub fn pending_request_seq(&self, slot: &SessionSlot) -> u64 {
8875 self.pending_seq.lock().get(slot).copied().unwrap_or(0)
8876 }
8877
8878 pub(crate) fn record_fatal_error(&self, error: forge_primitives::error::AppError) {
8881 *self.last_fatal_error.lock() = Some(error);
8882 }
8883
8884 pub fn last_fatal_error(&self) -> Option<forge_primitives::error::AppError> {
8890 self.last_fatal_error.lock().clone()
8891 }
8892
8893 pub fn service_status(&self) -> Option<ServiceIssue> {
8905 self.service_status.lock().clone()
8906 }
8907
8908 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 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 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 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 pub fn spawn_failure(&self, slot: &SessionSlot) -> Option<String> {
9004 self.spawn_failures.lock().get(slot).cloned()
9005 }
9006
9007 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 pub(crate) fn clear_spawn_failure(&self, slot: &SessionSlot) {
9019 let _ = self.spawn_failures.lock().remove(slot);
9020 }
9021
9022 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 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 pub fn death_stands(&self, slot: &SessionSlot) -> bool {
9062 self.deaths.lock().get(slot).is_some_and(|death| !death.shown)
9063 }
9064
9065 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 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 pub fn clear_heal_progress(&self, slot: &SessionSlot) {
9081 let _ = self.heal_progress.lock().remove(slot);
9082 }
9083
9084 pub fn heal_progress(&self, slot: &SessionSlot) -> Option<HealProgress> {
9087 self.heal_progress.lock().get(slot).copied()
9088 }
9089
9090 pub fn heal_attempts(&self) -> Option<u32> {
9093 u32::try_from(self.config_arc().sessions.heal_attempts).ok()
9094 }
9095
9096 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 pub(crate) fn note_seat_death(self: &Arc<Self>, slot: &SessionSlot, diagnostic: &str) {
9105 self.healing().note_death(slot, diagnostic);
9106 }
9107
9108 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 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 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 pub fn session_activity(&self, slot: &SessionSlot) -> forge_primitives::SessionLifecycleState {
9171 use forge_primitives::SessionLifecycleState as L;
9172
9173 if self.heal_progress(slot).is_some() {
9177 return L::Restarting;
9178 }
9179 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 if !guard.turn_in_flight() {
9200 return L::Idle;
9201 }
9202 matches!(
9210 guard.runtime_state,
9211 Some(forge_primitives::RuntimeSessionState::RequiresAction)
9212 )
9213 };
9214 if requires_action || self.pending_interaction(slot).is_some() {
9223 L::Attention
9224 } else {
9225 L::Running
9226 }
9227 }
9228
9229 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 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 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 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 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 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 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 seat_state: Some(crate::store::sessions::SeatState::Idle),
9366 is_git_repo: Some(tree.is_git_repo),
9367 cwd: tree.cwd,
9371 },
9372 )
9373 }
9374
9375 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 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 pub(crate) fn delete_worker_row(
9430 &self,
9431 project_key: &ProjectKey,
9432 label: &str,
9433 ) -> anyhow::Result<bool> {
9434 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 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 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 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 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 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 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 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 seat_state: None,
9638 is_git_repo: None,
9641 mcp_families,
9644 cwd: None,
9647 },
9648 )
9649 }
9650
9651 pub fn scan_usage(&self) -> forge_primitives::token_usage::UsageReport {
9658 use forge_agent::env::{timezone, token_usage};
9659 use time_tz::OffsetDateTimeExt;
9660 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for view in self.list_projects() {
10225 if view.key == *own {
10226 continue;
10227 }
10228 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 continue;
10255 }
10256 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 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 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 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 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 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 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 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 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 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 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 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 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 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 return cwd_raw.to_path_buf();
10611 }
10612 let Some(root) = self.live_worker_root(&project_key, cwd.as_deref()) else {
10613 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 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 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 self.expire_parked_for_slot(
10687 &entry.slot,
10688 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
10689 );
10690 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 self.remove_worker_by_session_key(&entry.slot);
10704 self.clear_spawn_failure(session_key);
10707 self.release_session(session_key);
10711 let _ = self.delete_worker_row(&project_key, &entry.label);
10721 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 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 let _ = self.update_tx.send(SessionUpdate::WorkerStatusChanged {
10768 project_key,
10769 action: crate::protocol::WorkerStatusAction::Removed,
10770 status: entry.to_status(),
10771 worktree: crate::protocol::WorktreeDisposition::Absent,
10775 });
10776 } else {
10777 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 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 self.release_session(slot);
10828 true
10829 }
10830
10831 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let domain = self.domain_handles.lock().get(key).cloned()?;
11353 domain.lock().conn.clone()
11354 }
11355
11356 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
11375fn 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
11392pub(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 | OauthUsageError::ScopeInsufficient
11421 | OauthUsageError::Decode(_) => UsageFetchStatus::Other,
11422 },
11423 }
11424}
11425
11426impl Workspace {
11427 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 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 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 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 let uuid = forge_sdk::request_id::next_prompt_id();
11493 crate::spawn::push_peer_user_turn_into_chat(self, sender, ¬ice, &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#[derive(Clone, Copy)]
11514pub(crate) struct WorkerTagRetryBudget {
11515 max_attempts: u32,
11516 delay: Duration,
11517}
11518
11519#[derive(Clone, Copy)]
11523pub(crate) struct WorkerTagFacts {
11524 needs_tag: bool,
11525 minted_row: bool,
11526}
11527
11528#[derive(Clone, Copy)]
11534enum TagWriteResult {
11535 Succeeded,
11537 DeferredNotFound,
11540}
11541
11542fn 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 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
11574pub(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 if workspace.heal_progress(&entry.slot).is_some() {
11609 return None;
11610 }
11611 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 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
11646async 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
11696fn session_env_for(
11700 project: &LoadedProject,
11701 account_env: &std::collections::HashMap<String, String>,
11702) -> std::collections::HashMap<String, String> {
11703 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
11715fn 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
11727fn 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
11740fn 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#[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#[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
11831async 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
11840fn 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
11847fn 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 #[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 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 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 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 let _ = ws.scan_usage();
12952
12953 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 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 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 #[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 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 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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 *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 #[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 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 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 #[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 #[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 #[test]
14418 fn record_worker_row_errors_when_store_unavailable() {
14419 let (ws, _rx) = Workspace::testing_stub();
14420 ws.seed_test_project("forge", "/tmp/row-no-store");
14424 let project = ws.project_key_for_name("forge").expect("seeded project");
14425 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 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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 ®istration,
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 #[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 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 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 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 assert_eq!(workspace.pool.lock().len(), 1);
16149
16150 workspace.shutdown().await;
16154
16155 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 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 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 assert_eq!(bound[0], "Stargate");
17011 }
17012
17013 #[tokio::test]
17014 async fn project_account_pin_excludes_unpinned_account() {
17015 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 let command_rx = install_fake_session_task(&workspace, &key);
17969 assert!(workspace.pool.lock().contains_key(&key), "precondition: session pooled");
17970
17971 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 #[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 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 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 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 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 #[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 #[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 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 #[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 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 assert_eq!(
18215 classify_oauth_usage_error(&fetch(OauthUsageError::UaProbe("no binary".to_owned()))),
18216 UsageFetchStatus::Other,
18217 );
18218 assert_eq!(
18221 classify_oauth_usage_error(&fetch(OauthUsageError::ScopeInsufficient)),
18222 UsageFetchStatus::Other,
18223 );
18224 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 #[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 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 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 #[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 #[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 let overtook = done_rx.recv_timeout(std::time::Duration::from_millis(500)).is_ok();
18410
18411 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 macro_rules! ask_census {
18827 ($($kind:ident => $variant:ident),* $(,)?) => {
18828 ask_census!(@match; [] $($kind $variant,)*);
18829
18830 const ASK_KINDS: &[PendingInteractionKind] = &[ $( PendingInteractionKind::$kind, )* ];
18833 };
18834 (@match; [$($arms:tt)*]) => {
18835 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 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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 assert_eq!(ws.worker_activity(&entry("w-gone", WorkerLiveness::Running)), L::Sleeping);
19550
19551 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 #[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 assert!(
20185 elapsed < Duration::from_secs(1),
20186 "non-NotFound errors must skip retry: {elapsed:?}"
20187 );
20188 }
20189
20190 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 #[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 let _path = jsonl_path_for(cfg.path(), cwd.path(), session_id);
20259
20260 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 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 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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[tokio::test]
20592 async fn jsonl_present_clears_needs_tag() {
20593 let (workspace, mut rx) = Workspace::testing_stub();
20594 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 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 #[tokio::test]
20649 async fn opportunistic_retry_clears_flag_when_jsonl_appears() {
20650 let (workspace, mut rx) = Workspace::testing_stub();
20651 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 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 #[tokio::test]
20712 async fn worker_tag_re_applied_after_new_session_rekey() {
20713 let (workspace, mut rx) = Workspace::testing_stub();
20714 workspace.seed_test_project("forge", "/tmp/forge");
20717 let project_key = workspace.project_key_for_name("forge").expect("seeded project");
20718
20719 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 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 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 {
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 let path_2 = jsonl_path_for(cfg.path(), cwd.path(), session_id_2);
20777 fs::write(&path_2, "").expect("seed second jsonl");
20778
20779 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 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 #[tokio::test]
20839 async fn git_repo_worker_tag_lands_under_worktree_path() {
20840 let (workspace, mut rx) = Workspace::testing_stub();
20841 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 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 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 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 #[tokio::test]
20911 async fn git_repo_worker_opportunistic_retry_uses_worktree_path() {
20912 let (workspace, mut rx) = Workspace::testing_stub();
20913 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 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 fn lead_slot() -> SessionSlot {
20978 SessionSlot::lead("TestOrg", "demo")
20979 }
20980
20981 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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, );
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 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 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 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 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 (&proj_x, "stranded", true),
21348 (&proj_x, "present", true),
21350 (&proj_x, "rooted", false),
21353 (&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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 const TRANSCRIPT_ONLY_ID: &str = "660e8400-e29b-41d4-a716-4466554400aa";
21875
21876 const TRANSCRIPT_OLDER_SECS: u64 = 1_700_000_000;
21880 const TRANSCRIPT_NEWER_SECS: u64 = 1_700_000_001;
21881
21882 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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 assert_eq!(mcp_families, None, "no row means no narrowing");
23684 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 #[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 write_tagged_transcript(
23724 &cfg,
23725 &project_path,
23726 "550e8400-e29b-41d4-a716-4466554400ff",
23727 "lead",
23728 );
23729 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 #[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 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 #[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 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 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 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 #[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 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 #[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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 #[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 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 #[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 workspace
24606 .insert_live_worker(&project_key, fake_worker("reviewer", worker_key, "lead", true));
24607
24608 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 let _ = update_rx.try_recv().expect("first transition emitted event");
24625
24626 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 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 #[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 #[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 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_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 #[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 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 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 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 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 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 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 #[test]
24919 fn resume_cwd_for_slot_returns_worktree_for_git_worker_with_no_catalog_cwd() {
24920 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 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 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 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 #[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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 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 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 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 #[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 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 use super::*;
26516 use crate::protocol::Command;
26517 use std::time::Duration;
26518
26519 #[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 fn sk(name: &str) -> SessionSlot {
26561 SessionSlot::from_str_for_test(format!("kick-test-{name}"))
26562 }
26563
26564 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 #[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 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 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 #[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 tokio::task::yield_now().await;
26634 tokio::task::yield_now().await;
26635 assert_dispatched_kick_keys(&workspace, &keys[0..1], "boot tick");
26636
26637 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 #[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(); 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 #[tokio::test]
26689 async fn enqueue_kick_without_dispatcher_started_does_not_panic() {
26690 let (workspace, _update_rx) = Workspace::testing_stub();
26691 workspace.enqueue_kick(KickRequest { slot: sk("orphan"), prompt_body: "k".into() });
26693 let _ = Duration::from_millis(0); }
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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
27711 fn effort_writes_the_launch_document_and_answers() {
27712 let (ws, mut rx, seat) = a_fleet();
27713 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 #[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 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 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 #[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 {
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 #[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 #[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 #[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 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 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 #[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 #[test]
28042 fn a_spawn_without_settings_gets_them_from_the_documents() {
28043 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
28212 fn a_refused_prompt_emits_no_frame() {
28213 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 #[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}