From 092a7aa842e83e91f0e025a90ccfb4175bd5db6a Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 09:22:20 +0200 Subject: [PATCH 01/75] Initial support for using Apple containers for isolation on MacOS --- Cargo.lock | 1 + README.md | 24 + config.toml | 12 +- lib/Cargo.toml | 1 + lib/src/lib.rs | 33 +- lib/src/manager.rs | 13 +- lib/src/services/combined.rs | 544 +++++------------- lib/src/services/config.rs | 139 ++++- lib/src/services/mod.rs | 5 +- lib/src/services/opencode_client_service.rs | 154 +++-- lib/src/services/resource_usage_service.rs | 105 ++-- lib/src/services/root_session_service.rs | 99 ++-- lib/src/services/transient_storage.rs | 146 ++++- lib/src/services/usage_aggregation_service.rs | 31 +- remote/src/orchestration.rs | 13 +- remote/tests/docker_remote_integration.rs | 94 ++- tui/src/app.rs | 10 +- tui/src/ops.rs | 66 +++ tui/src/tests.rs | 56 +- 19 files changed, 934 insertions(+), 612 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f47cf5..bb8f305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,6 +1753,7 @@ dependencies = [ name = "multicode-lib" version = "0.1.0" dependencies = [ + "base64", "diesel", "diesel_migrations", "libsqlite3-sys", diff --git a/README.md b/README.md index 1650e66..064b936 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,30 @@ Isolation is implemented using `systemd-run` (for resource constraints) and [`bwrap`](https://github.com/containers/bubblewrap) (for read/write isolation). These tools are **Linux only**, so *multicode* will not work on other operating systems. +On newer Apple Silicon Macs, there is also an experimental Apple `container` runtime backend. It +reuses the existing `[isolation]` configuration for readable, writable, isolated, and `tmpfs` +paths, and maps CPU / memory limits onto container allocation settings: + +```toml +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] +readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] +isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] +memory-max = "16 GiB" +cpu = "300%" +``` + +Mounting `~/.config/opencode` read-only lets the container see the same profiles, models, +skills, and other OpenCode configuration as the host. This is useful if you manage local +profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` +isolated so session state remains per-workspace. + ## Git / GitHub integration With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR diff --git a/config.toml b/config.toml index e5d3792..b51cd91 100644 --- a/config.toml +++ b/config.toml @@ -2,6 +2,11 @@ workspace-directory = "~/dev/agent-work" opencode = ["opencode-cli", "opencode"] # todo: find a solution that isn't bound to TUI lifecycle +[runtime] +backend = "apple-container" +# Local Apple container image. It should contain Java 25, git, gh, and opencode. +image = "multicode-java25:latest" + [github] #token = {command = "gh auth token"} token = {env = "GITHUB_MCP_TOKEN"} @@ -30,6 +35,7 @@ isolated = [ "~/.local/state/opencode", ] readable = [ + "~/.config/opencode", "~/.local/share/opencode/auth.json", ] tmpfs = [ @@ -38,8 +44,8 @@ tmpfs = [ ] inherit-env = [ "XDG_RUNTIME_DIR", - "DISPLAY", "HOME", + "PATH", "LANG", "TERM", "COLORTERM", @@ -51,9 +57,9 @@ cpu = "300%" [handler] -review = "/usr/bin/smerge ." +review = "/usr/bin/open ." review-pty = false -web = "/usr/bin/firefox {}" +web = "/usr/bin/open {}" [[tool]] type = "exec" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 340c363..65c43a7 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -25,6 +25,7 @@ tracing = "0" tracing-subscriber = { version = "0", features = ["fmt", "ansi"] } shell-words = "1" size = "0" +base64 = "0.22" [build-dependencies] openapiv3 = "2" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f8b70b4..3291d58 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -17,7 +17,7 @@ pub use remote_action::{ pub use services::root_session_service::RootSessionStatus; pub use services::workspace_archive::WorkspaceArchiveFormat; -use std::{fmt, sync::Arc, time::SystemTime}; +use std::{collections::BTreeMap, fmt, sync::Arc, time::SystemTime}; use serde::{Deserialize, Serialize}; @@ -90,10 +90,39 @@ impl Default for PersistentWorkspaceSnapshot { /// Workspace metadata that is saved in transient storage (`/run`) and does not survive a reboot. /// This is useful for process metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeBackend { + #[default] + LinuxSystemdBwrap, + AppleContainer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHandleSnapshot { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default, alias = "unit")] + pub id: String, + #[serde(default)] + pub metadata: BTreeMap, +} + +impl Default for RuntimeHandleSnapshot { + fn default() -> Self { + Self { + backend: RuntimeBackend::default(), + id: String::new(), + metadata: BTreeMap::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransientWorkspaceSnapshot { pub uri: String, - pub unit: String, + #[serde(flatten)] + pub runtime: RuntimeHandleSnapshot, } /// Holder for the HTTP connection to the opencode server. diff --git a/lib/src/manager.rs b/lib/src/manager.rs index 7bf12e1..41e401f 100644 --- a/lib/src/manager.rs +++ b/lib/src/manager.rs @@ -111,7 +111,10 @@ impl WorkspaceManager { #[cfg(test)] mod tests { use super::*; - use crate::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, + }; #[test] fn add_notifies_workspace_set_watch() { @@ -190,7 +193,11 @@ mod tests { snapshot.persistent.description = "incrementally updated".to_string(); snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), - unit: "run-u42.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u42.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -199,7 +206,7 @@ mod tests { let updated = workspace_rx.borrow_and_update().clone(); assert_eq!(updated.persistent.description, "incrementally updated"); assert_eq!( - updated.transient.as_ref().map(|t| t.unit.as_str()), + updated.transient.as_ref().map(|t| t.runtime.id.as_str()), Some("run-u42.service") ); assert!(!workspace_set_rx.has_changed().expect("watch still open")); diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 626fd9e..a0815a5 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -1,5 +1,4 @@ use std::{ - env, io::ErrorKind, path::{Path, PathBuf}, process::{ExitStatus, Stdio}, @@ -8,206 +7,34 @@ use std::{ }; use tokio::process::Command; -use uuid::Uuid; - -fn shell_escape_arg(arg: &str) -> String { - if arg.is_empty() { - "''".to_string() - } else if arg - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '_' | '-' | '.' | '=')) - { - arg.to_string() - } else { - format!("'{}'", arg.replace('\'', "'\\''")) - } -} - -fn format_command_line(program: &str, args: &[String]) -> String { - std::iter::once(program) - .chain(args.iter().map(String::as_str)) - .map(shell_escape_arg) - .collect::>() - .join(" ") -} - -fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { - for (name, _) in env { - args.push("--setenv".to_string()); - args.push(name.clone()); - } -} #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnCommand { + pub program: String, pub args: Vec, pub inherited_env: Vec<(String, String)>, } use crate::{ - TransientWorkspaceSnapshot, WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, - database::Database, logging, + WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, database::Database, logging, }; use super::{ GithubStatusService, GithubStatusServiceError, WorkspaceDirectoryError, config::{ - AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, path_looks_like_file, + AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, inherited_env_value, read_config, resolve_opencode_command, validate_handler_config, validate_remote_config, validate_tool_config_entries, validate_workspace_key, }, multicode_metadata_service, opencode_client_service, persistent_storage, - resource_usage_service, root_session_service, transient_storage, usage_aggregation_service, + resource_usage_service, root_session_service, + runtime::WorkspaceRuntime, + runtime_reconciliation_service::runtime_reconciliation_service, + transient_storage, usage_aggregation_service, workspace_archive::ArchiveWorkspaceEntry, workspace_directory, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum MountKind { - Readable, - Writable, - Isolated, - Tmpfs, -} - -#[derive(Debug, Clone)] -struct MountSpec { - target: PathBuf, - source: Option, - kind: MountKind, - is_file: bool, -} - -impl MountSpec { - fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { - let is_file = match source.as_ref() { - Some(source) => std::fs::metadata(source) - .map(|metadata| metadata.is_file()) - .unwrap_or(false), - None => std::fs::metadata(&target) - .map(|metadata| metadata.is_file()) - .unwrap_or_else(|_| path_looks_like_file(&target)), - }; - Self { - target, - source, - kind, - is_file, - } - } - - fn depth(&self) -> usize { - self.target.components().count() - } - - fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { - for prior_mount in prior_mounts.iter().rev() { - if path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) { - let relative = path - .strip_prefix(&prior_mount.mount.target) - .expect("path should be under prior mount target"); - return prior_mount.effective_source.join(relative); - } - } - path.to_path_buf() - } - - fn resolve_effective(&self, prior_mounts: &[ResolvedMountSpec]) -> ResolvedMountSpec { - let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); - let effective_source = match self.kind { - MountKind::Isolated => self - .source - .as_ref() - .map(|source| Self::resolve_backing_path(source, prior_mounts)) - .unwrap_or_else(|| effective_target.clone()), - MountKind::Readable | MountKind::Writable => { - self.source.clone().unwrap_or_else(|| self.target.clone()) - } - MountKind::Tmpfs => effective_target.clone(), - }; - ResolvedMountSpec { - mount: self.clone(), - effective_target, - effective_source, - } - } -} - -#[derive(Debug, Clone)] -struct ResolvedMountSpec { - mount: MountSpec, - effective_target: PathBuf, - effective_source: PathBuf, -} - -impl ResolvedMountSpec { - async fn prepare_source_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - self.prepare_node( - &self.effective_source, - owns_node, - self.mount - .source - .as_ref() - .filter(|original| *original != &self.effective_source), - ) - .await - } - - async fn prepare_target_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - let should_materialize = owns_node - && (!self.mount.is_file - || matches!(self.mount.kind, MountKind::Writable | MountKind::Isolated)); - self.prepare_node(&self.effective_target, should_materialize, None) - .await - } - - async fn prepare_node( - &self, - path: &Path, - materialize_node: bool, - seed_file: Option<&PathBuf>, - ) -> Result<(), CombinedServiceError> { - if self.mount.is_file { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - if materialize_node && tokio::fs::metadata(path).await.is_err() { - if let Some(seed_file) = seed_file { - if tokio::fs::metadata(seed_file).await.is_ok() { - tokio::fs::copy(seed_file, path).await?; - return Ok(()); - } - } - tokio::fs::File::create(path).await?; - } - } else if materialize_node { - tokio::fs::create_dir_all(path).await?; - } else if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - Ok(()) - } - - fn append_args(&self, args: &mut Vec) { - match self.mount.kind { - MountKind::Readable => { - args.push("--ro-bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Writable | MountKind::Isolated => { - args.push("--bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Tmpfs => { - args.push("--tmpfs".to_string()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - } - } -} - #[derive(Debug, Clone)] pub struct CombinedService { pub config: Config, @@ -217,6 +44,7 @@ pub struct CombinedService { workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, opencode_command: String, + runtime: WorkspaceRuntime, github_git_credentials_env: Option, } @@ -247,6 +75,8 @@ impl CombinedService { validate_handler_config(&config.handler)?; validate_remote_config(config.remote.as_ref())?; let opencode_command = resolve_opencode_command(&config.opencode)?; + let container_opencode_command = + resolve_container_opencode_command(config.runtime.backend, &config.opencode); let workspace_directory_path = expand_shell_path(&config.workspace_directory)?; if let Err(err) = logging::enable_workspace_file_logging(&workspace_directory_path).await { logging::log_file_enable_failed( @@ -273,6 +103,13 @@ impl CombinedService { GithubStatusService::new(database.clone(), config.github.token.clone()).await?; let github_git_credentials_env = github_git_credentials_env_from_config(&config, &github_status_service).await?; + let runtime = WorkspaceRuntime::new( + config.runtime.clone(), + workspace_directory_path.clone(), + expanded_isolation.clone(), + opencode_command.clone(), + container_opencode_command, + ); let persistent_path = workspace_directory_path .join(".multicode") @@ -287,6 +124,7 @@ impl CombinedService { workspace_directory_path.clone(), ); spawn_transient_storage(manager.clone(), transient_link); + spawn_runtime_reconciliation_service(manager.clone(), runtime.clone()); spawn_opencode_client_service(manager.clone()); spawn_root_session_service(manager.clone()); spawn_multicode_metadata_service(manager.clone()); @@ -301,6 +139,7 @@ impl CombinedService { workspace_directory_path, expanded_isolation, opencode_command, + runtime, github_git_credentials_env, }) } @@ -342,43 +181,20 @@ impl CombinedService { let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; - let password = generate_random_password(); - let port = pick_random_free_port().await?; - let unit = generate_transient_unit_name(); - let args = self - .build_systemd_bwrap_command(&key, &password, port, &unit) + let inherited_env = self + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; + let start = self.runtime.start_server(&key, &inherited_env).await?; tracing::info!( workspace_key = %key, - command = %format_command_line("systemd-run", &args.args), - "starting application via systemd-run opencode serve" + backend = ?self.config.runtime.backend, + runtime_id = %start.transient.runtime.id, + "started workspace runtime" ); - let mut command = Command::new("systemd-run"); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .args(&args.args); - for (name, value) in &args.inherited_env { - command.env(name, value); - } - let output = command.output().await?; - - if !output.status.success() { - return Err(CombinedServiceError::StartWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - - let uri = format!("http://opencode:{password}@127.0.0.1:{port}/"); - let transient = TransientWorkspaceSnapshot { - uri, - unit: unit.clone(), - }; let mut replaced = false; workspace.update(|snapshot| { if snapshot.transient.is_none() { - snapshot.transient = Some(transient.clone()); + snapshot.transient = Some(start.transient.clone()); replaced = true; true } else { @@ -387,7 +203,7 @@ impl CombinedService { }); if !replaced { - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&start.transient.runtime).await?; return Err(CombinedServiceError::TransientSnapshotAlreadyPresent( key.to_string(), )); @@ -400,14 +216,14 @@ impl CombinedService { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; let workspace_rx = workspace.subscribe(); - let unit = workspace_rx + let runtime_handle = workspace_rx .borrow() .transient .as_ref() - .map(|transient| transient.unit.clone()) + .map(|transient| transient.runtime.clone()) .ok_or_else(|| CombinedServiceError::TransientSnapshotMissing(key.clone()))?; - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&runtime_handle).await?; workspace.update(|snapshot| { if snapshot.transient.is_some() { snapshot.transient = None; @@ -453,27 +269,12 @@ impl CombinedService { let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; - let unit = generate_transient_unit_name(); - let mut args = vec![ - "--user".to_string(), - "--wait".to_string(), - "--collect".to_string(), - "--pty".to_string(), - ]; let inherited_env = self .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit); - self.append_systemd_limits(&mut args); - self.append_bwrap_sandbox_args(&mut args, &key).await?; - args.extend(command); - - Ok(SpawnCommand { - args, - inherited_env, - }) + self.runtime + .build_pty_command(&key, &inherited_env, command) + .await } pub async fn archive_workspace( @@ -624,6 +425,7 @@ impl CombinedService { &self.opencode_command } + #[cfg_attr(not(test), allow(dead_code))] async fn build_systemd_bwrap_command( &self, key: &str, @@ -631,50 +433,12 @@ impl CombinedService { port: u16, unit: &str, ) -> Result { - let mut args = vec!["--user".to_string(), "--no-block".to_string()]; let inherited_env = self - .sandbox_env_pairs(vec![ - ( - "OPENCODE_SERVER_USERNAME".to_string(), - "opencode".to_string(), - ), - ("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string()), - ]) + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit.to_string()); - self.append_systemd_limits(&mut args); - - self.append_bwrap_sandbox_args(&mut args, key).await?; - args.push(self.opencode_command.clone()); - args.push("serve".to_string()); - args.push("--hostname".to_string()); - args.push("127.0.0.1".to_string()); - args.push("--port".to_string()); - args.push(port.to_string()); - - Ok(SpawnCommand { - args, - inherited_env, - }) - } - - fn append_systemd_limits(&self, args: &mut Vec) { - if let Some(memory_high_bytes) = self.expanded_isolation.memory_high_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryHigh={memory_high_bytes}")); - } - if let Some(memory_max_bytes) = self.expanded_isolation.memory_max_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryMax={memory_max_bytes}")); - args.push("-p".to_string()); - args.push("MemorySwapMax=0".to_string()); - } - if let Some(cpu) = &self.expanded_isolation.cpu { - args.push("-p".to_string()); - args.push(format!("CPUQuota={cpu}")); - } + self.runtime + .build_linux_start_command(key, password, port, unit, &inherited_env) + .await } async fn sandbox_env_pairs( @@ -688,118 +452,12 @@ impl CombinedService { .inherit_env .iter() .filter_map(|env_name| { - env::var(env_name) - .ok() - .map(|env_value| (env_name.clone(), env_value)) + inherited_env_value(env_name).map(|value| (env_name.clone(), value)) }), ); Ok(env) } - async fn append_bwrap_sandbox_args( - &self, - args: &mut Vec, - key: &str, - ) -> Result<(), CombinedServiceError> { - let workspace_path = self.workspace_directory_path.join(key); - let workspace_path_str = workspace_path.to_string_lossy().into_owned(); - - args.push("bwrap".to_string()); - args.push("--chdir".to_string()); - args.push(workspace_path_str.clone()); - - args.push("--ro-bind".to_string()); - args.push("/".to_string()); - args.push("/".to_string()); - - let mut mount_specs = Vec::new(); - mount_specs.extend( - self.expanded_isolation - .readable - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Readable)), - ); - mount_specs.extend( - self.expanded_isolation - .writable - .iter() - .cloned() - .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), - ); - mount_specs.push(MountSpec::new( - workspace_path.clone(), - Some(workspace_path.clone()), - MountKind::Writable, - )); - mount_specs.extend( - self.expanded_isolation - .isolated - .iter() - .cloned() - .map(|path| { - let source = self.isolated_storage_path(key, &path); - MountSpec::new(path.clone(), Some(source), MountKind::Isolated) - }), - ); - mount_specs.extend( - self.expanded_isolation - .tmpfs - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), - ); - mount_specs.extend( - self.expanded_isolation - .added_skills - .iter() - .cloned() - .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), - ); - mount_specs.sort_by(|a, b| { - a.depth() - .cmp(&b.depth()) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.kind.cmp(&b.kind)) - }); - - let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); - for (index, mount_spec) in mount_specs.iter().enumerate() { - let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); - let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { - other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target - }); - let owns_source_node = owns_node - || (mount_spec.is_file - && mount_spec - .source - .as_ref() - .is_some_and(|source| source != &resolved_mount.effective_source)); - resolved_mount.prepare_source_node(owns_source_node).await?; - resolved_mount.prepare_target_node(owns_node).await?; - resolved_mounts.push(resolved_mount); - } - - for resolved_mount in resolved_mounts { - resolved_mount.append_args(args); - } - - args.push("--proc".to_string()); - args.push("/proc".to_string()); - args.push("--dev".to_string()); - args.push("/dev".to_string()); - args.push("--die-with-parent".to_string()); - - Ok(()) - } - - fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { - let relative = target - .strip_prefix("/") - .expect("isolated path is validated as absolute"); - self.isolate_path_for_key(key).join(relative) - } - fn isolate_path_for_key(&self, key: &str) -> PathBuf { self.workspace_directory_path .join(".multicode") @@ -973,6 +631,50 @@ impl CombinedService { } } +fn resolve_container_opencode_command( + backend: crate::RuntimeBackend, + candidates: &[String], +) -> String { + if backend == crate::RuntimeBackend::AppleContainer { + return candidates + .iter() + .filter_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + let name = Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate); + if name == "opencode" { + Some("opencode".to_string()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| "opencode".to_string()); + } + + candidates + .iter() + .find_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + Some( + Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate) + .to_string(), + ) + }) + .unwrap_or_else(|| "opencode".to_string()) +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1041,7 +743,12 @@ pub enum CombinedServiceError { field: String, message: String, }, + InvalidRuntimeConfig { + field: String, + message: String, + }, InvalidToolExecution(String), + UnsupportedRuntimeBackend(String), WorkspaceArchived(String), WorkspaceNotArchived(String), ArchiveWorkspaceRunning(String), @@ -1095,38 +802,7 @@ impl From for CombinedServiceError { } } -fn generate_random_password() -> String { - Uuid::new_v4().as_simple().to_string() -} - -fn generate_transient_unit_name() -> String { - format!("multicode-{}.service", Uuid::new_v4().as_simple()) -} - -async fn pick_random_free_port() -> Result { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -async fn stop_systemd_unit(unit: &str) -> Result<(), CombinedServiceError> { - let args = stop_systemd_args(unit); - let output = Command::new("systemctl") - .args(args) - .stdin(Stdio::null()) - .output() - .await?; - if output.status.success() { - Ok(()) - } else { - Err(CombinedServiceError::StopWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } -} - +#[cfg_attr(not(test), allow(dead_code))] fn stop_systemd_args(unit: &str) -> Vec { vec![ "--user".to_string(), @@ -1156,6 +832,14 @@ fn spawn_transient_storage(manager: Arc, transient_link: PathB }); } +fn spawn_runtime_reconciliation_service(manager: Arc, runtime: WorkspaceRuntime) { + tokio::spawn(async move { + if let Err(err) = runtime_reconciliation_service(manager, runtime).await { + tracing::error!(error = ?err, "runtime reconciliation service exited with error"); + } + }); +} + fn spawn_opencode_client_service(manager: Arc) { tokio::spawn(async move { if let Err(err) = opencode_client_service(manager).await { @@ -1199,7 +883,10 @@ fn spawn_resource_usage_service(manager: Arc) { #[cfg(test)] mod tests { use super::*; - use crate::services::{GithubTokenConfig, ToolType}; + use crate::services::{ + GithubTokenConfig, ToolType, + runtime::{MountKind, MountSpec}, + }; use crate::test_support::ENV_VAR_LOCK; use diesel::{QueryableByName, RunQueryDsl, sql_query, sqlite::SqliteConnection}; use std::os::unix::fs::PermissionsExt; @@ -1317,6 +1004,35 @@ token = { env = "GITHUB_TOKEN" } ); } + #[test] + fn resolve_container_opencode_command_prefers_opencode_for_apple_backend() { + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::AppleContainer, + &["opencode-cli".to_string(), "opencode".to_string()] + ), + "opencode" + ); + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::AppleContainer, + &["/opt/homebrew/bin/opencode-cli".to_string()] + ), + "opencode" + ); + } + + #[test] + fn resolve_container_opencode_command_keeps_first_candidate_for_linux_backend() { + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::LinuxSystemdBwrap, + &["opencode-cli".to_string(), "opencode".to_string()] + ), + "opencode-cli" + ); + } + #[test] fn config_parses_github_populate_git_credentials_flag() { let config: Config = toml::from_str( diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 2527899..cc5cb74 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -9,12 +9,15 @@ use serde::{Deserialize, Serialize}; use size::Size; use super::CombinedServiceError; +use crate::RuntimeBackend; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct Config { pub workspace_directory: String, pub isolation: IsolationConfig, + #[serde(default)] + pub runtime: RuntimeConfig, #[serde(default = "default_opencode_commands")] pub opencode: Vec, #[serde(default)] @@ -27,6 +30,15 @@ pub struct Config { pub github: GithubConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct RuntimeConfig { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default)] + pub image: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "kebab-case")] pub struct GithubConfig { @@ -315,11 +327,44 @@ pub(super) fn validate_workspace_key(key: &str) -> Result Result { - let expanded = shellexpand::full(value) - .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; + let expanded = shellexpand::full_with_context( + value, + || env::var("HOME").ok(), + |name| match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(synthesized_env_value(name)), + Err(err) => Err(err.to_string()), + }, + ) + .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; Ok(PathBuf::from(expanded.into_owned())) } +pub(super) fn inherited_env_value(name: &str) -> Option { + env::var(name).ok().or_else(|| synthesized_env_value(name)) +} + +pub(super) fn synthesized_env_value(name: &str) -> Option { + match name { + "XDG_RUNTIME_DIR" => { + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + } + _ => None, + } +} + +pub(super) fn synthesized_xdg_runtime_dir() -> Option { + #[cfg(target_os = "macos")] + { + Some(env::temp_dir().join("multicode-runtime")) + } + + #[cfg(not(target_os = "macos"))] + { + None + } +} + fn expand_isolation_paths( paths: &[String], field: &str, @@ -557,3 +602,93 @@ fn validate_handler_template( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + ffi::OsString, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; + + static ENV_VAR_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + old_value: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, old_value } + } + + fn remove(key: &'static str) -> Self { + let old_value = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, old_value } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + env::set_var(self.key, value); + } + } else { + unsafe { + env::remove_var(self.key); + } + } + } + } + + #[test] + fn expand_shell_path_expands_existing_environment_variables() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let runtime_dir = env::temp_dir().join(format!("multicode-config-test-{unique}")); + let _guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!(path, runtime_dir.join("opencode")); + } + + #[cfg(target_os = "macos")] + #[test] + fn expand_shell_path_synthesizes_xdg_runtime_dir_on_macos() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!( + path, + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("opencode") + ); + assert_eq!( + inherited_env_value("XDG_RUNTIME_DIR"), + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + ); + } +} diff --git a/lib/src/services/mod.rs b/lib/src/services/mod.rs index c1c24f2..54ef05c 100644 --- a/lib/src/services/mod.rs +++ b/lib/src/services/mod.rs @@ -6,6 +6,8 @@ pub mod opencode_client_service; pub mod persistent_storage; pub mod resource_usage_service; pub mod root_session_service; +pub mod runtime; +pub(crate) mod runtime_reconciliation_service; pub mod transient_storage; pub mod usage_aggregation_service; pub mod workspace_archive; @@ -16,7 +18,8 @@ pub(crate) mod workspace_watch; pub use crate::database::{Database, DatabaseError}; pub use combined::{CombinedService, CombinedServiceError}; pub use config::{ - Config, GithubTokenConfig, HandlerConfig, ToolConfig, ToolType, parse_optional_size_bytes, + Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, ToolType, + parse_optional_size_bytes, }; pub use github_status_service::{ GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, diff --git a/lib/src/services/opencode_client_service.rs b/lib/src/services/opencode_client_service.rs index 8d079e4..4bda479 100644 --- a/lib/src/services/opencode_client_service.rs +++ b/lib/src/services/opencode_client_service.rs @@ -7,14 +7,19 @@ use std::{ time::Duration, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use tokio::{ process::Command, sync::{broadcast, watch}, task::JoinHandle, }; use tokio_stream::StreamExt; +use url::Url; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeActivity, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{ OpencodeClientSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, @@ -191,8 +196,8 @@ async fn watch_workspace_snapshot( abort_event_forward_task(&mut event_forward_task); event_generation.fetch_add(1, Ordering::Relaxed); - match read_unit_activity(&transient.unit).await { - UnitActivity::Stopped => { + match WorkspaceRuntime::read_activity(&transient.runtime).await { + RuntimeActivity::Stopped => { workspace.update(|next| { if next.transient.as_ref() == Some(&transient) { let mut changed = false; @@ -211,7 +216,7 @@ async fn watch_workspace_snapshot( }); last_client_uri = None; } - UnitActivity::Active | UnitActivity::Unknown => {} + RuntimeActivity::Active | RuntimeActivity::Unknown => {} } if !wait_for_change_or_timeout(&mut workspace_rx, HEALTH_RETRY_INTERVAL).await { @@ -256,10 +261,18 @@ fn create_opencode_client( current_uri: &str, shared_http_client: Option<&reqwest::Client>, ) -> opencode::client::Client { + let (baseurl, auth_header) = opencode_client_target(current_uri); + if let Some(auth_header) = auth_header { + return opencode::client::Client::new_with_client( + &baseurl, + build_authenticated_http_client(auth_header), + ); + } + if let Some(shared_http_client) = shared_http_client { - opencode::client::Client::new_with_client(current_uri, shared_http_client.clone()) + opencode::client::Client::new_with_client(&baseurl, shared_http_client.clone()) } else { - opencode::client::Client::new(current_uri) + opencode::client::Client::new(&baseurl) } } @@ -272,6 +285,46 @@ fn build_shared_http_client() -> Option { .ok() } +fn build_authenticated_http_client(auth_header: String) -> reqwest::Client { + let timeout = Duration::from_secs(15); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&auth_header) + .expect("generated basic auth header should be valid"), + ); + reqwest::Client::builder() + .connect_timeout(timeout) + .timeout(timeout) + .default_headers(headers) + .build() + .expect("authenticated opencode http client should build") +} + +fn opencode_client_target(current_uri: &str) -> (String, Option) { + let Ok(mut url) = Url::parse(current_uri) else { + return (current_uri.to_string(), None); + }; + + let username = url.username().to_string(); + let password = url.password().map(str::to_string); + if username.is_empty() { + return (current_uri.to_string(), None); + } + + let _ = url.set_username(""); + let _ = url.set_password(None); + let credentials = match password { + Some(password) => format!("{username}:{password}"), + None => format!("{username}:"), + }; + let encoded = BASE64_STANDARD.encode(credentials); + ( + url.to_string().trim_end_matches('/').to_string(), + Some(format!("Basic {encoded}")), + ) +} + async fn forward_global_events( client: Arc, event_tx: broadcast::Sender, @@ -396,14 +449,8 @@ async fn wait_for_change_or_timeout( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitActivity { - Active, - Stopped, - Unknown, -} - -async fn read_unit_activity(unit: &str) -> UnitActivity { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_activity(unit: &str) -> RuntimeActivity { let output = match Command::new("systemctl") .args([ "--user", @@ -420,21 +467,21 @@ async fn read_unit_activity(unit: &str) -> UnitActivity { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } }; if !output.status.success() { - return UnitActivity::Stopped; + return RuntimeActivity::Stopped; } let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); if matches!(state.as_str(), "active" | "activating") { - UnitActivity::Active + RuntimeActivity::Active } else { - UnitActivity::Stopped + RuntimeActivity::Stopped } } @@ -450,7 +497,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use crate::TransientWorkspaceSnapshot; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; struct TestDir { @@ -512,6 +559,31 @@ mod tests { } } + #[test] + fn opencode_client_target_extracts_basic_auth_header_and_strips_userinfo() { + let (baseurl, auth_header) = + opencode_client_target("http://opencode:secret@127.0.0.1:1234/"); + assert_eq!(baseurl, "http://127.0.0.1:1234"); + assert_eq!( + auth_header, + Some(format!( + "Basic {}", + BASE64_STANDARD.encode("opencode:secret") + )) + ); + } + + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + #[test] fn health_probe_client_reuses_cached_client_for_same_uri() { let mut cached_probe_client = None; @@ -591,10 +663,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-health.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-health.service", + )); true }); @@ -692,10 +764,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}/"), - unit: "run-u-health-trailing-slash.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}/"), + "run-u-health-trailing-slash.service", + )); true }); @@ -786,10 +858,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-events.service", + )); true }); @@ -876,10 +948,10 @@ mod tests { let mut workspace_rx = workspace.subscribe(); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-stopped.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-stopped.service", + )); true }); @@ -945,10 +1017,10 @@ mod tests { .expect("workspace should exist"); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-active.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-active.service", + )); true }); @@ -980,7 +1052,7 @@ mod tests { let _path_guard = EnvVarGuard::set("PATH", empty_bin.as_os_str()); let activity = read_unit_activity("missing.service").await; - assert_eq!(activity, UnitActivity::Unknown); + assert_eq!(activity, RuntimeActivity::Unknown); }); } } diff --git a/lib/src/services/resource_usage_service.rs b/lib/src/services/resource_usage_service.rs index 36b4a57..4cfae37 100644 --- a/lib/src/services/resource_usage_service.rs +++ b/lib/src/services/resource_usage_service.rs @@ -6,7 +6,10 @@ use std::{ use tokio::{process::Command, sync::watch}; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeUsageSample, RuntimeUsageState, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace}; const RESOURCE_MONITOR_INTERVAL: Duration = Duration::from_secs(2); @@ -22,19 +25,6 @@ impl From for ResourceUsageServiceError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct UnitUsageSample { - memory_current: Option, - cpu_usage_nsec: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitUsageState { - Active(UnitUsageSample), - Stopped, - Unknown, -} - pub async fn resource_usage_service( manager: Arc, ) -> Result<(), ResourceUsageServiceError> { @@ -70,33 +60,34 @@ async fn watch_workspace_snapshot( continue; }; - let unit_changed = sampled_unit.as_deref() != Some(transient.unit.as_str()); + let unit_changed = sampled_unit.as_deref() != Some(transient.runtime.id.as_str()); if unit_changed { previous_cpu_sample = None; - sampled_unit = Some(transient.unit.clone()); + sampled_unit = Some(transient.runtime.id.clone()); next_sample_at = None; } let now = Instant::now(); if should_sample_usage(now, next_sample_at) { - match read_unit_usage(&transient.unit).await { - UnitUsageState::Active(usage_sample) => { - let (cpu_percent, next_cpu_sample) = cpu_percent_from_sample( - previous_cpu_sample, - usage_sample.cpu_usage_nsec, - now, - ); + match WorkspaceRuntime::read_usage(&transient.runtime).await { + RuntimeUsageSample { + state: Some(RuntimeUsageState::Active), + memory_current, + cpu_usage_nsec, + } => { + let (cpu_percent, next_cpu_sample) = + cpu_percent_from_sample(previous_cpu_sample, cpu_usage_nsec, now); previous_cpu_sample = next_cpu_sample; refresh_resource_usage( &workspace, - &transient.unit, + &transient.runtime.id, cpu_percent, - usage_sample.memory_current, + memory_current, ); } - UnitUsageState::Stopped | UnitUsageState::Unknown => { + RuntimeUsageSample { .. } => { previous_cpu_sample = None; - clear_resource_usage_for_unit(&workspace, &transient.unit); + clear_resource_usage_for_unit(&workspace, &transient.runtime.id); } } next_sample_at = Some(now + RESOURCE_MONITOR_INTERVAL); @@ -133,7 +124,7 @@ fn refresh_resource_usage( let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); let should_update = still_tracking_same_unit && (snapshot.usage_cpu_percent != cpu_percent || snapshot.usage_ram_bytes != ram_bytes); @@ -166,7 +157,7 @@ fn clear_resource_usage_for_unit(workspace: &Workspace, unit: &str) { let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); if has_usage && still_tracking_same_unit { snapshot.usage_cpu_percent = None; @@ -201,7 +192,8 @@ fn cpu_percent_from_sample( (cpu_percent, Some((current_cpu_usage_nsec, now))) } -async fn read_unit_usage(unit: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_usage(unit: &str) -> RuntimeUsageSample { let output = match Command::new("systemctl") .args([ "--user", @@ -221,20 +213,30 @@ async fn read_unit_usage(unit: &str) -> UnitUsageState { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } }; if !output.status.success() { - return UnitUsageState::Stopped; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; } parse_unit_usage_show_output(&String::from_utf8_lossy(&output.stdout)) } -fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +fn parse_unit_usage_show_output(output: &str) -> RuntimeUsageSample { let mut active_state: Option<&str> = None; let mut memory_current: Option = None; let mut cpu_usage_nsec: Option = None; @@ -253,16 +255,18 @@ fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { } let active_state = active_state.unwrap_or_default(); - if !matches!(active_state, "active" | "activating") { - return UnitUsageState::Stopped; - } - - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current, cpu_usage_nsec, - }) + state: Some(if matches!(active_state, "active" | "activating") { + RuntimeUsageState::Active + } else { + RuntimeUsageState::Stopped + }), + } } +#[cfg_attr(not(test), allow(dead_code))] fn parse_systemctl_u64(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() || trimmed == "[not set]" { @@ -290,10 +294,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=4096\nCPUUsageNSec=2000000000\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(4096), cpu_usage_nsec: Some(2_000_000_000), - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -302,10 +307,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=[not set]\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: None, cpu_usage_nsec: None, - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -313,7 +319,11 @@ mod tests { fn parse_unit_usage_show_output_reports_stopped_for_inactive_state() { assert_eq!( parse_unit_usage_show_output("ActiveState=inactive\nMemoryCurrent=0\nCPUUsageNSec=0\n"), - UnitUsageState::Stopped + RuntimeUsageSample { + memory_current: Some(0), + cpu_usage_nsec: Some(0), + state: Some(RuntimeUsageState::Stopped), + } ); } @@ -322,10 +332,11 @@ mod tests { let output = "CPUUsageNSec=300\nActiveState=active\nMemoryCurrent=1024\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(1024), cpu_usage_nsec: Some(300), - }) + state: Some(RuntimeUsageState::Active), + } ); } diff --git a/lib/src/services/root_session_service.rs b/lib/src/services/root_session_service.rs index 8a511a0..a567e1d 100644 --- a/lib/src/services/root_session_service.rs +++ b/lib/src/services/root_session_service.rs @@ -416,7 +416,9 @@ fn normalize_base_uri(uri: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::Notify, @@ -440,6 +442,17 @@ mod tests { .to_string() } + fn transient_snapshot(uri: &str, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri: uri.to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn sessions_json_with_subagent( root_session_id: &str, root_title: &str, @@ -651,10 +664,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -781,10 +794,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -894,10 +907,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1053,10 +1066,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1194,10 +1207,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1310,10 +1323,8 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-race.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-race.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1437,10 +1448,8 @@ mod tests { let base_uri = format!("http://{addr}"); let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-stale.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-stale.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client.clone(), events: old_event_tx.clone(), @@ -1558,10 +1567,10 @@ mod tests { let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-same-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-same-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1688,10 +1697,10 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-ignore-non-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-ignore-non-session.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1750,10 +1759,10 @@ mod tests { let (old_event_tx, _) = broadcast::channel(64); let old_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:9")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9/".to_string(), - unit: "run-u-root-old-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9/", + "run-u-root-old-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1774,10 +1783,10 @@ mod tests { let (new_event_tx, _) = broadcast::channel(64); let new_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:10")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:10/".to_string(), - unit: "run-u-root-new-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:10/", + "run-u-root-new-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: new_client, events: new_event_tx, diff --git a/lib/src/services/transient_storage.rs b/lib/src/services/transient_storage.rs index 8119795..ca26319 100644 --- a/lib/src/services/transient_storage.rs +++ b/lib/src/services/transient_storage.rs @@ -6,7 +6,7 @@ use std::{ use tokio::sync::watch; use uuid::Uuid; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{config::synthesized_xdg_runtime_dir, workspace_watch::monitor_workspace_snapshots}; use crate::{ TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, }; @@ -55,7 +55,7 @@ pub async fn transient_storage( let snapshot_path = snapshot_file_path(storage_dir.as_ref(), &key); let transient_snapshot = read_transient_snapshot(&snapshot_path).await?; workspace.update(|snapshot| { - if snapshot.transient != transient_snapshot { + if snapshot.transient.is_none() && transient_snapshot.is_some() { snapshot.transient = transient_snapshot.clone(); true } else { @@ -65,6 +65,12 @@ pub async fn transient_storage( let current_transient = workspace_rx.borrow_and_update().transient.clone(); tokio::spawn(async move { + if let Err(err) = + persist_transient_snapshot(&snapshot_path, current_transient.as_ref()).await + { + tracing::error!(error = ?err, "failed to persist initial transient snapshot"); + return; + } if let Err(err) = watch_workspace_snapshot(snapshot_path, workspace_rx, current_transient).await { @@ -113,8 +119,9 @@ async fn ensure_storage_directory( } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .or_else(synthesized_xdg_runtime_dir) .ok_or(TransientStorageError::MissingXdgRuntimeDir)?; - let runtime_dir = PathBuf::from(runtime_dir); if !runtime_dir.is_absolute() { return Err(TransientStorageError::InvalidXdgRuntimeDir(runtime_dir)); } @@ -271,14 +278,15 @@ async fn persist_transient_snapshot( #[cfg(test)] mod tests { use super::*; - use crate::WorkspaceManager; use crate::test_support::ENV_VAR_LOCK; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, WorkspaceManager}; use std::{ ffi::OsString, fs, path::{Path, PathBuf}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::Duration, }; + use uuid::Uuid; struct TestDir { path: PathBuf, @@ -286,14 +294,10 @@ mod tests { impl TestDir { fn new() -> Self { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); let path = std::env::temp_dir().join(format!( "multicode-transient-storage-{}-{}", std::process::id(), - unique + Uuid::new_v4().as_simple() )); fs::create_dir_all(&path).expect("test dir should be created"); Self { path } @@ -323,6 +327,14 @@ mod tests { } Self { key, old_value } } + + fn remove(key: &'static str) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, old_value } + } } impl Drop for EnvVarGuard { @@ -397,6 +409,36 @@ mod tests { }); } + #[cfg(target_os = "macos")] + #[test] + fn ensure_storage_directory_synthesizes_xdg_runtime_dir_on_macos() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let link = root.path().join("state/transient-link"); + let target = ensure_storage_directory(&link) + .await + .expect("storage directory should be created"); + + assert!( + target.starts_with( + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("multicode") + ) + ); + }); + } + #[test] fn ensure_storage_directory_creates_missing_symlink_target() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -450,7 +492,11 @@ mod tests { let initial_transient = TransientWorkspaceSnapshot { uri: "file:///initial".to_string(), - unit: "run-u-initial.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-initial.service".to_string(), + metadata: Default::default(), + }, }; let snapshot_path = storage_dir.join("alpha.json"); tokio::fs::write( @@ -479,7 +525,11 @@ mod tests { .update(|snapshot| { snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "file:///updated".to_string(), - unit: "run-u-updated.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-updated.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -491,7 +541,7 @@ mod tests { .expect("snapshot file should stay readable"); let snapshot: TransientWorkspaceSnapshot = serde_json::from_slice(&content).expect("snapshot should parse"); - if snapshot.unit == "run-u-updated.service" { + if snapshot.runtime.id == "run-u-updated.service" { break; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -576,4 +626,74 @@ mod tests { service_task.abort(); }); } + + #[test] + fn transient_storage_does_not_clobber_live_transient_state_when_disk_snapshot_is_missing() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let storage_dir = root.path().join("storage"); + tokio::fs::create_dir_all(&storage_dir) + .await + .expect("storage dir should exist"); + + let link = root.path().join("transient-link"); + tokio::fs::symlink(&storage_dir, &link) + .await + .expect("symlink should be created"); + + let manager = Arc::new(WorkspaceManager::new()); + manager + .add("alpha") + .expect("workspace should be added before service starts"); + + let live_transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: Default::default(), + }, + }; + manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.transient = Some(live_transient.clone()); + true + }); + + let alpha_rx = manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe(); + let service_task = tokio::spawn(transient_storage(manager.clone(), link.clone())); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(alpha_rx.borrow().transient, Some(live_transient.clone())); + + let snapshot_path = storage_dir.join("alpha.json"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let content = tokio::fs::read(&snapshot_path) + .await + .expect("snapshot file should be written"); + let snapshot: TransientWorkspaceSnapshot = + serde_json::from_slice(&content).expect("snapshot should parse"); + if snapshot == live_transient { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("live transient state should be persisted"); + + service_task.abort(); + }); + } } diff --git a/lib/src/services/usage_aggregation_service.rs b/lib/src/services/usage_aggregation_service.rs index a7a5c13..d8943c8 100644 --- a/lib/src/services/usage_aggregation_service.rs +++ b/lib/src/services/usage_aggregation_service.rs @@ -354,9 +354,22 @@ fn sum_usage(usage_by_message: &HashMap) -> (u64, f64) { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn assistant_message_json( message_id: &str, session_id: &str, @@ -507,10 +520,10 @@ mod tests { events: event_tx, }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true @@ -653,10 +666,10 @@ mod tests { events: event_tx.clone(), }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage-events.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index ecb675d..b04d505 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -529,7 +529,9 @@ fn should_sync_bidirectional_mapping_up( remote_latest: Option, ) -> bool { match (local_latest, remote_latest) { - (Some(_), Some(_)) => compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less, + (Some(_), Some(_)) => { + compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less + } (Some(_), None) => true, (None, Some(_)) => false, (None, None) => true, @@ -2179,7 +2181,9 @@ mod tests { std::fs::create_dir_all(&local_dir).expect("local dir should be created"); let mapping = ResolvedSyncPathMapping { local: local_dir.clone(), - remote: PathBuf::from("/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha"), + remote: PathBuf::from( + "/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha", + ), exclude: Vec::new(), dereference_symlinks: false, local_is_dir: true, @@ -2194,7 +2198,10 @@ mod tests { ) .expect("directory sync args should build"); - assert!(args.iter().any(|arg| arg == &format!("{}/", local_dir.to_string_lossy()))); + assert!( + args.iter() + .any(|arg| arg == &format!("{}/", local_dir.to_string_lossy())) + ); assert!(!args.iter().any(|arg| arg == "--mkpath")); assert!(args.iter().any(|arg| { arg == "alice@example.com:/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha/" diff --git a/remote/tests/docker_remote_integration.rs b/remote/tests/docker_remote_integration.rs index 68090b3..17c76ab 100644 --- a/remote/tests/docker_remote_integration.rs +++ b/remote/tests/docker_remote_integration.rs @@ -135,9 +135,15 @@ fn build_probe_binary() -> PathBuf { .current_dir(&repo_root) .status() .expect("cargo build for multicode-tui should run"); - assert!(build_status.success(), "multicode-tui should build for integration test"); + assert!( + build_status.success(), + "multicode-tui should build for integration test" + ); let probe_binary = repo_root.join("target/debug/multicode-tui"); - assert!(probe_binary.exists(), "built multicode-tui binary should exist"); + assert!( + probe_binary.exists(), + "built multicode-tui binary should exist" + ); probe_binary } @@ -201,7 +207,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] ) .expect("dockerfile should be written"); - let image = format!("multicode-remote-test-bidi-matrix-{}:{}", case.test_name(), std::process::id()); + let image = format!( + "multicode-remote-test-bidi-matrix-{}:{}", + case.test_name(), + std::process::id() + ); let build = StdCommand::new("docker") .args(["build", "-t", &image, "."]) .current_dir(root.path()) @@ -210,7 +220,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] assert!(build.success(), "docker build should succeed"); let port = reserve_tcp_port(); - let container_name = format!("multicode-remote-test-bidi-matrix-{}-{}", case.test_name(), std::process::id()); + let container_name = format!( + "multicode-remote-test-bidi-matrix-{}-{}", + case.test_name(), + std::process::id() + ); let run = StdCommand::new("docker") .args([ "run", @@ -229,7 +243,9 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .expect("docker run should execute"); assert!(run.success(), "docker run should succeed"); - let _container = DockerContainerGuard { name: container_name.clone() }; + let _container = DockerContainerGuard { + name: container_name.clone(), + }; wait_for_ssh(port, &key_path, &known_hosts).await; @@ -320,8 +336,13 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .output() .await .expect("remote seed probe should run"); - assert!(remote_seed.status.success(), "remote seed probe should succeed"); - let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout).trim().to_string(); + assert!( + remote_seed.status.success(), + "remote seed probe should succeed" + ); + let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout) + .trim() + .to_string(); let remote_parent_probe = Command::new("ssh") .args([ @@ -339,31 +360,67 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .await .expect("remote parent probe should run"); - assert!(remote_parent_probe.success(), "bidi sync must not place files in the remote parent directory"); + assert!( + remote_parent_probe.success(), + "bidi sync must not place files in the remote parent directory" + ); let local_seed_path = bidi_local.join("seed.txt"); - let local_seed_text = fs::read_to_string(&local_seed_path).ok().map(|text| text.trim().to_string()); + let local_seed_text = fs::read_to_string(&local_seed_path) + .ok() + .map(|text| text.trim().to_string()); assert!( - !bidi_local.parent().expect("bidi local parent should exist").join("seed.txt").exists(), + !bidi_local + .parent() + .expect("bidi local parent should exist") + .join("seed.txt") + .exists(), "bidi sync must not place files in the local parent directory" ); match case { BidiExistenceCase::LocalAndRemoteMissing => { - assert_eq!(remote_seed_text, "", "remote destination should remain empty when both sides start empty"); - assert!(!local_seed_path.exists(), "local destination should remain empty when both sides start empty"); + assert_eq!( + remote_seed_text, "", + "remote destination should remain empty when both sides start empty" + ); + assert!( + !local_seed_path.exists(), + "local destination should remain empty when both sides start empty" + ); } BidiExistenceCase::LocalOnly => { - assert_eq!(remote_seed_text, "local-seed", "initial upload should seed the exact remote destination from the local directory"); - assert_eq!(local_seed_text.as_deref(), Some("local-seed"), "local seed should remain in the configured local directory"); + assert_eq!( + remote_seed_text, "local-seed", + "initial upload should seed the exact remote destination from the local directory" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("local-seed"), + "local seed should remain in the configured local directory" + ); } BidiExistenceCase::RemoteOnly => { - assert_eq!(remote_seed_text, "remote-seed", "remote-only case should preserve the exact remote destination contents"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "final sync-down should place remote contents into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "remote-only case should preserve the exact remote destination contents" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "final sync-down should place remote contents into the configured local directory" + ); } BidiExistenceCase::LocalAndRemotePresent => { - assert_eq!(remote_seed_text, "remote-seed", "newer remote content should win within the configured remote destination"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "newer remote content should sync down into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "newer remote content should win within the configured remote destination" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "newer remote content should sync down into the configured local directory" + ); } } } @@ -675,7 +732,6 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] }); } - #[test] fn docker_remote_flow_bidi_sync_handles_both_missing() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/tui/src/app.rs b/tui/src/app.rs index a8621b4..8c17a5a 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -788,9 +788,10 @@ impl TuiState { } }; - let mut tmux_command = vec!["systemd-run".to_string()]; let inherited_env = exec_command.inherited_env; - tmux_command.extend(exec_command.args); + let tmux_command = std::iter::once(exec_command.program) + .chain(exec_command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) @@ -861,9 +862,10 @@ impl TuiState { io::Error::other(format!("failed to prepare PTY review handler: {err:?}")) })?; - let mut tmux_command = vec!["systemd-run".to_string()]; let inherited_env = command.inherited_env; - tmux_command.extend(command.args); + let tmux_command = std::iter::once(command.program) + .chain(command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 024894d..5206b8a 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -1,4 +1,6 @@ use crate::*; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; pub(crate) fn shell_escape_arg(arg: &str) -> String { if arg.is_empty() { @@ -208,6 +210,18 @@ pub(crate) async fn run_tmux_new_session_command( workspace_key: &str, custom_description: &str, ) -> io::Result<()> { + if !command_exists("tmux") { + let debug_command = command + .split_first() + .map(|(program, args)| format_command_line(program, args)) + .unwrap_or_else(|| "".to_string()); + tracing::info!( + command = %debug_command, + "tmux unavailable; running interactive command directly" + ); + return run_interactive_command(terminal, env, &command).await; + } + restore_terminal(terminal)?; let session_name = generate_tmux_session_name(workspace_key); @@ -322,6 +336,58 @@ pub(crate) async fn run_tmux_new_session_command( } } +pub(crate) fn command_exists(command: &str) -> bool { + if command.contains('/') { + return is_executable_file(Path::new(command)); + } + + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path) + .map(|directory| directory.join(command)) + .any(|candidate| is_executable_file(&candidate)) +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +async fn run_interactive_command( + terminal: &mut Terminal>, + env: &[(String, String)], + command: &[String], +) -> io::Result<()> { + let Some((program, args)) = command.split_first() else { + return Err(io::Error::other("interactive command must not be empty")); + }; + + restore_terminal(terminal)?; + let status = Command::new(program) + .args(args) + .envs(env.iter().cloned()) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await; + let setup_result = setup_terminal().map(|new_terminal| { + *terminal = new_terminal; + }); + + match (status, setup_result) { + (_, Err(err)) => Err(err), + (Ok(status), Ok(())) if status.success() => Ok(()), + (Ok(status), Ok(())) => Err(io::Error::other(format!( + "interactive command exited with status {status}" + ))), + (Err(err), Ok(())) => Err(err), + } +} + pub(crate) async fn set_tmux_session_option( session_name: &str, option: &str, diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 866ebc3..09c5957 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -11,9 +11,9 @@ mod tests { pr_review_icon_color, }; use crate::ops::{ - SessionWaitState, attach_cli_args, build_handler_command, session_wait_state_for_entry, - tmux_session_command, tmux_status_left, validate_workspace_link_target, - workspace_attach_target, workspace_ordering, + SessionWaitState, attach_cli_args, build_handler_command, command_exists, + session_wait_state_for_entry, tmux_session_command, tmux_status_left, + validate_workspace_link_target, workspace_attach_target, workspace_ordering, }; use crate::render::selected_link_tooltip_area; use crate::system::{ @@ -21,9 +21,13 @@ mod tests { parse_proc_meminfo_total_ram_bytes, parse_proc_meminfo_used_ram_bytes, started_workspace_attach_ready, }; - use multicode_lib::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use multicode_lib::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, + }; use std::{ fs, + os::unix::fs::PermissionsExt, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; @@ -64,7 +68,11 @@ mod tests { persistent: PersistentWorkspaceSnapshot::default(), transient: uri.map(|uri| TransientWorkspaceSnapshot { uri: uri.to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: started.then(|| multicode_lib::OpencodeClientSnapshot { client: std::sync::Arc::new(multicode_lib::opencode::client::Client::new( @@ -88,7 +96,11 @@ mod tests { persistent: PersistentWorkspaceSnapshot::default(), transient: Some(TransientWorkspaceSnapshot { uri: "http://127.0.0.1".to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: None, root_session_id: None, @@ -222,6 +234,38 @@ mod tests { ); } + #[test] + fn command_exists_detects_executable_files_on_path() { + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + let tool_path = bin_dir.join("multicode-test-tool"); + fs::write(&tool_path, "#!/bin/sh\nexit 0\n").expect("tool should be written"); + let mut perms = fs::metadata(&tool_path) + .expect("tool metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&tool_path, perms).expect("tool should be executable"); + + let old_path = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", bin_dir.as_os_str()); + } + + assert!(command_exists("multicode-test-tool")); + assert!(!command_exists("missing-tool")); + + if let Some(path) = old_path { + unsafe { + std::env::set_var("PATH", path); + } + } else { + unsafe { + std::env::remove_var("PATH"); + } + } + } + #[test] fn tui_cli_args_accept_optional_relay_socket() { let parsed = crate::CliArgs::try_parse_from([ From 0b2652408661ea5ab63eace5cd693c916caeb6df Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 09:23:46 +0200 Subject: [PATCH 02/75] Initial support for using Apple containers for isolation on MacOS --- apple-container/Containerfile | 57 + apple-container/build-local.sh | 11 + lib/src/services/runtime.rs | 1880 +++++++++++++++++ .../runtime_reconciliation_service.rs | 159 ++ .../apple_container_runtime_integration.rs | 690 ++++++ 5 files changed, 2797 insertions(+) create mode 100644 apple-container/Containerfile create mode 100755 apple-container/build-local.sh create mode 100644 lib/src/services/runtime.rs create mode 100644 lib/src/services/runtime_reconciliation_service.rs create mode 100644 lib/tests/apple_container_runtime_integration.rs diff --git a/apple-container/Containerfile b/apple-container/Containerfile new file mode 100644 index 0000000..e048edc --- /dev/null +++ b/apple-container/Containerfile @@ -0,0 +1,57 @@ +FROM node:22-bookworm-slim AS node + +FROM ghcr.io/graalvm/native-image-community:25 + +ARG HOST_UID=1000 +ARG HOST_GID=1000 +ARG GH_VERSION=2.83.2 + +COPY --from=node /usr/local/ /usr/local/ + +RUN set -eux; \ + microdnf install -y \ + bash \ + ca-certificates \ + curl \ + git \ + openssh-clients \ + procps-ng \ + rsync \ + shadow-utils \ + tar \ + tmux \ + unzip \ + xz \ + zstd; \ + microdnf clean all; \ + arch="$(uname -m)"; \ + case "${arch}" in \ + aarch64|arm64) gh_arch="arm64" ;; \ + x86_64|amd64) gh_arch="amd64" ;; \ + *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + -o /tmp/gh.tar.gz; \ + tar -xzf /tmp/gh.tar.gz -C /tmp; \ + install "/tmp/gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \ + rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${gh_arch}"; \ + npm install -g opencode-ai; \ + if ! getent group "${HOST_GID}" >/dev/null; then \ + groupadd --gid "${HOST_GID}" multicode; \ + fi; \ + useradd \ + --uid "${HOST_UID}" \ + --gid "${HOST_GID}" \ + --create-home \ + --shell /bin/bash \ + multicode + +ENV HOME=/home/multicode +ENV USER=multicode +ENV PATH=/usr/local/bin:${PATH} + +USER multicode +WORKDIR /workspace +ENTRYPOINT [] + +CMD ["/bin/bash"] diff --git a/apple-container/build-local.sh b/apple-container/build-local.sh new file mode 100755 index 0000000..12d3da1 --- /dev/null +++ b/apple-container/build-local.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +exec container build \ + -t multicode-java25:latest \ + -f "$SCRIPT_DIR/Containerfile" \ + --build-arg "HOST_UID=$(id -u)" \ + --build-arg "HOST_GID=$(id -g)" \ + "$SCRIPT_DIR" diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs new file mode 100644 index 0000000..cb1697a --- /dev/null +++ b/lib/src/services/runtime.rs @@ -0,0 +1,1880 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::{Output, Stdio}, +}; + +use tokio::process::Command; +use uuid::Uuid; + +use super::{ + combined::{CombinedServiceError, SpawnCommand}, + config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, +}; +use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + +pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeActivity { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeUsageState { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct RuntimeUsageSample { + pub(super) memory_current: Option, + pub(super) cpu_usage_nsec: Option, + pub(super) state: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RuntimeStartResult { + pub(super) transient: TransientWorkspaceSnapshot, +} + +#[derive(Debug, Clone)] +struct RuntimeContext { + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + host_opencode_command: String, + container_opencode_command: String, +} + +#[derive(Debug, Clone)] +pub(super) enum WorkspaceRuntime { + Linux(LinuxSystemdBwrapRuntime), + AppleContainer(AppleContainerRuntime), +} + +impl WorkspaceRuntime { + pub(super) fn new( + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + host_opencode_command: String, + container_opencode_command: String, + ) -> Self { + let context = RuntimeContext { + runtime: runtime.clone(), + workspace_directory_path, + expanded_isolation, + host_opencode_command, + container_opencode_command, + }; + match runtime.backend { + RuntimeBackend::LinuxSystemdBwrap => Self::Linux(LinuxSystemdBwrapRuntime { context }), + RuntimeBackend::AppleContainer => { + Self::AppleContainer(AppleContainerRuntime { context }) + } + } + } + + pub(super) async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => runtime.start_server(key, inherited_env).await, + Self::AppleContainer(runtime) => runtime.start_server(key, inherited_env).await, + } + } + + pub(super) async fn stop_server( + &self, + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::stop_server(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::stop_server(runtime_handle).await + } + } + } + + pub(super) async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + match self { + Self::Linux(runtime) => runtime.build_pty_command(key, inherited_env, command).await, + Self::AppleContainer(runtime) => { + runtime.build_pty_command(key, inherited_env, command).await + } + } + } + + pub(super) async fn build_linux_start_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => { + runtime + .build_systemd_bwrap_command(key, password, port, unit, inherited_env) + .await + } + Self::AppleContainer(_) => Err(CombinedServiceError::UnsupportedRuntimeBackend( + "build_systemd_bwrap_command is only available for the linux-systemd-bwrap backend" + .to_string(), + )), + } + } + + pub(super) async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_activity(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_activity(runtime_handle).await + } + } + } + + pub(super) async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_usage(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_usage(runtime_handle).await + } + } + } + + pub(super) fn backend(&self) -> RuntimeBackend { + match self { + Self::Linux(_) => RuntimeBackend::LinuxSystemdBwrap, + Self::AppleContainer(_) => RuntimeBackend::AppleContainer, + } + } + + pub(super) fn runtime_spec(&self) -> String { + let context = match self { + Self::Linux(runtime) => &runtime.context, + Self::AppleContainer(runtime) => &runtime.context, + }; + + let mut parts = vec![ + format!("backend={:?}", context.runtime.backend), + format!( + "image={}", + context.runtime.image.as_deref().unwrap_or_default() + ), + format!("host-opencode={}", context.host_opencode_command), + format!("container-opencode={}", context.container_opencode_command), + format!( + "readable={}", + format_path_list(&context.expanded_isolation.readable) + ), + format!( + "writable={}", + format_path_list(&context.expanded_isolation.writable) + ), + format!( + "isolated={}", + format_path_list(&context.expanded_isolation.isolated) + ), + format!( + "tmpfs={}", + format_path_list(&context.expanded_isolation.tmpfs) + ), + format!( + "skills={}", + format_skill_mounts(&context.expanded_isolation.added_skills) + ), + format!( + "inherit-env={}", + context.expanded_isolation.inherit_env.join(",") + ), + format!( + "memory-high={}", + context + .expanded_isolation + .memory_high_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "memory-max={}", + context + .expanded_isolation + .memory_max_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "cpu={}", + context + .expanded_isolation + .cpu + .as_deref() + .unwrap_or_default() + ), + ]; + parts.push(format!( + "workspace-root={}", + context.workspace_directory_path.to_string_lossy() + )); + parts.join("\n") + } +} + +#[derive(Debug, Clone)] +pub(super) struct LinuxSystemdBwrapRuntime { + context: RuntimeContext, +} + +impl LinuxSystemdBwrapRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let unit = generate_linux_runtime_id(); + let command = self + .build_systemd_bwrap_command(key, &password, port, &unit, inherited_env) + .await?; + let mut process = Command::new(&command.program); + process + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .args(&command.args); + for (name, value) in &command.inherited_env { + process.env(name, value); + } + let output = process.output().await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: unit, + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::Linux(self.clone()).runtime_spec(), + )]), + }, + }, + }) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let args = vec![ + "--user".to_string(), + "stop".to_string(), + "--no-block".to_string(), + runtime_handle.id.clone(), + ]; + let output = Command::new("systemctl") + .args(args) + .stdin(Stdio::null()) + .output() + .await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let unit = generate_linux_runtime_id(); + let mut args = vec![ + "--user".to_string(), + "--wait".to_string(), + "--collect".to_string(), + "--pty".to_string(), + ]; + append_systemd_run_inherit_env(&mut args, inherited_env); + args.push("--unit".to_string()); + args.push(unit); + self.append_systemd_limits(&mut args); + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.extend(command); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: inherited_env.to_vec(), + }) + } + + async fn build_systemd_bwrap_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + let mut args = vec!["--user".to_string(), "--no-block".to_string()]; + let mut env = inherited_env.to_vec(); + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + append_systemd_run_inherit_env(&mut args, &env); + args.push("--unit".to_string()); + args.push(unit.to_string()); + self.append_systemd_limits(&mut args); + + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.push(self.context.host_opencode_command.clone()); + args.push("serve".to_string()); + args.push("--hostname".to_string()); + args.push("127.0.0.1".to_string()); + args.push("--port".to_string()); + args.push(port.to_string()); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: env, + }) + } + + fn append_systemd_limits(&self, args: &mut Vec) { + if let Some(memory_high_bytes) = self.context.expanded_isolation.memory_high_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryHigh={memory_high_bytes}")); + } + if let Some(memory_max_bytes) = self.context.expanded_isolation.memory_max_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryMax={memory_max_bytes}")); + args.push("-p".to_string()); + args.push("MemorySwapMax=0".to_string()); + } + if let Some(cpu) = &self.context.expanded_isolation.cpu { + args.push("-p".to_string()); + args.push(format!("CPUQuota={cpu}")); + } + } + + async fn append_bwrap_sandbox_args( + &self, + args: &mut Vec, + key: &str, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let workspace_path_str = workspace_path.to_string_lossy().into_owned(); + + args.push("bwrap".to_string()); + args.push("--chdir".to_string()); + args.push(workspace_path_str.clone()); + + args.push("--ro-bind".to_string()); + args.push("/".to_string()); + args.push("/".to_string()); + + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), + ); + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_args(args); + } + + args.push("--proc".to_string()); + args.push("/proc".to_string()); + args.push("--dev".to_string()); + args.push("/dev".to_string()); + args.push("--die-with-parent".to_string()); + + Ok(()) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.context + .workspace_directory_path + .join(".multicode") + .join("isolate") + .join(key) + .join(relative) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--value", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + + if !output.status.success() { + return RuntimeActivity::Stopped; + } + + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if matches!(state.as_str(), "active" | "activating") { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--property", + "MemoryCurrent", + "--property", + "CPUUsageNSec", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + if !output.status.success() { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + } + + parse_linux_unit_usage(&String::from_utf8_lossy(&output.stdout)) + } +} + +#[derive(Debug, Clone)] +pub(super) struct AppleContainerRuntime { + context: RuntimeContext, +} + +impl AppleContainerRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let container_name = self.container_name_for_key(key); + self.remove_container_if_present(&container_name).await?; + let command = self + .build_run_command(key, &container_name, &password, port, inherited_env) + .await?; + + let output = run_blocking_process(command.program.clone(), command.args.clone()).await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let mut metadata = BTreeMap::new(); + metadata.insert("workspace-key".to_string(), key.to_string()); + metadata.insert("port".to_string(), port.to_string()); + metadata.insert( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::AppleContainer(self.clone()).runtime_spec(), + ); + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: container_name, + metadata, + }, + }, + }) + } + + async fn remove_container_if_present( + &self, + container_name: &str, + ) -> Result<(), CombinedServiceError> { + let output = run_blocking_process( + container_program(), + vec![ + "rm".to_string(), + "-f".to_string(), + container_name.to_string(), + ], + ) + .await?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if container_delete_reports_missing(&stderr) { + return Ok(()); + } + tracing::warn!( + container_name, + status = output.status.code(), + stderr = %stderr, + "best-effort apple container preflight delete failed; continuing startup" + ); + Ok(()) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let output = run_blocking_process( + container_program(), + vec![ + "rm".to_string(), + "-f".to_string(), + runtime_handle.id.clone(), + ], + ) + .await?; + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() || container_delete_reports_missing(&stderr) { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: stderr.into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let image = self.context.runtime.image.as_deref().ok_or_else(|| { + CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: "apple-container backend requires a runtime image".to_string(), + } + })?; + let env_file = self.write_env_file(key, "exec.env", inherited_env).await?; + let workspace_path = self.context.workspace_directory_path.join(key); + let mut args = vec![ + "run".to_string(), + "--rm".to_string(), + "--tty".to_string(), + "--interactive".to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(args.as_mut(), key).await?; + args.push(image.to_string()); + args.extend(command); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let inspect_output = run_blocking_process( + container_program(), + vec!["inspect".to_string(), runtime_handle.id.clone()], + ) + .await; + if let Ok(output) = inspect_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains(r#""status":"running""#) { + return RuntimeActivity::Active; + } + if stdout.contains(r#""status":"stopped""#) + || stdout.contains(r#""status":"exited""#) + { + return RuntimeActivity::Stopped; + } + } else { + return RuntimeActivity::Stopped; + } + } + + let output = match run_blocking_process(container_program(), vec!["list".to_string()]).await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + if !output.status.success() { + return RuntimeActivity::Unknown; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout + .lines() + .any(|line| line.contains(runtime_handle.id.as_str())) + { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(_runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + } + } + + async fn build_run_command( + &self, + key: &str, + container_name: &str, + password: &str, + port: u16, + inherited_env: &[(String, String)], + ) -> Result { + let image = self.context.runtime.image.as_deref().ok_or_else(|| { + CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: "apple-container backend requires a runtime image".to_string(), + } + })?; + let mut env = inherited_env.to_vec(); + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + + let workspace_path = self.context.workspace_directory_path.join(key); + tokio::fs::create_dir_all(&workspace_path).await?; + let env_file = self.write_env_file(key, "server.env", &env).await?; + + let mut args = vec![ + "run".to_string(), + "--detach".to_string(), + "--rm".to_string(), + "--name".to_string(), + container_name.to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + "--publish".to_string(), + format!("127.0.0.1:{port}:{port}/tcp"), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(&mut args, key).await?; + args.push(image.to_string()); + args.push(self.context.container_opencode_command.clone()); + args.push("serve".to_string()); + args.push("--hostname".to_string()); + args.push("0.0.0.0".to_string()); + args.push("--port".to_string()); + args.push(port.to_string()); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + fn append_container_limits(&self, args: &mut Vec) { + if let Some(cpu) = self + .context + .expanded_isolation + .cpu + .as_deref() + .and_then(container_cpu_value) + { + args.push("--cpus".to_string()); + args.push(cpu); + } + + let memory_limit = self + .context + .expanded_isolation + .memory_max_bytes + .or(self.context.expanded_isolation.memory_high_bytes); + if let Some(memory_limit) = memory_limit { + args.push("--memory".to_string()); + args.push(memory_limit.to_string()); + } + } + + async fn append_container_mounts( + &self, + args: &mut Vec, + key: &str, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + if let Some(skill_mount) = self.build_aggregated_skill_mount(key).await? { + mount_specs.push(skill_mount); + } else { + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| { + MountSpec::new(mount.target, Some(mount.source), MountKind::Readable) + }), + ); + } + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + resolved_mount.prepare_target_node(owns_node).await?; + resolved_mount.prepare_container_materialized_file().await?; + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_container_args(args); + } + + Ok(()) + } + + async fn build_aggregated_skill_mount( + &self, + key: &str, + ) -> Result, CombinedServiceError> { + let added_skills = &self.context.expanded_isolation.added_skills; + if added_skills.is_empty() { + return Ok(None); + } + + let Some(target_root) = added_skills + .first() + .and_then(|mount| mount.target.parent()) + .map(Path::to_path_buf) + else { + return Ok(None); + }; + + if added_skills + .iter() + .any(|mount| mount.target.parent() != Some(target_root.as_path())) + { + return Ok(None); + } + + let aggregate_root = self.apple_runtime_root(key).join("skills"); + match tokio::fs::remove_dir_all(&aggregate_root).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + tokio::fs::create_dir_all(&aggregate_root).await?; + + if tokio::fs::metadata(&target_root).await.is_ok() { + copy_directory_tree(&target_root, &aggregate_root).await?; + } + + for skill in added_skills { + let Some(skill_name) = skill.target.file_name() else { + continue; + }; + copy_directory_tree(&skill.source, &aggregate_root.join(skill_name)).await?; + } + + Ok(Some(MountSpec::new( + target_root, + Some(aggregate_root), + MountKind::Readable, + ))) + } + + async fn write_env_file( + &self, + key: &str, + file_name: &str, + env: &[(String, String)], + ) -> Result { + let runtime_root = self.apple_runtime_root(key); + tokio::fs::create_dir_all(&runtime_root).await?; + let path = runtime_root.join(file_name); + let mut content = String::new(); + for (name, value) in env { + if value.contains('\n') || value.contains('\r') { + return Err(CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.env-file".to_string(), + message: format!( + "environment variable '{name}' contains newlines and cannot be written to a container env file" + ), + }); + } + content.push_str(name); + content.push('='); + content.push_str(value); + content.push('\n'); + } + tokio::fs::write(&path, content).await?; + Ok(path) + } + + fn apple_runtime_root(&self, key: &str) -> PathBuf { + self.context + .workspace_directory_path + .join(".multicode") + .join("apple-container") + .join(key) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.apple_runtime_root(key).join("isolate").join(relative) + } + + fn container_name_for_key(&self, key: &str) -> String { + format!("multicode-{}", key) + } +} + +fn format_path_list(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>() + .join(",") +} + +fn format_skill_mounts(skills: &[super::config::AddedSkillMount]) -> String { + let mut pairs = skills + .iter() + .map(|skill| { + format!( + "{}=>{}", + skill.source.to_string_lossy(), + skill.target.to_string_lossy() + ) + }) + .collect::>(); + pairs.sort(); + pairs.join(",") +} + +fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { + for (name, _) in env { + args.push("--setenv".to_string()); + args.push(name.clone()); + } +} + +fn generate_random_password() -> String { + Uuid::new_v4().as_simple().to_string() +} + +fn generate_linux_runtime_id() -> String { + format!("multicode-{}.service", Uuid::new_v4().as_simple()) +} + +async fn pick_random_free_port() -> Result { + if let Some(port) = std::env::var_os("MULTICODE_FIXED_PORT") { + let port = port.to_string_lossy(); + let parsed = + port.parse::() + .map_err(|err| CombinedServiceError::InvalidRuntimeConfig { + field: "MULTICODE_FIXED_PORT".to_string(), + message: err.to_string(), + })?; + return Ok(parsed); + } + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +fn parse_linux_unit_usage(output: &str) -> RuntimeUsageSample { + let mut active_state: Option<&str> = None; + let mut memory_current: Option = None; + let mut cpu_usage_nsec: Option = None; + + for line in output.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "ActiveState" => active_state = Some(value), + "MemoryCurrent" => memory_current = parse_systemctl_u64(value), + "CPUUsageNSec" => cpu_usage_nsec = parse_systemctl_u64(value), + _ => {} + } + } + + let state = match active_state.unwrap_or_default() { + "active" | "activating" => RuntimeUsageState::Active, + "" => RuntimeUsageState::Unknown, + _ => RuntimeUsageState::Stopped, + }; + + RuntimeUsageSample { + memory_current, + cpu_usage_nsec, + state: Some(state), + } +} + +fn parse_systemctl_u64(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "[not set]" { + return None; + } + trimmed.parse::().ok() +} + +fn container_cpu_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let cpus = if let Some(percent) = value.strip_suffix('%') { + percent.trim().parse::().ok()? / 100.0 + } else { + value.parse::().ok()? + }; + if !cpus.is_finite() || cpus <= 0.0 { + return None; + } + Some(cpus.ceil().max(1.0).to_string()) +} + +fn container_program() -> String { + std::env::var("MULTICODE_CONTAINER_COMMAND").unwrap_or_else(|_| "container".to_string()) +} + +fn container_delete_reports_missing(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("not found") + || stderr.contains("no such") + || stderr.contains("no matching containers") + || stderr.contains("does not exist") +} + +async fn run_blocking_process( + program: String, + args: Vec, +) -> Result { + tokio::task::spawn_blocking(move || { + std::process::Command::new(program) + .args(args) + .stdin(Stdio::null()) + .output() + }) + .await + .map_err(|err| std::io::Error::other(err.to_string()))? +} + +async fn copy_directory_tree(source: &Path, target: &Path) -> Result<(), std::io::Error> { + let mut pending = vec![(source.to_path_buf(), target.to_path_buf())]; + + while let Some((source_dir, target_dir)) = pending.pop() { + tokio::fs::create_dir_all(&target_dir).await?; + let mut entries = tokio::fs::read_dir(&source_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let metadata = tokio::fs::metadata(&source_path).await?; + if metadata.is_dir() { + pending.push((source_path, target_path)); + } else if metadata.is_file() { + tokio::fs::copy(&source_path, &target_path).await?; + } + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum MountKind { + Readable, + Writable, + Isolated, + Tmpfs, +} + +#[derive(Debug, Clone)] +pub(crate) struct MountSpec { + target: PathBuf, + source: Option, + kind: MountKind, + is_file: bool, +} + +impl MountSpec { + pub(crate) fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { + let is_file = match source.as_ref() { + Some(source) => std::fs::metadata(source) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + path_looks_like_file(source) || path_looks_like_file(&target) + }) + }), + None => std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| path_looks_like_file(&target)), + }; + Self { + target, + source, + kind, + is_file, + } + } + + fn depth(&self) -> usize { + self.target.components().count() + } + + fn resolve_backing_mount<'a>( + path: &Path, + prior_mounts: &'a [ResolvedMountSpec], + ) -> Option<&'a ResolvedMountSpec> { + prior_mounts.iter().rev().find(|prior_mount| { + path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) + }) + } + + fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { + if let Some(prior_mount) = Self::resolve_backing_mount(path, prior_mounts) { + let relative = path + .strip_prefix(&prior_mount.mount.target) + .expect("path should be under prior mount target"); + prior_mount.effective_source.join(relative) + } else { + path.to_path_buf() + } + } + + pub(crate) fn resolve_effective( + &self, + prior_mounts: &[ResolvedMountSpec], + ) -> ResolvedMountSpec { + let backing_mount_kind = + Self::resolve_backing_mount(&self.target, prior_mounts).map(|mount| mount.mount.kind); + let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); + let effective_source = match self.kind { + MountKind::Isolated => self + .source + .as_ref() + .map(|source| Self::resolve_backing_path(source, prior_mounts)) + .unwrap_or_else(|| effective_target.clone()), + MountKind::Readable | MountKind::Writable => { + self.source.clone().unwrap_or_else(|| self.target.clone()) + } + MountKind::Tmpfs => effective_target.clone(), + }; + ResolvedMountSpec { + mount: self.clone(), + backing_mount_kind, + effective_target, + effective_source, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedMountSpec { + mount: MountSpec, + backing_mount_kind: Option, + effective_target: PathBuf, + effective_source: PathBuf, +} + +impl ResolvedMountSpec { + pub(crate) async fn prepare_source_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + self.prepare_node( + &self.effective_source, + owns_node, + self.mount + .source + .as_ref() + .filter(|original| *original != &self.effective_source), + ) + .await + } + + pub(crate) async fn prepare_target_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + let should_materialize = if self.mount.is_file { owns_node } else { true }; + self.prepare_node(&self.effective_target, should_materialize, None) + .await + } + + pub(crate) async fn prepare_container_materialized_file( + &self, + ) -> Result<(), CombinedServiceError> { + if !self.should_materialize_container_file() { + return Ok(()); + } + + if let Some(parent) = self.effective_target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + if tokio::fs::metadata(&self.effective_source).await.is_ok() { + tokio::fs::copy(&self.effective_source, &self.effective_target).await?; + } else if tokio::fs::metadata(&self.effective_target).await.is_err() { + tokio::fs::File::create(&self.effective_target).await?; + } + + Ok(()) + } + + async fn prepare_node( + &self, + path: &Path, + materialize_node: bool, + seed_file: Option<&PathBuf>, + ) -> Result<(), CombinedServiceError> { + if self.mount.is_file { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + if materialize_node && tokio::fs::metadata(path).await.is_err() { + if let Some(seed_file) = seed_file { + if tokio::fs::metadata(seed_file).await.is_ok() { + tokio::fs::copy(seed_file, path).await?; + return Ok(()); + } + } + tokio::fs::File::create(path).await?; + } + } else if materialize_node { + tokio::fs::create_dir_all(path).await?; + } else if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + Ok(()) + } + + fn append_args(&self, args: &mut Vec) { + match self.mount.kind { + MountKind::Readable => { + args.push("--ro-bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Writable | MountKind::Isolated => { + args.push("--bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + } + } + + fn append_container_args(&self, args: &mut Vec) { + if self.should_materialize_container_file() { + return; + } + + match self.mount.kind { + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Readable | MountKind::Writable | MountKind::Isolated => { + args.push("--mount".to_string()); + let mut mount = format!( + "type=bind,source={},target={}", + self.effective_source.to_string_lossy(), + self.mount.target.to_string_lossy() + ); + if matches!(self.mount.kind, MountKind::Readable) { + mount.push_str(",readonly"); + } + args.push(mount); + } + } + } + + fn should_materialize_container_file(&self) -> bool { + self.mount.kind == MountKind::Readable + && self.mount.is_file + && self.backing_mount_kind == Some(MountKind::Isolated) + && self.effective_target != self.mount.target + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::config::{AddedSkillMount, IsolationConfig}; + use std::fs; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "multicode-runtime-test-{}-{}", + std::process::id(), + Uuid::new_v4().as_simple() + )); + fs::create_dir_all(&path).expect("test dir should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn apple_runtime(root: &TestDir, isolation: IsolationConfig) -> AppleContainerRuntime { + let expanded_isolation = + ExpandedIsolationConfig::from_config(&isolation, None).expect("config should expand"); + AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: root.path().join("workspaces"), + expanded_isolation, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + } + } + + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { + args.windows(sequence.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(sequence.iter().copied()) + }) + } + + #[test] + fn container_cpu_value_converts_percent_to_cpu_count() { + assert_eq!(container_cpu_value("300%"), Some("3".to_string())); + assert_eq!(container_cpu_value("150%"), Some("2".to_string())); + assert_eq!(container_cpu_value("2"), Some("2".to_string())); + assert_eq!(container_cpu_value("1.5"), Some("2".to_string())); + assert_eq!(container_cpu_value(""), None); + } + + #[test] + fn container_delete_reports_missing_matches_common_container_rm_errors() { + assert!(container_delete_reports_missing( + "Error: failed to delete one or more containers: [\"multicode-alpha\"]: no matching containers found" + )); + assert!(container_delete_reports_missing( + "Error: container not found" + )); + assert!(container_delete_reports_missing("Error: No such container")); + assert!(!container_delete_reports_missing( + "Error: failed to delete one or more containers: permission denied" + )); + } + + #[test] + fn apple_container_run_command_honors_limits_and_mounts() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let readable = root.path().join("readonly"); + let writable = root.path().join("writable"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&readable).expect("readable should exist"); + fs::create_dir_all(&writable).expect("writable should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![readable.to_string_lossy().into_owned()], + writable: vec![writable.to_string_lossy().into_owned()], + isolated: vec!["/var/tmp".to_string()], + tmpfs: vec!["/tmp".to_string()], + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: Some("8 GB".to_string()), + memory_max: Some("10 GB".to_string()), + cpu: Some("300%".to_string()), + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--detach", "--rm"] + )); + assert!(contains_sequence( + &command.args, + &["--name", "multicode-alpha"] + )); + assert!(contains_sequence(&command.args, &["--cpus", "3"])); + assert!(contains_sequence( + &command.args, + &["--memory", "10000000000"] + )); + assert!(contains_sequence( + &command.args, + &["--publish", "127.0.0.1:31337:31337/tcp"] + )); + assert!(contains_sequence(&command.args, &["--tmpfs", "/tmp"])); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("type=bind") && arg.contains("readonly")) + ); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("/var/tmp") && arg.contains("type=bind")) + ); + assert!(contains_sequence( + &command.args, + &[ + "ghcr.io/example/multicode-java25:latest", + "opencode", + "serve", + "--hostname", + "0.0.0.0", + "--port", + "31337" + ] + )); + }); + } + + #[test] + fn apple_container_pty_command_uses_one_shot_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + fs::create_dir_all(workspace_root.join("alpha")).expect("workspace should exist"); + let runtime = apple_runtime(&root, IsolationConfig::default()); + + let command = runtime + .build_pty_command( + "alpha", + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + vec!["/bin/bash".to_string()], + ) + .await + .expect("pty command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--rm", "--tty", "--interactive"] + )); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + assert!(command.inherited_env.is_empty()); + }); + } + + #[test] + fn apple_container_materializes_nested_readable_file_inside_isolated_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let auth_dir = home.join(".local/share/opencode"); + let auth_file = auth_dir.join("auth.json"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&auth_dir).expect("auth directory should exist"); + fs::write(&auth_file, r#"{"token":"apple"}"#).expect("auth file should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![auth_file.to_string_lossy().into_owned()], + writable: Vec::new(), + isolated: vec![auth_dir.to_string_lossy().into_owned()], + tmpfs: Vec::new(), + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: None, + memory_max: None, + cpu: None, + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + home.to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + let isolated_storage = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("isolate") + .join( + auth_dir + .strip_prefix("/") + .expect("auth directory should be absolute"), + ); + let materialized_auth = isolated_storage.join("auth.json"); + let host_auth_mount = format!( + "type=bind,source={},target={}", + auth_file.to_string_lossy(), + auth_file.to_string_lossy() + ); + let isolated_dir_mount = format!( + "type=bind,source={},target={}", + isolated_storage.to_string_lossy(), + auth_dir.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &isolated_dir_mount), + "isolated parent directory should still be mounted" + ); + assert!( + command.args.iter().all(|arg| arg != &host_auth_mount), + "nested readable file should be materialized into the isolated backing tree instead of emitted as a separate bind mount" + ); + assert_eq!( + fs::read_to_string(&materialized_auth).expect("materialized auth should exist"), + r#"{"token":"apple"}"# + ); + }); + } + + #[test] + fn apple_container_coalesces_added_skills_into_single_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let skills_root = root.path().join("workspace-skills"); + let skill_one = skills_root.join("skill-one"); + let skill_two = skills_root.join("skill-two"); + let container_skills_target = + root.path().join("container-home/.config/opencode/skills"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&skill_one).expect("first skill should exist"); + fs::create_dir_all(&skill_two).expect("second skill should exist"); + fs::write(skill_one.join("SKILL.md"), "# one").expect("first skill file should exist"); + fs::write(skill_two.join("SKILL.md"), "# two").expect("second skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: Vec::new(), + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![ + AddedSkillMount { + source: skill_one.clone(), + target: container_skills_target.join("skill-one"), + }, + AddedSkillMount { + source: skill_two.clone(), + target: container_skills_target.join("skill-two"), + }, + ], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + container_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should mount one aggregated skills directory" + ); + assert!( + command + .args + .iter() + .all(|arg| !arg.contains("container-home/.config/opencode/skills/skill-one")), + "individual skill mounts should be omitted" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-one/SKILL.md")) + .expect("aggregated skill one should exist"), + "# one" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-two/SKILL.md")) + .expect("aggregated skill two should exist"), + "# two" + ); + }); + } + + #[test] + fn apple_container_aggregated_skill_mount_preserves_host_skills() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let host_home = root.path().join("host-home"); + let host_skills_target = host_home.join(".config/opencode/skills"); + let host_skill = host_skills_target.join("host-skill"); + let added_skills_root = root.path().join("workspace-skills"); + let added_skill = added_skills_root.join("workspace-skill"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&host_skill).expect("host skill should exist"); + fs::create_dir_all(&added_skill).expect("added skill should exist"); + fs::write(host_skill.join("SKILL.md"), "# host").expect("host skill file should exist"); + fs::write(added_skill.join("SKILL.md"), "# workspace") + .expect("added skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: vec![host_home.join(".config/opencode")], + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![AddedSkillMount { + source: added_skill.clone(), + target: host_skills_target.join("workspace-skill"), + }], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + host_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should expose a merged skills directory" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("host-skill/SKILL.md")) + .expect("host skill should be preserved"), + "# host" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("workspace-skill/SKILL.md")) + .expect("workspace skill should be included"), + "# workspace" + ); + }); + } +} diff --git a/lib/src/services/runtime_reconciliation_service.rs b/lib/src/services/runtime_reconciliation_service.rs new file mode 100644 index 0000000..6c4f202 --- /dev/null +++ b/lib/src/services/runtime_reconciliation_service.rs @@ -0,0 +1,159 @@ +use std::sync::Arc; + +use tokio::sync::watch; + +use super::{ + runtime::{RUNTIME_SPEC_METADATA_KEY, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + RuntimeBackend, TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, + WorkspaceSnapshot, manager::Workspace, +}; + +#[derive(Debug)] +#[allow(dead_code)] +pub(super) enum RuntimeReconciliationServiceError { + Manager(WorkspaceManagerError), +} + +impl From for RuntimeReconciliationServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub(super) async fn runtime_reconciliation_service( + manager: Arc, + runtime: WorkspaceRuntime, +) -> Result<(), RuntimeReconciliationServiceError> { + let expected_backend = runtime.backend(); + let expected_spec = runtime.runtime_spec(); + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let runtime = runtime.clone(); + let expected_spec = expected_spec.clone(); + async move { + tokio::spawn(async move { + watch_workspace_snapshot( + key, + workspace, + workspace_rx, + runtime, + expected_backend, + expected_spec, + ) + .await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace_snapshot( + key: String, + workspace: Workspace, + mut workspace_rx: watch::Receiver, + runtime: WorkspaceRuntime, + expected_backend: RuntimeBackend, + expected_spec: String, +) { + loop { + let current_transient = workspace_rx.borrow().transient.clone(); + if let Some(transient) = current_transient { + if should_invalidate_runtime(&transient, expected_backend, &expected_spec) { + tracing::info!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + expected_backend = ?expected_backend, + actual_backend = ?transient.runtime.backend, + "stopping stale workspace runtime because the runtime specification changed" + ); + if let Err(err) = runtime.stop_server(&transient.runtime).await { + tracing::warn!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + error = ?err, + "failed to stop stale workspace runtime during reconciliation" + ); + } + workspace.update(|snapshot| { + if snapshot.transient.as_ref() == Some(&transient) { + snapshot.transient = None; + true + } else { + false + } + }); + } + } + + if workspace_rx.changed().await.is_err() { + break; + } + } +} + +fn should_invalidate_runtime( + transient: &TransientWorkspaceSnapshot, + expected_backend: RuntimeBackend, + expected_spec: &str, +) -> bool { + if transient.runtime.backend != expected_backend { + return true; + } + + transient.runtime.backend == RuntimeBackend::AppleContainer + && transient + .runtime + .metadata + .get(RUNTIME_SPEC_METADATA_KEY) + .map(String::as_str) + != Some(expected_spec) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + use std::collections::BTreeMap; + + #[test] + fn runtime_reconciliation_invalidates_apple_runtime_without_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::new(), + }, + }; + + assert!(should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } + + #[test] + fn runtime_reconciliation_keeps_apple_runtime_with_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + "expected".to_string(), + )]), + }, + }; + + assert!(!should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } +} diff --git a/lib/tests/apple_container_runtime_integration.rs b/lib/tests/apple_container_runtime_integration.rs new file mode 100644 index 0000000..a06e602 --- /dev/null +++ b/lib/tests/apple_container_runtime_integration.rs @@ -0,0 +1,690 @@ +use std::{ + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use multicode_lib::{RuntimeBackend, services::CombinedService}; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let root = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::current_dir() + .expect("current directory should be available") + .join("target") + .join("test-tmp") + }); + let path = root.join(format!( + "multicode-apple-container-integration-{}-{}", + std::process::id(), + unique + )); + fs::create_dir_all(&path).expect("test root should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct EnvVarGuard { + key: &'static str, + old_value: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, old_value } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + std::env::set_var(self.key, value); + } + } else { + unsafe { + std::env::remove_var(self.key); + } + } + } +} + +fn make_executable(path: &Path) { + let mut perms = fs::metadata(path) + .expect("executable metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("permissions should be updated"); +} + +fn write_fake_container_cli(path: &Path) { + fs::write( + path, + r#"#!/bin/bash +set -euo pipefail +root="${MULTICODE_FAKE_CONTAINER_ROOT:?missing MULTICODE_FAKE_CONTAINER_ROOT}" +state_dir="$root/state" +mkdir -p "$state_dir" +printf '%s\n' "$*" >> "$root/commands.log" + +cmd="${1:-}" +shift || true +case "$cmd" in + run) + name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --name) + name="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + if [ -n "$name" ]; then + : > "$state_dir/$name" + fi + ;; + rm) + if [ "${1:-}" = "-f" ] && [ -n "${2:-}" ]; then + rm -f "$state_dir/$2" + fi + ;; + inspect) + if [ -n "${1:-}" ] && [ -e "$state_dir/$1" ]; then + printf '[{\"status\":\"running\"}]\n' + exit 0 + fi + exit 1 + ;; + list) + for file in "$state_dir"/*; do + [ -e "$file" ] || continue + basename "$file" + done + ;; + *) + ;; +esac +"#, + ) + .expect("fake container script should be written"); + make_executable(path); +} + +fn write_fake_opencode(path: &Path) { + fs::write(path, "#!/bin/bash\nexit 0\n").expect("fake opencode should be written"); + make_executable(path); +} + +fn read_commands(path: &Path) -> Vec { + fs::read_to_string(path) + .expect("commands log should be readable") + .lines() + .map(ToOwned::to_owned) + .collect() +} + +#[test] +fn starts_and_stops_workspace_with_apple_container_backend() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["{home}/.gradle", "{home}/.config/gh"] +isolated = ["{home}/.local/share/opencode", "{home}/.local/state/opencode", "/var/tmp"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "16 GiB" +cpu = "300%" +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + assert_eq!(transient.runtime.id, "multicode-alpha"); + assert!(transient.uri.starts_with("http://opencode:")); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!(run_command.contains("--name multicode-alpha")); + assert!(run_command.contains("--cpus 3")); + assert!(run_command.contains("--memory 17179869184")); + assert!(run_command.contains("--tmpfs /tmp")); + assert!(run_command.contains("ghcr.io/example/multicode-java25:latest")); + assert!(run_command.contains("opencode serve --hostname 0.0.0.0")); + + let server_env = workspace_directory + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!(env_contents.contains("OPENCODE_SERVER_USERNAME=opencode")); + assert!(env_contents.contains("OPENCODE_SERVER_PASSWORD=")); + assert!(env_contents.contains(&format!("HOME={}", home.display()))); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + + let stopped = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + if snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(stopped.is_ok(), "workspace should clear transient state"); + + let commands = read_commands(&fake_container_root.join("commands.log")); + assert!( + commands.iter().any(|line| line == "rm -f multicode-alpha"), + "stop should remove the container" + ); + }); +} + +#[test] +fn start_workspace_removes_stale_named_container_before_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start after removing stale container"); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let stale_rm_index = commands + .iter() + .position(|line| line == "rm -f multicode-alpha") + .expect("stale container should be removed before start"); + let run_index = commands + .iter() + .position(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!( + stale_rm_index < run_index, + "stale container removal should happen before run" + ); + }); +} + +#[test] +fn build_exec_tool_command_uses_one_shot_apple_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(workspace_directory.join("alpha")).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = EnvVarGuard::set( + "MULTICODE_FAKE_CONTAINER_ROOT", + root.path().join("fake-root"), + ); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "8 GiB" +cpu = "200%" +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let command = service + .build_exec_tool_command("alpha", "/bin/bash") + .await + .expect("exec tool command should build"); + assert_eq!(command.program, bin_dir.join("container").to_string_lossy()); + assert_eq!(command.inherited_env, Vec::<(String, String)>::new()); + assert!( + command.args.windows(4).any(|window| { + window + == ["run", "--rm", "--tty", "--interactive"] + .iter() + .map(|v| v.to_string()) + .collect::>() + }), + "apple backend should use one-shot container run for PTY tools" + ); + assert!(command.args.iter().any(|arg| arg == "--cpus")); + assert!(command.args.iter().any(|arg| arg == "2")); + assert!(command.args.iter().any(|arg| arg == "--memory")); + assert!(command.args.iter().any(|arg| arg == "8589934592")); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + }); +} + +#[test] +fn stale_apple_container_transient_is_cleared_on_startup() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let workspace_path = workspace_directory.join("alpha"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let transient_dir = root.path().join("transient-store"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_path).expect("workspace should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&transient_dir).expect("transient dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let transient_link = workspace_directory.join(".multicode").join("transient"); + fs::create_dir_all( + transient_link + .parent() + .expect("transient link parent should be available"), + ) + .expect("transient link parent should exist"); + std::os::unix::fs::symlink(&transient_dir, &transient_link) + .expect("transient link should be created"); + fs::write( + transient_dir.join("alpha.json"), + serde_json::to_vec_pretty(&multicode_lib::TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: multicode_lib::RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: std::collections::BTreeMap::new(), + }, + }) + .expect("transient snapshot should serialize"), + ) + .expect("transient snapshot should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +readable = ["{home}/.config/opencode"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let commands_log = fake_container_root.join("commands.log"); + let cleared = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let removed_stale_container = fs::read_to_string(&commands_log) + .map(|content| content.lines().any(|line| line == "rm -f multicode-alpha")) + .unwrap_or(false); + if removed_stale_container && snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(cleared.is_ok(), "stale transient should be cleared"); + + let commands = read_commands(&commands_log); + assert!( + commands.iter().any(|line| line == "rm -f multicode-alpha"), + "stale apple container should be removed during reconciliation" + ); + }); +} + +#[test] +#[ignore = "requires a real Apple container image with opencode installed"] +fn real_apple_container_backend_starts_and_stops_with_supplied_image() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let image = std::env::var("MULTICODE_APPLE_CONTAINER_TEST_IMAGE").expect( + "set MULTICODE_APPLE_CONTAINER_TEST_IMAGE to a real image that contains opencode", + ); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "{image}" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "4 GiB" +cpu = "100%" +"#, + workspace_directory = workspace_directory.display(), + image = image, + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start with real container backend"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + }); +} From af4b74248c345f087afe5420bae6a119fea5fe5f Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 10:41:14 +0200 Subject: [PATCH 03/75] ensure git config is mounted in Apple containers --- README.md | 5 +- config.toml | 1 + lib/src/services/combined.rs | 177 +++++++++++------------------------ lib/src/services/runtime.rs | 70 +++++++++++++- 4 files changed, 129 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 064b936..5cde608 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ image = "ghcr.io/example/multicode-java25:latest" [isolation] writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] -readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] +readable = ["~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json"] isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] tmpfs = ["/tmp"] inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] @@ -61,6 +61,9 @@ skills, and other OpenCode configuration as the host. This is useful if you mana profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` isolated so session state remains per-workspace. +Mounting `~/.gitconfig` read-only lets the container see your global git identity and defaults. +Repo-local `.git/config` settings still override the global file. + ## Git / GitHub integration With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR diff --git a/config.toml b/config.toml index b51cd91..7f60c77 100644 --- a/config.toml +++ b/config.toml @@ -35,6 +35,7 @@ isolated = [ "~/.local/state/opencode", ] readable = [ + "~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json", ] diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index a0815a5..3f88af4 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -466,27 +466,7 @@ impl CombinedService { } fn github_git_credentials_env_vars(&self) -> Vec<(String, String)> { - let Some(github_git_credentials_env) = &self.github_git_credentials_env else { - return Vec::new(); - }; - - let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; - vec![ - ( - "MULTICODE_GITHUB_USERNAME".to_string(), - github_git_credentials_env.username.clone(), - ), - ( - "MULTICODE_GITHUB_TOKEN".to_string(), - github_git_credentials_env.token.clone(), - ), - ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), - ( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ), - ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), - ] + github_git_credentials_env_vars(self.github_git_credentials_env.as_ref()) } async fn compress_directory_to_archive( @@ -675,6 +655,40 @@ fn resolve_container_opencode_command( .unwrap_or_else(|| "opencode".to_string()) } +fn github_git_credentials_env_vars( + github_git_credentials_env: Option<&GithubGitCredentialsEnv>, +) -> Vec<(String, String)> { + let Some(github_git_credentials_env) = github_git_credentials_env else { + return Vec::new(); + }; + + let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; + vec![ + ( + "MULTICODE_GITHUB_USERNAME".to_string(), + github_git_credentials_env.username.clone(), + ), + ( + "MULTICODE_GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GH_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ), + ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), + ] +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1525,110 +1539,29 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] #[test] fn github_git_credentials_env_vars_include_helper_and_secrets() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); - - runtime.block_on(async { - let _env_lock = ENV_VAR_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let root = TestDir::new(); - let home = root.path().join("home"); - let runtime_dir = root.path().join("runtime"); - let github_api_dir = root.path().join("github-api"); - let github_server = github_api_dir.join("server.py"); - let github_port = 38492; - fs::create_dir_all(&home).expect("home should exist"); - fs::create_dir_all(&runtime_dir).expect("runtime should exist"); - fs::create_dir_all(&github_api_dir).expect("github api dir should exist"); - let workspace_directory = home.join("workspaces"); - fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); - - fs::write( - &github_server, - format!( - r#"from http.server import BaseHTTPRequestHandler, HTTPServer -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/user": - body = b'{{"login":"sandbox-user","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://example.com/avatar","gravatar_id":"","url":"https://api.github.com/users/sandbox-user","html_url":"https://github.com/sandbox-user","followers_url":"https://api.github.com/users/sandbox-user/followers","following_url":"https://api.github.com/users/sandbox-user/following{{/other_user}}","gists_url":"https://api.github.com/users/sandbox-user/gists{{/gist_id}}","starred_url":"https://api.github.com/users/sandbox-user/starred{{/owner}}{{/repo}}","subscriptions_url":"https://api.github.com/users/sandbox-user/subscriptions","organizations_url":"https://api.github.com/users/sandbox-user/orgs","repos_url":"https://api.github.com/users/sandbox-user/repos","events_url":"https://api.github.com/users/sandbox-user/events{{/privacy}}","received_events_url":"https://api.github.com/users/sandbox-user/received_events","type":"User","site_admin":false,"name":"Sandbox User","company":null,"blog":"","location":null,"email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","private_gists":0,"total_private_repos":0,"owned_private_repos":0,"disk_usage":0,"collaborators":0,"two_factor_authentication":false}}' - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - def log_message(self, format, *args): - pass -HTTPServer(("127.0.0.1", {github_port}), Handler).serve_forever() -"# - ), - ) - .expect("github server script should be written"); - let mut github_process = std::process::Command::new("python3") - .arg(&github_server) - .spawn() - .expect("github api server should start"); - std::thread::sleep(std::time::Duration::from_millis(250)); - - let _home_guard = EnvVarGuard::set("HOME", &home); - let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); - unsafe { - std::env::set_var("MULTICODE_GITHUB_TEST_TOKEN", "secret-token"); - std::env::set_var("GITHUB_API_URL", format!("http://127.0.0.1:{github_port}")); - } - - let config: Config = toml::from_str( - &format!( - r#"workspace-directory = "{}" - -[github] -populate-git-credentials = true -token = {{ env = "MULTICODE_GITHUB_TEST_TOKEN" }} - -[isolation] -"#, - workspace_directory.display() - ), - ) - .expect("config should parse"); - - let service = CombinedService::from_config(config) - .await - .expect("combined service should start"); - let env_vars = service.github_git_credentials_env_vars(); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_USERNAME".to_string(), - "sandbox-user".to_string(), - ))); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_TOKEN".to_string(), - "secret-token".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_COUNT".to_string(), - "1".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ))); - assert!(env_vars.contains(&( + let env_vars = github_git_credentials_env_vars(Some(&GithubGitCredentialsEnv { + username: "sandbox-user".to_string(), + token: "secret-token".to_string(), + })); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_USERNAME".to_string(), + "sandbox-user".to_string(), + ))); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_TOKEN".to_string(), + "secret-token".to_string(), + ))); + assert!(env_vars.contains(&("GH_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GITHUB_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GIT_CONFIG_COUNT".to_string(), "1".to_string(),))); + assert!(env_vars.contains(&( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ))); + assert!(env_vars.contains(&( "GIT_CONFIG_VALUE_0".to_string(), r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#.to_string(), ))); - - unsafe { - std::env::remove_var("MULTICODE_GITHUB_TEST_TOKEN"); - std::env::remove_var("GITHUB_API_URL"); - } - let _ = github_process.kill(); - let _ = github_process.wait(); - }); } #[test] diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index cb1697a..6ad0c45 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::{ combined::{CombinedServiceError, SpawnCommand}, - config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, + config::{ExpandedIsolationConfig, RuntimeConfig, expand_shell_path, path_looks_like_file}, }; use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; @@ -897,6 +897,7 @@ impl AppleContainerRuntime { }), ); } + mount_specs.extend(self.implicit_readable_mounts(&mount_specs)); mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -929,6 +930,24 @@ impl AppleContainerRuntime { Ok(()) } + fn implicit_readable_mounts(&self, existing_mounts: &[MountSpec]) -> Vec { + let Some(gitconfig) = expand_shell_path("~/.gitconfig") + .ok() + .filter(|path| path.is_absolute() && path.is_file()) + else { + return Vec::new(); + }; + + if existing_mounts + .iter() + .any(|mount| mount.target == gitconfig) + { + return Vec::new(); + } + + vec![MountSpec::new(gitconfig, None, MountKind::Readable)] + } + async fn build_aggregated_skill_mount( &self, key: &str, @@ -1579,6 +1598,55 @@ mod tests { }); } + #[test] + fn apple_container_implicitly_mounts_host_gitconfig() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let gitconfig = home.join(".gitconfig"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(&gitconfig, "[user]\nname = Test User\n").expect("gitconfig should exist"); + + let previous_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &home); + } + + let runtime = apple_runtime(&root, IsolationConfig::default()); + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + if let Some(previous_home) = previous_home { + unsafe { + std::env::set_var("HOME", previous_home); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + + let gitconfig_mount = format!( + "type=bind,source={},target={},readonly", + gitconfig.to_string_lossy(), + gitconfig.to_string_lossy() + ); + assert!( + command.args.iter().any(|arg| arg == &gitconfig_mount), + "apple backend should implicitly mount ~/.gitconfig read-only" + ); + }); + } + #[test] fn apple_container_pty_command_uses_one_shot_container_run() { let runtime = tokio::runtime::Builder::new_current_thread() From e7a41dae739e9dcc6eb3772d4807042dd5fbcb28 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 10:51:13 +0200 Subject: [PATCH 04/75] fix git config for apple containers --- README.md | 7 +- config.toml | 1 - lib/src/services/combined.rs | 154 +++++++++++++++++++++++++++++++++++ lib/src/services/runtime.rs | 133 ++++++++++++++++++++++++------ 4 files changed, 268 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5cde608..51f33a8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ image = "ghcr.io/example/multicode-java25:latest" [isolation] writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] -readable = ["~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json"] +readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] tmpfs = ["/tmp"] inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] @@ -61,8 +61,9 @@ skills, and other OpenCode configuration as the host. This is useful if you mana profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` isolated so session state remains per-workspace. -Mounting `~/.gitconfig` read-only lets the container see your global git identity and defaults. -Repo-local `.git/config` settings still override the global file. +Apple workspaces also expose the host `~/.gitconfig` automatically. The runtime mounts it through +an internal read-only path and sets `GIT_CONFIG_GLOBAL` so git can use your host global identity +and defaults without requiring a direct file bind. ## Git / GitHub integration diff --git a/config.toml b/config.toml index 7f60c77..b51cd91 100644 --- a/config.toml +++ b/config.toml @@ -35,7 +35,6 @@ isolated = [ "~/.local/state/opencode", ] readable = [ - "~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json", ] diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 3f88af4..0d27463 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -180,6 +180,7 @@ impl CombinedService { let workspace = self.manager.get_workspace(&key)?; let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; + strip_workspace_git_identity_overrides(&workspace_path).await?; let inherited_env = self .sandbox_env_pairs(Vec::<(String, String)>::new()) @@ -689,6 +690,74 @@ fn github_git_credentials_env_vars( ] } +async fn strip_workspace_git_identity_overrides( + workspace_path: &Path, +) -> Result<(), CombinedServiceError> { + let workspace_path = workspace_path.to_path_buf(); + let repo_roots = tokio::task::spawn_blocking(move || find_git_repo_roots(&workspace_path)) + .await + .map_err(|err| std::io::Error::other(err.to_string()))??; + + for repo_root in repo_roots { + unset_repo_local_git_config(&repo_root, "user.name").await?; + unset_repo_local_git_config(&repo_root, "user.email").await?; + } + + Ok(()) +} + +fn find_git_repo_roots(workspace_path: &Path) -> Result, std::io::Error> { + let mut stack = vec![workspace_path.to_path_buf()]; + let mut repo_roots = std::collections::BTreeSet::new(); + + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if entry.file_name() == ".git" { + repo_roots.insert(directory.clone()); + continue; + } + if file_type.is_dir() { + stack.push(path); + } + } + } + + Ok(repo_roots.into_iter().collect()) +} + +async fn unset_repo_local_git_config( + repo_root: &Path, + key: &str, +) -> Result<(), CombinedServiceError> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["config", "--local", "--unset-all", key]) + .stdin(Stdio::null()) + .output() + .await?; + + if output.status.success() || output.status.code() == Some(5) { + return Ok(()); + } + + Err(std::io::Error::other(format!( + "failed to remove repo-local git config {key} from {}: {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + )) + .into()) +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1564,6 +1633,91 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] ))); } + #[test] + fn strip_workspace_git_identity_overrides_removes_repo_local_user_identity() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace = root.path().join("workspace"); + let repo = workspace.join("repo"); + fs::create_dir_all(&repo).expect("repo dir should exist"); + + let init = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["init"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git init should run"); + assert!(init.status.success(), "git init should succeed"); + + let set_name = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.name", "Local Name"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config user.name should run"); + assert!( + set_name.status.success(), + "git config user.name should succeed" + ); + + let set_email = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.email", "local@example.com"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config user.email should run"); + assert!( + set_email.status.success(), + "git config user.email should succeed" + ); + + strip_workspace_git_identity_overrides(&workspace) + .await + .expect("workspace git identity cleanup should succeed"); + + let get_name = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.name"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config get user.name should run"); + assert_eq!(get_name.status.code(), Some(1)); + + let get_email = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.email"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config get user.email should run"); + assert_eq!(get_email.status.code(), Some(1)); + + let remote = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "core.repositoryformatversion"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config core.repositoryformatversion should run"); + assert!(remote.status.success(), "repo config should remain intact"); + }); + } + #[test] fn start_workspace_builds_expected_isolation_command_arguments() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index 6ad0c45..c98d4c3 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -9,11 +9,13 @@ use uuid::Uuid; use super::{ combined::{CombinedServiceError, SpawnCommand}, - config::{ExpandedIsolationConfig, RuntimeConfig, expand_shell_path, path_looks_like_file}, + config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, }; use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; +const APPLE_GITCONFIG_DIR: &str = "/multicode-host/git"; +const APPLE_GITCONFIG_FILE_NAME: &str = ".gitconfig"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RuntimeActivity { @@ -685,7 +687,11 @@ impl AppleContainerRuntime { message: "apple-container backend requires a runtime image".to_string(), } })?; - let env_file = self.write_env_file(key, "exec.env", inherited_env).await?; + let mut env = inherited_env.to_vec(); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + let env_file = self.write_env_file(key, "exec.env", &env).await?; let workspace_path = self.context.workspace_directory_path.join(key); let mut args = vec![ "run".to_string(), @@ -698,7 +704,8 @@ impl AppleContainerRuntime { workspace_path.to_string_lossy().into_owned(), ]; self.append_container_limits(&mut args); - self.append_container_mounts(args.as_mut(), key).await?; + self.append_container_mounts(args.as_mut(), key, host_gitconfig.as_deref()) + .await?; args.push(image.to_string()); args.extend(command); @@ -778,6 +785,9 @@ impl AppleContainerRuntime { "opencode".to_string(), )); env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; let workspace_path = self.context.workspace_directory_path.join(key); tokio::fs::create_dir_all(&workspace_path).await?; @@ -797,7 +807,8 @@ impl AppleContainerRuntime { format!("127.0.0.1:{port}:{port}/tcp"), ]; self.append_container_limits(&mut args); - self.append_container_mounts(&mut args, key).await?; + self.append_container_mounts(&mut args, key, host_gitconfig.as_deref()) + .await?; args.push(image.to_string()); args.push(self.context.container_opencode_command.clone()); args.push("serve".to_string()); @@ -840,8 +851,12 @@ impl AppleContainerRuntime { &self, args: &mut Vec, key: &str, + host_gitconfig: Option<&Path>, ) -> Result<(), CombinedServiceError> { let workspace_path = self.context.workspace_directory_path.join(key); + let implicit_gitconfig_mount = self + .build_implicit_gitconfig_mount(key, host_gitconfig) + .await?; let mut mount_specs = Vec::new(); mount_specs.extend( self.context @@ -849,6 +864,7 @@ impl AppleContainerRuntime { .readable .iter() .cloned() + .filter(|path| !self.is_implicitly_handled_gitconfig(path, host_gitconfig)) .map(|path| MountSpec::new(path, None, MountKind::Readable)), ); mount_specs.extend( @@ -897,7 +913,9 @@ impl AppleContainerRuntime { }), ); } - mount_specs.extend(self.implicit_readable_mounts(&mount_specs)); + if let Some(implicit_gitconfig_mount) = implicit_gitconfig_mount { + mount_specs.push(implicit_gitconfig_mount); + } mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -918,8 +936,10 @@ impl AppleContainerRuntime { .as_ref() .is_some_and(|source| source != &resolved_mount.effective_source)); resolved_mount.prepare_source_node(owns_source_node).await?; - resolved_mount.prepare_target_node(owns_node).await?; - resolved_mount.prepare_container_materialized_file().await?; + if resolved_mount.needs_container_target_materialization() { + resolved_mount.prepare_target_node(owns_node).await?; + resolved_mount.prepare_container_materialized_file().await?; + } resolved_mounts.push(resolved_mount); } @@ -930,22 +950,61 @@ impl AppleContainerRuntime { Ok(()) } - fn implicit_readable_mounts(&self, existing_mounts: &[MountSpec]) -> Vec { - let Some(gitconfig) = expand_shell_path("~/.gitconfig") - .ok() - .filter(|path| path.is_absolute() && path.is_file()) - else { - return Vec::new(); + async fn append_implicit_env( + &self, + env: &mut Vec<(String, String)>, + host_gitconfig: Option<&Path>, + ) -> Result<(), CombinedServiceError> { + if host_gitconfig.is_some() { + env.push(( + "GIT_CONFIG_GLOBAL".to_string(), + format!("{APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}"), + )); + } + Ok(()) + } + + async fn build_implicit_gitconfig_mount( + &self, + key: &str, + host_gitconfig: Option<&Path>, + ) -> Result, CombinedServiceError> { + let Some(host_gitconfig) = host_gitconfig else { + return Ok(None); }; - if existing_mounts - .iter() - .any(|mount| mount.target == gitconfig) - { - return Vec::new(); + let source_root = self.apple_runtime_root(key).join("gitconfig"); + match tokio::fs::remove_dir_all(&source_root).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), } + tokio::fs::create_dir_all(&source_root).await?; + let gitconfig_contents = std::fs::read(host_gitconfig)?; + tokio::fs::write( + source_root.join(APPLE_GITCONFIG_FILE_NAME), + gitconfig_contents, + ) + .await?; + + Ok(Some(MountSpec::new( + PathBuf::from(APPLE_GITCONFIG_DIR), + Some(source_root), + MountKind::Readable, + ))) + } + + fn host_gitconfig_path_for_env(&self, env: &[(String, String)]) -> Option { + let home = env + .iter() + .find(|(name, _)| name == "HOME") + .map(|(_, value)| value)?; + let path = PathBuf::from(home).join(".gitconfig"); + (path.is_absolute() && path.is_file() && std::fs::read(&path).is_ok()).then_some(path) + } - vec![MountSpec::new(gitconfig, None, MountKind::Readable)] + fn is_implicitly_handled_gitconfig(&self, path: &Path, host_gitconfig: Option<&Path>) -> bool { + host_gitconfig.is_some_and(|gitconfig| gitconfig == path) } async fn build_aggregated_skill_mount( @@ -1303,6 +1362,10 @@ pub(crate) struct ResolvedMountSpec { } impl ResolvedMountSpec { + fn needs_container_target_materialization(&self) -> bool { + self.backing_mount_kind.is_some() && self.effective_target != self.mount.target + } + pub(crate) async fn prepare_source_node( &self, owns_node: bool, @@ -1621,9 +1684,20 @@ mod tests { let runtime = apple_runtime(&root, IsolationConfig::default()); let command = runtime - .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[("HOME".to_string(), home.to_string_lossy().into_owned())], + ) .await .expect("command should build"); + let server_env = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); if let Some(previous_home) = previous_home { unsafe { @@ -1637,12 +1711,25 @@ mod tests { let gitconfig_mount = format!( "type=bind,source={},target={},readonly", - gitconfig.to_string_lossy(), - gitconfig.to_string_lossy() + workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("gitconfig") + .to_string_lossy(), + APPLE_GITCONFIG_DIR ); assert!( command.args.iter().any(|arg| arg == &gitconfig_mount), - "apple backend should implicitly mount ~/.gitconfig read-only" + "apple backend should implicitly mount host gitconfig through a synthetic directory" + ); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!( + env_contents.contains(&format!( + "GIT_CONFIG_GLOBAL={APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}" + )), + "apple backend should point git at the synthetic mounted gitconfig" ); }); } From 10a7474288b3400a3c5af3d3addede6fbc35d2e3 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 15:44:45 +0200 Subject: [PATCH 05/75] Fix Apple exec tools for running workspaces Co-Authored-By: OpenAI Codex --- lib/src/services/combined.rs | 452 ++++++++++++++++++++++++++++++++++- lib/src/services/runtime.rs | 323 +++++++++++++++++++++---- 2 files changed, 730 insertions(+), 45 deletions(-) diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 0d27463..77cdf7f 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -21,6 +21,7 @@ use crate::{ use super::{ GithubStatusService, GithubStatusServiceError, WorkspaceDirectoryError, + autonomous_workspace_service::autonomous_workspace_service, config::{ AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, inherited_env_value, read_config, resolve_opencode_command, validate_handler_config, validate_remote_config, @@ -131,7 +132,7 @@ impl CombinedService { spawn_usage_aggregation_service(manager.clone()); spawn_resource_usage_service(manager.clone()); - Ok(Self { + let service = Self { config, manager, database, @@ -141,7 +142,11 @@ impl CombinedService { opencode_command, runtime, github_git_credentials_env, - }) + }; + + spawn_autonomous_workspace_service(service.clone()); + + Ok(service) } pub fn workspace_directory_path(&self) -> &Path { @@ -213,6 +218,56 @@ impl CombinedService { Ok(()) } + pub async fn assign_workspace_repository( + &self, + key: &str, + repository: Option<&str>, + ) -> Result, CombinedServiceError> { + let key = validate_workspace_key(key)?; + let normalized = repository + .map(|repository| { + super::autonomous_workspace_service::normalize_github_repository_spec(repository) + .ok_or_else(|| { + CombinedServiceError::InvalidRepositorySpec(repository.trim().to_string()) + }) + }) + .transpose()?; + + let workspace = self.manager.get_workspace(&key)?; + workspace.update(|snapshot| { + if snapshot.persistent.assigned_repository == normalized { + return false; + } + snapshot.persistent.assigned_repository = normalized.clone(); + snapshot.persistent.automation_issue = None; + snapshot.automation_status = normalized + .as_ref() + .map(|repository| format!("Repository assigned; scan queued for {repository}")); + if normalized.is_some() { + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + } + true + }); + Ok(normalized) + } + + pub fn request_workspace_issue_scan(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + workspace.update(|snapshot| { + if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { + if snapshot.persistent.automation_issue.is_none() { + snapshot.automation_status = Some(format!("Scan requested for {repository}")); + } + } + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + true + }); + Ok(()) + } + pub async fn stop_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; @@ -236,6 +291,20 @@ impl CombinedService { Ok(()) } + pub async fn delete_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let snapshot = workspace.subscribe().borrow().clone(); + + if let Some(transient) = snapshot.transient.as_ref() { + self.runtime.stop_server(&transient.runtime).await?; + } + + self.remove_workspace_disk_state(&key).await?; + self.manager.remove(&key)?; + Ok(()) + } + /// Build a command to run a user-defined exec-type tool. pub async fn build_exec_tool_command( &self, @@ -266,6 +335,14 @@ impl CombinedService { "PTY tool command must not be empty".to_string(), )); } + let runtime_handle = self + .manager + .get_workspace(&key)? + .subscribe() + .borrow() + .transient + .as_ref() + .map(|transient| transient.runtime.clone()); let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; @@ -274,7 +351,7 @@ impl CombinedService { .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; self.runtime - .build_pty_command(&key, &inherited_env, command) + .build_pty_command(&key, runtime_handle.as_ref(), &inherited_env, command) .await } @@ -466,6 +543,36 @@ impl CombinedService { .join(key) } + fn persistent_snapshot_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path + .join(".multicode") + .join("persistent") + .join(format!("{key}.json")) + } + + fn transient_snapshot_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path + .join(".multicode") + .join("transient") + .join(format!("{key}.json")) + } + + async fn remove_workspace_disk_state(&self, key: &str) -> Result<(), CombinedServiceError> { + remove_path_if_exists(&self.workspace_directory_path.join(key)).await?; + remove_path_if_exists(&self.isolate_path_for_key(key)).await?; + remove_path_if_exists(&self.persistent_snapshot_path_for_key(key)).await?; + remove_path_if_exists(&self.transient_snapshot_path_for_key(key)).await?; + + for format in WorkspaceArchiveFormat::all() { + let archive_entry = ArchiveWorkspaceEntry::new(key, format); + remove_path_if_exists(&archive_entry.to_path(&self.workspace_directory_path)).await?; + remove_path_if_exists(&archive_entry.to_isolate_path(&self.workspace_directory_path)) + .await?; + } + + Ok(()) + } + fn github_git_credentials_env_vars(&self) -> Vec<(String, String)> { github_git_credentials_env_vars(self.github_git_credentials_env.as_ref()) } @@ -690,6 +797,21 @@ fn github_git_credentials_env_vars( ] } +async fn remove_path_if_exists(path: &Path) -> Result<(), CombinedServiceError> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.is_dir() => { + tokio::fs::remove_dir_all(path).await?; + Ok(()) + } + Ok(_) => { + tokio::fs::remove_file(path).await?; + Ok(()) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + async fn strip_workspace_git_identity_overrides( workspace_path: &Path, ) -> Result<(), CombinedServiceError> { @@ -831,6 +953,7 @@ pub enum CombinedServiceError { message: String, }, InvalidToolExecution(String), + InvalidRepositorySpec(String), UnsupportedRuntimeBackend(String), WorkspaceArchived(String), WorkspaceNotArchived(String), @@ -849,6 +972,67 @@ pub enum CombinedServiceError { }, } +impl CombinedServiceError { + pub fn summary(&self) -> String { + match self { + Self::StartWorkspaceFailed { status, stderr } => { + summarize_workspace_start_failure(*status, stderr) + } + Self::StopWorkspaceFailed { status, stderr } => { + summarize_workspace_stop_failure(*status, stderr) + } + _ => format!("{self:?}"), + } + } +} + +pub fn summarize_workspace_start_failure(status: Option, stderr: &str) -> String { + let stderr = compact_process_stderr(stderr); + if stderr.contains("no free indices are available for allocation") { + return format!( + "Apple container vmnet allocator exhausted{}; restart the Apple container backend", + exit_status_suffix(status), + ); + } + + if stderr.is_empty() { + format!("workspace start failed{}", exit_status_suffix(status)) + } else { + format!( + "workspace start failed{}: {stderr}", + exit_status_suffix(status), + ) + } +} + +fn summarize_workspace_stop_failure(status: Option, stderr: &str) -> String { + let stderr = compact_process_stderr(stderr); + if stderr.is_empty() { + format!("workspace stop failed{}", exit_status_suffix(status)) + } else { + format!( + "workspace stop failed{}: {stderr}", + exit_status_suffix(status), + ) + } +} + +fn compact_process_stderr(stderr: &str) -> String { + stderr + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .trim_matches('"') + .to_string() +} + +fn exit_status_suffix(status: Option) -> String { + status + .map(|status| format!(" (exit {status})")) + .unwrap_or_default() +} + impl From for CombinedServiceError { fn from(value: std::io::Error) -> Self { Self::Io(value) @@ -963,6 +1147,14 @@ fn spawn_resource_usage_service(manager: Arc) { }); } +fn spawn_autonomous_workspace_service(service: CombinedService) { + tokio::spawn(async move { + if let Err(err) = autonomous_workspace_service(service).await { + tracing::error!(error = ?err, "autonomous workspace service exited with error"); + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -999,6 +1191,19 @@ mod tests { time::{Duration, SystemTime, UNIX_EPOCH}, }; + #[test] + fn summarize_workspace_start_failure_reports_allocator_exhaustion_hint() { + let summary = summarize_workspace_start_failure( + Some(1), + r#"Error: failed to bootstrap container (cause: "unknown: "no free indices are available for allocation"")"#, + ); + + assert_eq!( + summary, + "Apple container vmnet allocator exhausted (exit 1); restart the Apple container backend" + ); + } + struct TestDir { path: PathBuf, } @@ -1633,6 +1838,247 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] ))); } + #[test] + fn assign_workspace_repository_normalizes_and_clears_automation_issue() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issue/1".to_string()); + true + }); + + let normalized = service + .assign_workspace_repository("alpha", Some("https://github.com/example/repo.git")) + .await + .expect("repository assignment should succeed"); + assert_eq!(normalized.as_deref(), Some("example/repo")); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert_eq!( + snapshot.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + assert!(snapshot.persistent.automation_issue.is_none()); + assert_eq!(snapshot.automation_scan_request_nonce, 1); + + let cleared = service + .assign_workspace_repository("alpha", None) + .await + .expect("clearing repository assignment should succeed"); + assert!(cleared.is_none()); + let cleared_snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert!(cleared_snapshot.persistent.assigned_repository.is_none()); + assert_eq!(cleared_snapshot.automation_scan_request_nonce, 1); + }); + } + + #[test] + fn delete_workspace_stops_runtime_and_removes_workspace_state() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let stop_log = root.path().join("systemctl-stop.log"); + let fake_systemctl = bin_dir.join("systemctl"); + fs::write( + &fake_systemctl, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> '{}'\nprintf -- '---\\n' >> '{}'\nexit 0\n", + stop_log.display(), + stop_log.display() + ), + ) + .expect("fake systemctl should be written"); + make_executable(&fake_systemctl); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + workspace.update(|snapshot| { + snapshot.transient = Some(crate::TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: crate::RuntimeHandleSnapshot { + backend: crate::RuntimeBackend::LinuxSystemdBwrap, + id: "alpha.service".to_string(), + metadata: Default::default(), + }, + }); + true + }); + + let live_dir = workspace_directory.join("alpha"); + let isolate_dir = workspace_directory.join(".multicode").join("isolate").join("alpha"); + let persistent_snapshot = workspace_directory + .join(".multicode") + .join("persistent") + .join("alpha.json"); + let transient_snapshot = workspace_directory + .join(".multicode") + .join("transient") + .join("alpha.json"); + let archive_path = + ArchiveWorkspaceEntry::new("alpha", WorkspaceArchiveFormat::TarZstd) + .to_path(&workspace_directory); + let isolate_archive_path = + ArchiveWorkspaceEntry::new("alpha", WorkspaceArchiveFormat::TarZstd) + .to_isolate_path(&workspace_directory); + + tokio::fs::create_dir_all(&live_dir) + .await + .expect("live dir should exist"); + tokio::fs::create_dir_all(&isolate_dir) + .await + .expect("isolate dir should exist"); + tokio::fs::write(live_dir.join("README.md"), "workspace") + .await + .expect("workspace file should exist"); + tokio::fs::write(isolate_dir.join("marker.txt"), "isolate") + .await + .expect("isolate file should exist"); + tokio::fs::write(&persistent_snapshot, "{}") + .await + .expect("persistent snapshot should exist"); + tokio::fs::write(&transient_snapshot, "{}") + .await + .expect("transient snapshot should exist"); + if let Some(parent) = archive_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .expect("archive parent should exist"); + } + if let Some(parent) = isolate_archive_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .expect("isolate archive parent should exist"); + } + tokio::fs::write(&archive_path, "archive") + .await + .expect("archive should exist"); + tokio::fs::write(&isolate_archive_path, "isolate archive") + .await + .expect("isolate archive should exist"); + + service + .delete_workspace("alpha") + .await + .expect("workspace deletion should succeed"); + + assert!(service.manager.get_workspace("alpha").is_err()); + assert!(!live_dir.exists()); + assert!(!isolate_dir.exists()); + assert!(!persistent_snapshot.exists()); + assert!(!transient_snapshot.exists()); + assert!(!archive_path.exists()); + assert!(!isolate_archive_path.exists()); + + let stop_invocation = + fs::read_to_string(&stop_log).expect("runtime stop should be recorded"); + assert!(stop_invocation.contains("alpha.service")); + }); + } + #[test] fn strip_workspace_git_identity_overrides_removes_repo_local_user_identity() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index c98d4c3..de2b9b0 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -2,9 +2,10 @@ use std::{ collections::BTreeMap, path::{Path, PathBuf}, process::{Output, Stdio}, + sync::OnceLock, }; -use tokio::process::Command; +use tokio::{process::Command, sync::Mutex}; use uuid::Uuid; use super::{ @@ -109,13 +110,20 @@ impl WorkspaceRuntime { pub(super) async fn build_pty_command( &self, key: &str, + runtime_handle: Option<&RuntimeHandleSnapshot>, inherited_env: &[(String, String)], command: Vec, ) -> Result { match self { - Self::Linux(runtime) => runtime.build_pty_command(key, inherited_env, command).await, + Self::Linux(runtime) => { + runtime + .build_pty_command(key, runtime_handle, inherited_env, command) + .await + } Self::AppleContainer(runtime) => { - runtime.build_pty_command(key, inherited_env, command).await + runtime + .build_pty_command(key, runtime_handle, inherited_env, command) + .await } } } @@ -317,6 +325,7 @@ impl LinuxSystemdBwrapRuntime { async fn build_pty_command( &self, key: &str, + _runtime_handle: Option<&RuntimeHandleSnapshot>, inherited_env: &[(String, String)], command: Vec, ) -> Result { @@ -584,15 +593,17 @@ impl AppleContainerRuntime { key: &str, inherited_env: &[(String, String)], ) -> Result { + let _start_guard = apple_container_start_lock().lock().await; let password = generate_random_password(); let port = pick_random_free_port().await?; - let container_name = self.container_name_for_key(key); - self.remove_container_if_present(&container_name).await?; + let container_name = self.generate_runtime_id(key); let command = self .build_run_command(key, &container_name, &password, port, inherited_env) .await?; - let output = run_blocking_process(command.program.clone(), command.args.clone()).await?; + let output = self + .run_container_start_command(command.program.clone(), command.args.clone()) + .await?; if !output.status.success() { return Err(CombinedServiceError::StartWorkspaceFailed { @@ -621,35 +632,60 @@ impl AppleContainerRuntime { }) } - async fn remove_container_if_present( + async fn run_container_start_command( &self, - container_name: &str, - ) -> Result<(), CombinedServiceError> { - let output = run_blocking_process( - container_program(), - vec![ - "rm".to_string(), - "-f".to_string(), - container_name.to_string(), - ], - ) - .await?; - + program: String, + args: Vec, + ) -> Result { + let output = run_blocking_process(program.clone(), args.clone()).await?; if output.status.success() { - return Ok(()); + return Ok(output); } - let stderr = String::from_utf8_lossy(&output.stderr); - if container_delete_reports_missing(&stderr) { - return Ok(()); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if !container_start_reports_allocator_exhaustion(&stderr) { + return Ok(output); } + tracing::warn!( - container_name, - status = output.status.code(), - stderr = %stderr, - "best-effort apple container preflight delete failed; continuing startup" + stderr = %stderr.trim(), + "apple container allocator exhausted; pruning containers before retry" ); - Ok(()) + let prune_output = run_blocking_process( + container_program(), + vec!["prune".to_string(), "-f".to_string()], + ) + .await?; + if !prune_output.status.success() { + let prune_stderr = String::from_utf8_lossy(&prune_output.stderr) + .trim() + .to_string(); + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: if prune_stderr.is_empty() { + format!("{stderr}\nautomatic container prune failed") + } else { + format!("{stderr}\nautomatic container prune failed: {prune_stderr}") + }, + }); + } + + tracing::info!("apple container prune succeeded; retrying workspace startup"); + let retry_output = run_blocking_process(program, args).await?; + if !retry_output.status.success() + && container_start_reports_allocator_exhaustion(&String::from_utf8_lossy( + &retry_output.stderr, + )) + { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: retry_output.status.code(), + stderr: format!( + "{}\nautomatic container prune was insufficient; restart the Apple container backend", + String::from_utf8_lossy(&retry_output.stderr).trim() + ), + }); + } + Ok(retry_output) } async fn stop_server( @@ -678,9 +714,18 @@ impl AppleContainerRuntime { async fn build_pty_command( &self, key: &str, + runtime_handle: Option<&RuntimeHandleSnapshot>, inherited_env: &[(String, String)], command: Vec, ) -> Result { + if let Some(runtime_handle) = runtime_handle + && runtime_handle.backend == RuntimeBackend::AppleContainer + { + return self + .build_exec_command(runtime_handle, key, inherited_env, command) + .await; + } + let image = self.context.runtime.image.as_deref().ok_or_else(|| { CombinedServiceError::InvalidRuntimeConfig { field: "runtime.image".to_string(), @@ -716,6 +761,40 @@ impl AppleContainerRuntime { }) } + async fn build_exec_command( + &self, + runtime_handle: &RuntimeHandleSnapshot, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let mut env = inherited_env.to_vec(); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + let env_file = self.write_env_file(key, "exec.env", &env).await?; + let workspace_path = self.context.workspace_directory_path.join(key); + let args = vec![ + "exec".to_string(), + "--tty".to_string(), + "--interactive".to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + runtime_handle.id.clone(), + ] + .into_iter() + .chain(command) + .collect(); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { let inspect_output = run_blocking_process( container_program(), @@ -974,12 +1053,7 @@ impl AppleContainerRuntime { }; let source_root = self.apple_runtime_root(key).join("gitconfig"); - match tokio::fs::remove_dir_all(&source_root).await { - Ok(()) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } - tokio::fs::create_dir_all(&source_root).await?; + clear_directory_contents(&source_root).await?; let gitconfig_contents = std::fs::read(host_gitconfig)?; tokio::fs::write( source_root.join(APPLE_GITCONFIG_FILE_NAME), @@ -1032,12 +1106,7 @@ impl AppleContainerRuntime { } let aggregate_root = self.apple_runtime_root(key).join("skills"); - match tokio::fs::remove_dir_all(&aggregate_root).await { - Ok(()) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } - tokio::fs::create_dir_all(&aggregate_root).await?; + clear_directory_contents(&aggregate_root).await?; if tokio::fs::metadata(&target_root).await.is_ok() { copy_directory_tree(&target_root, &aggregate_root).await?; @@ -1100,8 +1169,8 @@ impl AppleContainerRuntime { self.apple_runtime_root(key).join("isolate").join(relative) } - fn container_name_for_key(&self, key: &str) -> String { - format!("multicode-{}", key) + fn generate_runtime_id(&self, key: &str) -> String { + format!("multicode-{key}-{}", Uuid::new_v4().as_simple()) } } @@ -1219,6 +1288,11 @@ fn container_program() -> String { std::env::var("MULTICODE_CONTAINER_COMMAND").unwrap_or_else(|_| "container".to_string()) } +fn apple_container_start_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + fn container_delete_reports_missing(stderr: &str) -> bool { let stderr = stderr.to_ascii_lowercase(); stderr.contains("not found") @@ -1227,6 +1301,12 @@ fn container_delete_reports_missing(stderr: &str) -> bool { || stderr.contains("does not exist") } +fn container_start_reports_allocator_exhaustion(stderr: &str) -> bool { + stderr + .to_ascii_lowercase() + .contains("no free indices are available for allocation") +} + async fn run_blocking_process( program: String, args: Vec, @@ -1262,6 +1342,21 @@ async fn copy_directory_tree(source: &Path, target: &Path) -> Result<(), std::io Ok(()) } +async fn clear_directory_contents(path: &Path) -> Result<(), std::io::Error> { + tokio::fs::create_dir_all(path).await?; + let mut entries = tokio::fs::read_dir(path).await?; + while let Some(entry) = entries.next_entry().await? { + let entry_path = entry.path(); + let metadata = entry.metadata().await?; + if metadata.is_dir() { + tokio::fs::remove_dir_all(entry_path).await?; + } else { + tokio::fs::remove_file(entry_path).await?; + } + } + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum MountKind { Readable, @@ -1570,6 +1665,16 @@ mod tests { )); } + #[test] + fn container_start_reports_allocator_exhaustion_matches_apple_error() { + assert!(container_start_reports_allocator_exhaustion( + "Error: failed to bootstrap container (cause: \"unknown: \"no free indices are available for allocation\"\")" + )); + assert!(!container_start_reports_allocator_exhaustion( + "Error: failed to bootstrap container: permission denied" + )); + } + #[test] fn apple_container_run_command_honors_limits_and_mounts() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -1750,6 +1855,7 @@ mod tests { let command = runtime .build_pty_command( "alpha", + None, &[( "HOME".to_string(), root.path().to_string_lossy().into_owned(), @@ -1776,6 +1882,60 @@ mod tests { }); } + #[test] + fn apple_container_pty_command_uses_container_exec_for_running_workspace() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + fs::create_dir_all(workspace_root.join("alpha")).expect("workspace should exist"); + let runtime = apple_runtime(&root, IsolationConfig::default()); + let runtime_handle = RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha-running".to_string(), + metadata: BTreeMap::new(), + }; + + let command = runtime + .build_pty_command( + "alpha", + Some(&runtime_handle), + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + vec!["/bin/bash".to_string()], + ) + .await + .expect("pty command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["exec", "--tty", "--interactive", "--env-file",] + )); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!(contains_sequence( + &command.args, + &[ + "--workdir", + workspace_root.join("alpha").to_string_lossy().as_ref(), + "multicode-alpha-running", + "/bin/bash", + ] + )); + assert!( + !command.args.iter().any(|arg| arg == "run"), + "running workspaces should reuse the active container" + ); + assert!(command.inherited_env.is_empty()); + }); + } + #[test] fn apple_container_materializes_nested_readable_file_inside_isolated_mount() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -2032,4 +2192,83 @@ mod tests { ); }); } + + #[test] + fn apple_container_reuses_aggregated_skills_directory_across_commands() { + use std::os::unix::fs::MetadataExt; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let host_home = root.path().join("host-home"); + let host_skills_target = host_home.join(".config/opencode/skills"); + let skill_root = root.path().join("workspace-skills"); + let skill = skill_root.join("machine-readable-pr"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&skill).expect("skill should exist"); + fs::write(skill.join("SKILL.md"), "# pr").expect("skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: vec![host_home.join(".config/opencode")], + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![AddedSkillMount { + source: skill.clone(), + target: host_skills_target.join("machine-readable-pr"), + }], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + }; + + runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("run command should build"); + let aggregate_root = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let first_ino = fs::metadata(&aggregate_root) + .expect("aggregate root should exist") + .ino(); + + runtime + .build_pty_command("alpha", None, &[], vec!["/bin/sh".to_string()]) + .await + .expect("pty command should build"); + let second_ino = fs::metadata(&aggregate_root) + .expect("aggregate root should still exist") + .ino(); + + assert_eq!( + first_ino, second_ino, + "apple backend should update the aggregated skills directory in place so existing mounts stay valid" + ); + assert_eq!( + fs::read_to_string(aggregate_root.join("machine-readable-pr/SKILL.md")) + .expect("aggregated skill should remain present"), + "# pr" + ); + }); + } } From a1e8d7a917c633a03db261540ac6f6bd11f93d96 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 15:48:10 +0200 Subject: [PATCH 06/75] Add autonomous GitHub issue handling Co-Authored-By: OpenAI Codex --- lib/Cargo.toml | 2 +- lib/src/lib.rs | 10 + lib/src/manager.rs | 67 +- .../services/autonomous_workspace_service.rs | 1054 +++++++++++++++++ lib/src/services/config.rs | 24 + lib/src/services/mod.rs | 7 +- lib/src/services/persistent_storage.rs | 6 + lib/src/services/root_session_service.rs | 134 ++- .../apple_container_runtime_integration.rs | 40 +- remote/src/orchestration.rs | 10 + tui/src/app.rs | 193 ++- tui/src/main.rs | 82 +- tui/src/render.rs | 97 +- tui/src/tests.rs | 165 ++- 14 files changed, 1845 insertions(+), 46 deletions(-) create mode 100644 lib/src/services/autonomous_workspace_service.rs diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 65c43a7..51b71ea 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -6,7 +6,7 @@ build = "build.rs" license = "Apache-2.0" [dependencies] -tokio = { version = "1", features = ["sync", "fs", "rt", "time", "process", "net"] } +tokio = { version = "1", features = ["sync", "fs", "rt", "time", "process", "net", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" shellexpand = "3" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 3291d58..fd2f282 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -68,6 +68,10 @@ pub struct PersistentWorkspaceSnapshot { pub description: String, pub created_at: Option, #[serde(default)] + pub assigned_repository: Option, + #[serde(default)] + pub automation_issue: Option, + #[serde(default)] pub archive_format: Option, #[serde(default)] pub agent_provided: AgentProvidedPersistentSnapshot, @@ -81,6 +85,8 @@ impl Default for PersistentWorkspaceSnapshot { archived: false, description: String::new(), created_at: None, + assigned_repository: None, + automation_issue: None, archive_format: None, agent_provided: AgentProvidedPersistentSnapshot::default(), custom_links: CustomLinksPersistentSnapshot::default(), @@ -148,6 +154,8 @@ pub struct WorkspaceSnapshot { pub root_session_id: Option, pub root_session_title: Option, pub root_session_status: Option, + pub automation_status: Option, + pub automation_scan_request_nonce: u64, pub usage_total_tokens: Option, pub usage_total_cost: Option, pub usage_cpu_percent: Option, @@ -164,6 +172,8 @@ impl Default for WorkspaceSnapshot { root_session_id: None, root_session_title: None, root_session_status: None, + automation_status: None, + automation_scan_request_nonce: 0, usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, diff --git a/lib/src/manager.rs b/lib/src/manager.rs index 41e401f..5398d81 100644 --- a/lib/src/manager.rs +++ b/lib/src/manager.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeSet, HashMap}, - sync::RwLock, + sync::{Arc, RwLock}, }; use tokio::sync::watch; @@ -15,24 +15,45 @@ pub enum WorkspaceManagerError { #[derive(Debug, Clone)] pub struct Workspace { - snapshot_tx: watch::Sender, + snapshot_tx: Arc>>>, } impl Workspace { pub fn new(snapshot: WorkspaceSnapshot) -> Self { let (snapshot_tx, _) = watch::channel(snapshot); - Self { snapshot_tx } + Self { + snapshot_tx: Arc::new(RwLock::new(Some(snapshot_tx))), + } } pub fn subscribe(&self) -> watch::Receiver { - self.snapshot_tx.subscribe() + self.snapshot_tx + .read() + .expect("workspace lock poisoned") + .as_ref() + .expect("workspace is closed") + .subscribe() } pub fn update(&self, updater: F) where F: FnOnce(&mut WorkspaceSnapshot) -> bool, { - let _ = self.snapshot_tx.send_if_modified(updater); + if let Some(snapshot_tx) = self + .snapshot_tx + .read() + .expect("workspace lock poisoned") + .as_ref() + { + let _ = snapshot_tx.send_if_modified(updater); + } + } + + pub fn close(&self) { + self.snapshot_tx + .write() + .expect("workspace lock poisoned") + .take(); } } @@ -96,6 +117,18 @@ impl WorkspaceManager { self.workspace_keys_tx.subscribe() } + pub fn remove(&self, key: &str) -> Result<(), WorkspaceManagerError> { + let workspace = self + .workspaces + .write() + .expect("workspace lock poisoned") + .remove(key) + .ok_or_else(|| WorkspaceManagerError::WorkspaceNotFound(key.to_string()))?; + workspace.close(); + self.publish_workspace_keys(); + Ok(()) + } + fn publish_workspace_keys(&self) { let keys = self .workspaces @@ -211,4 +244,28 @@ mod tests { ); assert!(!workspace_set_rx.has_changed().expect("watch still open")); } + + #[test] + fn remove_notifies_workspace_set_watch_and_closes_workspace() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let manager = WorkspaceManager::new(); + let mut workspace_set_rx = manager.subscribe(); + let mut workspace_rx = manager.add("alpha").expect("workspace should be added"); + let _ = workspace_set_rx.borrow_and_update(); + + manager + .remove("alpha") + .expect("workspace should be removed"); + + assert!(workspace_set_rx.has_changed().expect("watch still open")); + assert!(workspace_set_rx.borrow_and_update().is_empty()); + assert!(workspace_rx.changed().await.is_err()); + assert!(manager.get_workspace("alpha").is_err()); + }); + } } diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs new file mode 100644 index 0000000..50f0a64 --- /dev/null +++ b/lib/src/services/autonomous_workspace_service.rs @@ -0,0 +1,1054 @@ +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +use serde::Deserialize; +use tokio::{ + process::Command, + sync::watch, + time::{Instant, sleep_until}, +}; + +use super::{CombinedService, GithubStatus, workspace_watch::monitor_workspace_snapshots}; +use crate::{ + RootSessionStatus, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, +}; + +const ISSUE_PRIORITY_LABELS: [&str; 4] = [ + "type: bug", + "type:docs", + "type: improvement", + "type: enhancement", +]; +const ISSUE_PRIORITY_BOOST_LABELS: [&str; 2] = ["type: regression", "priority: high"]; +const IN_PROGRESS_LABEL: &str = "status: in progress"; +const ISSUE_SCAN_RETRY_DELAY: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub enum AutonomousWorkspaceServiceError { + Manager(WorkspaceManagerError), +} + +impl From for AutonomousWorkspaceServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub async fn autonomous_workspace_service( + service: CombinedService, +) -> Result<(), AutonomousWorkspaceServiceError> { + monitor_workspace_snapshots( + service.manager.clone(), + move |key, workspace, workspace_rx| { + let service = service.clone(); + async move { + tokio::spawn(async move { + watch_workspace(service, key, workspace, workspace_rx).await; + }); + Ok(()) + } + }, + ) + .await +} + +async fn watch_workspace( + service: CombinedService, + workspace_key: String, + workspace: Workspace, + mut workspace_rx: watch::Receiver, +) { + let issue_scan_delay = Duration::from_secs(service.config.autonomous.issue_scan_delay_seconds); + let issue_scan_delay = issue_scan_delay.max(Duration::from_secs(1)); + let mut next_scan_at: Option = None; + let mut watched_issue_url: Option = None; + let mut issue_status_rx: Option>> = None; + let mut previous_root_status: Option = None; + let mut previous_scan_request_nonce: u64 = 0; + let mut blocked_start_scan_request_nonce: Option = None; + + loop { + let snapshot = workspace_rx.borrow().clone(); + let assigned_repository = snapshot.persistent.assigned_repository.clone(); + let scan_requested = snapshot.automation_scan_request_nonce != previous_scan_request_nonce; + previous_scan_request_nonce = snapshot.automation_scan_request_nonce; + + if snapshot.persistent.archived || assigned_repository.is_none() { + watched_issue_url = None; + issue_status_rx = None; + next_scan_at = None; + blocked_start_scan_request_nonce = None; + previous_root_status = snapshot.root_session_status; + set_automation_status(&workspace, None); + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } + + let assigned_repository = assigned_repository.expect("checked above"); + let repository_label = compact_repository_label(&assigned_repository); + if scan_requested { + blocked_start_scan_request_nonce = None; + } + if scan_requested + && snapshot.persistent.automation_issue.is_none() + && matches!( + snapshot + .root_session_status + .unwrap_or(RootSessionStatus::Idle), + RootSessionStatus::Idle + ) + { + next_scan_at = Some(Instant::now()); + set_automation_status(&workspace, Some(format!("Scan now {repository_label}"))); + } + + if snapshot.transient.is_none() { + if start_retry_is_blocked( + blocked_start_scan_request_nonce, + snapshot.automation_scan_request_nonce, + ) { + previous_root_status = snapshot.root_session_status; + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } + set_automation_status(&workspace, Some(format!("Start {repository_label}"))); + match service.start_workspace(&workspace_key).await { + Ok(()) => { + blocked_start_scan_request_nonce = None; + } + Err(err) => { + blocked_start_scan_request_nonce = Some(snapshot.automation_scan_request_nonce); + set_automation_status( + &workspace, + Some(format!( + "Start failed {repository_label}: {}", + err.summary() + )), + ); + if workspace_rx.changed().await.is_err() { + break; + } + } + } + previous_root_status = snapshot.root_session_status; + continue; + } + + if snapshot.opencode_client.is_none() || snapshot.root_session_id.is_none() { + set_automation_status(&workspace, Some(format!("Wait server {repository_label}"))); + previous_root_status = snapshot.root_session_status; + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } + + let current_issue_url = snapshot.persistent.automation_issue.clone(); + if let Some(current_issue_url) = current_issue_url { + if watched_issue_url.as_deref() != Some(current_issue_url.as_str()) { + watched_issue_url = Some(current_issue_url.clone()); + issue_status_rx = service + .github_status_service() + .watch_status(¤t_issue_url); + } + + if snapshot.root_session_status == Some(RootSessionStatus::Idle) + && previous_root_status != Some(RootSessionStatus::Idle) + { + let _ = service + .github_status_service() + .request_refresh(¤t_issue_url); + } + + if issue_is_closed(issue_status_rx.as_ref()) { + workspace.update(|next| { + if next.persistent.automation_issue.as_deref() + == Some(current_issue_url.as_str()) + { + next.persistent.automation_issue = None; + true + } else { + false + } + }); + watched_issue_url = None; + issue_status_rx = None; + next_scan_at = Some(Instant::now()); + set_automation_status(&workspace, Some(format!("Next issue {repository_label}"))); + previous_root_status = snapshot.root_session_status; + continue; + } + + set_automation_status( + &workspace, + Some(issue_progress_status( + &assigned_repository, + ¤t_issue_url, + snapshot.root_session_status, + )), + ); + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until(&mut workspace_rx, &mut issue_status_rx, None).await + { + break; + } + continue; + } + + if matches!( + snapshot.root_session_status, + Some(RootSessionStatus::Busy | RootSessionStatus::Question) + ) { + set_automation_status( + &workspace, + Some(format!("Wait current session {repository_label}")), + ); + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until(&mut workspace_rx, &mut issue_status_rx, None).await + { + break; + } + continue; + } + + let now = Instant::now(); + let current_deadline = next_scan_at.unwrap_or(now); + if current_deadline > now { + set_automation_status( + &workspace, + Some(format!( + "No issues {repository_label}; next {}m", + ((current_deadline - now).as_secs() + 59) / 60 + )), + ); + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until( + &mut workspace_rx, + &mut issue_status_rx, + Some(current_deadline), + ) + .await + { + break; + } + continue; + } + + set_automation_status(&workspace, Some(format!("Scan issues {repository_label}"))); + match claim_next_issue( + &service, + &workspace, + &workspace_key, + &snapshot, + &assigned_repository, + ) + .await + { + Ok(Some(issue)) => { + watched_issue_url = Some(issue.url.clone()); + issue_status_rx = service.github_status_service().watch_status(&issue.url); + next_scan_at = None; + set_automation_status( + &workspace, + Some(format!("Working {}", issue.display_reference())), + ); + } + Ok(None) => { + next_scan_at = Some(Instant::now() + issue_scan_delay); + set_automation_status( + &workspace, + Some(format!( + "No issues {repository_label}; next {}m", + (issue_scan_delay.as_secs() + 59) / 60 + )), + ); + } + Err(err) => { + next_scan_at = Some(Instant::now() + ISSUE_SCAN_RETRY_DELAY); + set_automation_status( + &workspace, + Some(format!("Scan failed {repository_label}: {err}")), + ); + } + } + + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until(&mut workspace_rx, &mut issue_status_rx, next_scan_at) + .await + { + break; + } + } +} + +fn start_retry_is_blocked(blocked_nonce: Option, current_nonce: u64) -> bool { + blocked_nonce == Some(current_nonce) +} + +async fn claim_next_issue( + service: &CombinedService, + workspace: &Workspace, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, + assigned_repository: &str, +) -> Result, String> { + let excluded_issue_urls = active_issue_urls(service, workspace_key); + let token = resolved_gh_token(service).await?; + let Some(issue) = find_next_issue(assigned_repository, &excluded_issue_urls, &token).await? + else { + return Ok(None); + }; + + set_automation_status( + workspace, + Some(format!("Claiming {}", issue.display_reference())), + ); + persist_automation_issue_claim(workspace, assigned_repository, &issue); + + if let Err(err) = prompt_root_session(snapshot, assigned_repository, &issue).await { + clear_automation_issue_claim(workspace, &issue.url); + tracing::warn!( + workspace_key, + issue_url = %issue.url, + error = %err, + "failed to start autonomous issue work after reserving issue" + ); + return Err(err); + } + + if let Err(err) = + add_work_started_comment(assigned_repository, &issue, workspace_key, &token).await + { + tracing::warn!( + workspace_key, + issue_url = %issue.url, + error = %err, + "autonomous issue prompt started but adding work-started comment failed" + ); + return Err(err); + } + + if let Err(err) = add_issue_label(assigned_repository, &issue, IN_PROGRESS_LABEL, &token).await + { + tracing::warn!( + workspace_key, + issue_url = %issue.url, + label = IN_PROGRESS_LABEL, + error = %err, + "autonomous issue prompt started but adding in-progress label failed" + ); + return Err(err); + } + + Ok(Some(issue)) +} + +fn persist_automation_issue_claim( + workspace: &Workspace, + assigned_repository: &str, + issue: &SelectedIssue, +) { + workspace.update(|next| { + let changed_issue = next.persistent.automation_issue.as_deref() != Some(issue.url.as_str()); + let changed_repo = + next.persistent.assigned_repository.as_deref() != Some(assigned_repository); + if changed_issue || changed_repo { + next.persistent.assigned_repository = Some(assigned_repository.to_string()); + next.persistent.automation_issue = Some(issue.url.clone()); + true + } else { + false + } + }); +} + +fn clear_automation_issue_claim(workspace: &Workspace, issue_url: &str) { + workspace.update(|next| { + if next.persistent.automation_issue.as_deref() == Some(issue_url) { + next.persistent.automation_issue = None; + true + } else { + false + } + }); +} + +fn active_issue_urls(service: &CombinedService, current_workspace_key: &str) -> HashSet { + let workspace_keys = service.manager.subscribe().borrow().clone(); + let mut urls = HashSet::new(); + for key in workspace_keys { + if key == current_workspace_key { + continue; + } + let Ok(workspace) = service.manager.get_workspace(&key) else { + continue; + }; + let snapshot = workspace.subscribe().borrow().clone(); + if let Some(url) = snapshot.persistent.automation_issue { + urls.insert(url); + } + urls.extend(snapshot.persistent.custom_links.issue); + urls.extend(snapshot.persistent.agent_provided.issue); + } + urls +} + +fn set_automation_status(workspace: &Workspace, next_status: Option) { + workspace.update(|snapshot| { + if snapshot.automation_status != next_status { + snapshot.automation_status = next_status.clone(); + true + } else { + false + } + }); +} + +fn issue_progress_status( + assigned_repository: &str, + issue_url: &str, + root_status: Option, +) -> String { + let issue_ref = issue_reference(issue_url).unwrap_or_else(|| issue_url.to_string()); + let _ = assigned_repository; + match root_status.unwrap_or(RootSessionStatus::Idle) { + RootSessionStatus::Busy => format!("Working {issue_ref}"), + RootSessionStatus::Question => format!("Question {issue_ref}"), + RootSessionStatus::Idle => format!("Wait close {issue_ref}"), + } +} + +fn compact_repository_label(assigned_repository: &str) -> &str { + assigned_repository + .rsplit('/') + .next() + .unwrap_or(assigned_repository) +} + +fn issue_is_closed(issue_status_rx: Option<&watch::Receiver>>) -> bool { + matches!( + issue_status_rx.and_then(|receiver| *receiver.borrow()), + Some(GithubStatus::Issue(issue_status)) + if issue_status.state == super::github_status_service::GithubIssueState::Closed + ) +} + +async fn wait_for_workspace_change_until( + workspace_rx: &mut watch::Receiver, + issue_status_rx: &mut Option>>, + deadline: Option, +) -> bool { + match (issue_status_rx.as_mut(), deadline) { + (Some(issue_status_rx), Some(deadline)) => { + tokio::select! { + changed = workspace_rx.changed() => changed.is_ok(), + changed = issue_status_rx.changed() => changed.is_ok(), + _ = sleep_until(deadline.into()) => true, + } + } + (Some(issue_status_rx), None) => { + tokio::select! { + changed = workspace_rx.changed() => changed.is_ok(), + changed = issue_status_rx.changed() => changed.is_ok(), + } + } + (None, Some(deadline)) => { + tokio::select! { + changed = workspace_rx.changed() => changed.is_ok(), + _ = sleep_until(deadline.into()) => true, + } + } + (None, None) => workspace_rx.changed().await.is_ok(), + } +} + +async fn prompt_root_session( + snapshot: &WorkspaceSnapshot, + assigned_repository: &str, + issue: &SelectedIssue, +) -> Result<(), String> { + let opencode_client = snapshot + .opencode_client + .as_ref() + .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; + let root_session_id = snapshot + .root_session_id + .clone() + .ok_or_else(|| "workspace has no root session id".to_string())?; + let session_id = root_session_id + .parse::() + .map_err(|err| format!("invalid root session id '{root_session_id}': {err}"))?; + let prompt = build_issue_prompt(assigned_repository, issue); + let prompt_body = opencode::client::types::SessionPromptAsyncBody { + agent: None, + format: None, + message_id: None, + model: None, + no_reply: None, + parts: vec![ + opencode::client::types::TextPartInput { + id: None, + ignored: None, + metadata: Default::default(), + synthetic: None, + text: prompt, + time: None, + type_: opencode::client::types::TextPartInputType::Text, + } + .into(), + ], + system: None, + tools: HashMap::new(), + variant: None, + }; + opencode_client + .client + .session_prompt_async(&session_id, None, None, &prompt_body) + .await + .map_err(|err| format!("failed to send autonomous issue prompt: {err}"))?; + Ok(()) +} + +fn build_issue_prompt(assigned_repository: &str, issue: &SelectedIssue) -> String { + format!( + "You are operating in an autonomous multicode workspace for repository {assigned_repository}.\n\ +Start work on GitHub issue {issue_url}.\n\ +Issue title: {issue_title}\n\ +Your job is to:\n\ +1. Ensure the repository is available in this workspace.\n\ +2. Understand and reproduce the issue, creating a minimal reproducer or failing test when possible.\n\ +3. Implement the fix.\n\ +4. Run focused verification and summarize the evidence.\n\ +5. Open or update a pull request, request review, and emit the machine-readable repository / issue / PR tags while you work.\n\ +\n\ +Prefer an upstream pull request if you have write access. Keep going until the workspace is ready for review or you need human feedback.", + issue_url = issue.url, + issue_title = issue.title + ) +} + +async fn find_next_issue( + assigned_repository: &str, + excluded_issue_urls: &HashSet, + token: &str, +) -> Result, String> { + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + + for label in ISSUE_PRIORITY_LABELS { + let issues = list_issues_for_label(assigned_repository, label, token).await?; + for issue in issues { + if issue.has_label(IN_PROGRESS_LABEL) + || excluded_issue_urls.contains(&issue.url) + || !seen.insert(issue.url.clone()) + { + continue; + } + candidates.push(issue); + } + } + + Ok(candidates.into_iter().min_by(issue_priority_cmp)) +} + +async fn list_issues_for_label( + assigned_repository: &str, + label: &str, + token: &str, +) -> Result, String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args(issue_search_args(assigned_repository, label)) + .output() + .await + .map_err(|err| { + format!("failed to run gh search issues for {assigned_repository}: {err}") + })?; + + if !output.status.success() { + return Err(format!( + "gh search issues failed for {assigned_repository}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + serde_json::from_slice::>(&output.stdout) + .map(|issues| { + issues + .into_iter() + .filter(SelectedIssue::is_open_issue_candidate) + .collect() + }) + .map_err(|err| format!("failed to parse gh search issues output: {err}")) +} + +fn issue_search_args(assigned_repository: &str, label: &str) -> Vec { + vec![ + "search".to_string(), + "issues".to_string(), + "--repo".to_string(), + assigned_repository.to_string(), + "--state".to_string(), + "open".to_string(), + "--label".to_string(), + label.to_string(), + "--sort".to_string(), + "created".to_string(), + "--order".to_string(), + "desc".to_string(), + "--limit".to_string(), + "100".to_string(), + "--json".to_string(), + "number,title,createdAt,labels,url,state,isPullRequest".to_string(), + "--".to_string(), + "-linked:pr".to_string(), + ] +} + +async fn add_work_started_comment( + assigned_repository: &str, + issue: &SelectedIssue, + workspace_key: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "comment", + &issue.url, + "--repo", + assigned_repository, + "--body", + &format!("multicode has started work on this issue in workspace `{workspace_key}`."), + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue comment for {}: {err}", issue.url))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue comment failed for {}: {}", + issue.url, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +async fn add_issue_label( + assigned_repository: &str, + issue: &SelectedIssue, + label: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + &issue.url, + "--repo", + assigned_repository, + "--add-label", + label, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {}: {err}", issue.url))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {}: {}", + issue.url, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +fn apply_gh_env(command: &mut Command, token: &str) { + command.env("GH_TOKEN", token); + command.env("GITHUB_TOKEN", token); +} + +async fn resolved_gh_token(service: &CombinedService) -> Result { + service + .github_status_service() + .resolved_github_token() + .await + .map_err(|err| format!("failed to resolve GitHub token: {err}")) +} + +fn gh_program() -> String { + std::env::var("MULTICODE_GH_COMMAND").unwrap_or_else(|_| "gh".to_string()) +} + +pub(crate) fn normalize_github_repository_spec(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix("https://github.com/") { + return normalize_github_repository_path(rest); + } + if let Some(rest) = trimmed.strip_prefix("http://github.com/") { + return normalize_github_repository_path(rest); + } + normalize_github_repository_path(trimmed) +} + +fn normalize_github_repository_path(path: &str) -> Option { + let mut segments = path + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + if segments.len() < 2 { + return None; + } + let owner = segments.remove(0); + let mut repo = segments.remove(0).to_string(); + if let Some(stripped) = repo.strip_suffix(".git") { + repo = stripped.to_string(); + } + (!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}")) +} + +fn issue_reference(url: &str) -> Option { + let stripped = url.strip_prefix("https://github.com/")?; + let segments = stripped.split('/').collect::>(); + if segments.len() < 4 { + return None; + } + let owner = segments[0]; + let repo = segments[1]; + let number = segments[3]; + Some(format!("{owner}/{repo}#{number}")) +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedIssue { + number: u64, + title: String, + url: String, + #[serde(rename = "createdAt")] + created_at: String, + state: Option, + #[serde(rename = "isPullRequest")] + is_pull_request: Option, + labels: Vec, +} + +impl SelectedIssue { + fn has_label(&self, label: &str) -> bool { + self.labels + .iter() + .any(|candidate| candidate.name.eq_ignore_ascii_case(label)) + } + + fn has_priority_boost_label(&self) -> bool { + ISSUE_PRIORITY_BOOST_LABELS + .iter() + .any(|label| self.has_label(label)) + } + + fn primary_priority_rank(&self) -> usize { + ISSUE_PRIORITY_LABELS + .iter() + .position(|label| self.has_label(label)) + .unwrap_or(ISSUE_PRIORITY_LABELS.len()) + } + + fn display_reference(&self) -> String { + issue_reference(&self.url).unwrap_or_else(|| format!("#{}", self.number)) + } + + fn is_open_issue_candidate(&self) -> bool { + matches!(self.state.as_deref(), Some("OPEN") | Some("open")) + && self.is_pull_request != Some(true) + } +} + +fn issue_priority_cmp(left: &SelectedIssue, right: &SelectedIssue) -> std::cmp::Ordering { + right + .has_priority_boost_label() + .cmp(&left.has_priority_boost_label()) + .then_with(|| { + left.primary_priority_rank() + .cmp(&right.primary_priority_rank()) + }) + .then_with(|| right.created_at.cmp(&left.created_at)) +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedIssueLabel { + name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceSnapshot; + + #[test] + fn normalize_github_repository_spec_accepts_owner_repo_and_urls() { + assert_eq!( + normalize_github_repository_spec("micronaut-projects/micronaut-core"), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!( + normalize_github_repository_spec( + "https://github.com/micronaut-projects/micronaut-core" + ), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!( + normalize_github_repository_spec( + "https://github.com/micronaut-projects/micronaut-core.git/" + ), + Some("micronaut-projects/micronaut-core".to_string()) + ); + assert_eq!(normalize_github_repository_spec("invalid"), None); + } + + #[test] + fn start_retry_is_blocked_only_for_same_scan_nonce() { + assert!(start_retry_is_blocked(Some(4), 4)); + assert!(!start_retry_is_blocked(Some(4), 5)); + assert!(!start_retry_is_blocked(None, 4)); + } + + #[test] + fn find_next_issue_prioritizes_boost_labels_then_base_priority_then_newest() { + let excluded = HashSet::from(["https://github.com/example/repo/issue/5".to_string()]); + let mut issues = vec![ + SelectedIssue { + number: 5, + title: "already claimed".to_string(), + url: "https://github.com/example/repo/issue/5".to_string(), + created_at: "2026-04-09T10:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + }, + SelectedIssue { + number: 6, + title: "busy".to_string(), + url: "https://github.com/example/repo/issue/6".to_string(), + created_at: "2026-04-09T11:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: IN_PROGRESS_LABEL.to_string(), + }, + ], + }, + SelectedIssue { + number: 7, + title: "plain bug".to_string(), + url: "https://github.com/example/repo/issue/7".to_string(), + created_at: "2026-04-09T09:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + }, + SelectedIssue { + number: 8, + title: "high priority enhancement".to_string(), + url: "https://github.com/example/repo/issue/8".to_string(), + created_at: "2026-04-09T08:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![ + SelectedIssueLabel { + name: "type: enhancement".to_string(), + }, + SelectedIssueLabel { + name: "priority: high".to_string(), + }, + ], + }, + SelectedIssue { + number: 9, + title: "regression bug".to_string(), + url: "https://github.com/example/repo/issue/9".to_string(), + created_at: "2026-04-09T07:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: "type: regression".to_string(), + }, + ], + }, + ]; + + issues.sort_by(issue_priority_cmp); + + let selected = issues + .into_iter() + .filter(|issue| !issue.has_label(IN_PROGRESS_LABEL)) + .filter(|issue| !excluded.contains(&issue.url)) + .next() + .expect("one issue should remain"); + + assert_eq!(selected.number, 9); + } + + #[test] + fn issue_priority_cmp_prefers_newer_issue_with_same_priority_bucket() { + let older = SelectedIssue { + number: 10, + title: "older".to_string(), + url: "https://github.com/example/repo/issues/10".to_string(), + created_at: "2026-04-09T07:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + }; + let newer = SelectedIssue { + number: 11, + title: "newer".to_string(), + url: "https://github.com/example/repo/issues/11".to_string(), + created_at: "2026-04-09T08:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], + }; + + assert_eq!( + issue_priority_cmp(&older, &newer), + std::cmp::Ordering::Greater + ); + assert_eq!(issue_priority_cmp(&newer, &older), std::cmp::Ordering::Less); + } + + #[test] + fn issue_reference_formats_owner_repo_and_number() { + assert_eq!( + issue_reference("https://github.com/example/repo/issues/42"), + Some("example/repo#42".to_string()) + ); + assert_eq!( + issue_reference("https://github.com/example/repo/issue/43"), + Some("example/repo#43".to_string()) + ); + } + + #[test] + fn issue_search_args_excludes_linked_pull_requests() { + let args = issue_search_args("example/repo", "type: bug"); + assert!(args.iter().any(|arg| arg == "-linked:pr")); + assert!(args.windows(2).any(|pair| pair == ["--state", "open"])); + } + + #[test] + fn selected_issue_candidate_must_be_open_and_not_a_pull_request() { + let open_issue = SelectedIssue { + number: 1, + title: "candidate".to_string(), + url: "https://github.com/example/repo/issues/1".to_string(), + created_at: "2026-04-09T10:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![], + }; + assert!(open_issue.is_open_issue_candidate()); + + let closed_issue = SelectedIssue { + state: Some("CLOSED".to_string()), + ..open_issue.clone() + }; + assert!(!closed_issue.is_open_issue_candidate()); + + let pull_request = SelectedIssue { + is_pull_request: Some(true), + ..open_issue + }; + assert!(!pull_request.is_open_issue_candidate()); + } + + #[test] + fn persist_automation_issue_claim_updates_workspace_state() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + let issue = SelectedIssue { + number: 810, + title: "candidate".to_string(), + url: "https://github.com/example/repo/issues/810".to_string(), + created_at: "2026-04-09T10:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![], + }; + + persist_automation_issue_claim(&workspace, "example/repo", &issue); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + assert_eq!( + snapshot.persistent.automation_issue.as_deref(), + Some(issue.url.as_str()) + ); + } + + #[test] + fn clear_automation_issue_claim_only_clears_matching_issue() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + let issue = SelectedIssue { + number: 810, + title: "candidate".to_string(), + url: "https://github.com/example/repo/issues/810".to_string(), + created_at: "2026-04-09T10:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![], + }; + + persist_automation_issue_claim(&workspace, "example/repo", &issue); + clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/999"); + let unchanged = workspace.subscribe().borrow().clone(); + assert_eq!( + unchanged.persistent.automation_issue.as_deref(), + Some(issue.url.as_str()) + ); + + clear_automation_issue_claim(&workspace, &issue.url); + let cleared = workspace.subscribe().borrow().clone(); + assert!(cleared.persistent.automation_issue.is_none()); + assert_eq!( + cleared.persistent.assigned_repository.as_deref(), + Some("example/repo") + ); + } +} diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index cc5cb74..5cc494d 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -18,6 +18,8 @@ pub struct Config { pub isolation: IsolationConfig, #[serde(default)] pub runtime: RuntimeConfig, + #[serde(default)] + pub autonomous: AutonomousConfig, #[serde(default = "default_opencode_commands")] pub opencode: Vec, #[serde(default)] @@ -30,6 +32,24 @@ pub struct Config { pub github: GithubConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AutonomousConfig { + #[serde( + default = "default_issue_scan_delay_seconds", + alias = "issue-scan-delay-seconds" + )] + pub issue_scan_delay_seconds: u64, +} + +impl Default for AutonomousConfig { + fn default() -> Self { + Self { + issue_scan_delay_seconds: default_issue_scan_delay_seconds(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "kebab-case")] pub struct RuntimeConfig { @@ -115,6 +135,10 @@ fn default_remote_sync_interval_seconds() -> u64 { 2 } +fn default_issue_scan_delay_seconds() -> u64 { + 15 * 60 +} + fn default_handler_review() -> String { "/usr/bin/smerge".to_string() } diff --git a/lib/src/services/mod.rs b/lib/src/services/mod.rs index 54ef05c..573739e 100644 --- a/lib/src/services/mod.rs +++ b/lib/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod autonomous_workspace_service; pub mod combined; pub mod config; pub mod github_status_service; @@ -16,10 +17,10 @@ pub(crate) mod workspace_task_watch; pub(crate) mod workspace_watch; pub use crate::database::{Database, DatabaseError}; -pub use combined::{CombinedService, CombinedServiceError}; +pub use combined::{CombinedService, CombinedServiceError, summarize_workspace_start_failure}; pub use config::{ - Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, ToolType, - parse_optional_size_bytes, + AutonomousConfig, Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, + ToolType, parse_optional_size_bytes, }; pub use github_status_service::{ GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, diff --git a/lib/src/services/persistent_storage.rs b/lib/src/services/persistent_storage.rs index 2569c90..2e5ea2b 100644 --- a/lib/src/services/persistent_storage.rs +++ b/lib/src/services/persistent_storage.rs @@ -282,6 +282,8 @@ mod tests { archived: true, description: "loaded from disk".to_string(), created_at: Some(UNIX_EPOCH + Duration::from_secs(10)), + assigned_repository: None, + automation_issue: None, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), @@ -368,6 +370,8 @@ mod tests { archived: true, description: "added later".to_string(), created_at: Some(UNIX_EPOCH + Duration::from_secs(20)), + assigned_repository: None, + automation_issue: None, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), @@ -484,6 +488,8 @@ mod tests { archived: false, description: "missing created_at".to_string(), created_at: None, + assigned_repository: None, + automation_issue: None, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), diff --git a/lib/src/services/root_session_service.rs b/lib/src/services/root_session_service.rs index a567e1d..e28be42 100644 --- a/lib/src/services/root_session_service.rs +++ b/lib/src/services/root_session_service.rs @@ -244,8 +244,16 @@ async fn query_current_root_session_details( .collect::>(), }; - let Some(root_session) = select_root_session(root_session_candidate, &sessions) else { - return Ok(None); + let root_session = match select_root_session(root_session_candidate, &sessions) { + Some(root_session) => root_session, + None => match bootstrap_root_session(client).await { + Ok(Some(root_session)) => root_session, + Ok(None) => return Ok(None), + Err(err) => { + tracing::warn!(error = %err, "failed to bootstrap root session"); + return Ok(None); + } + }, }; let root_session_id: String = root_session.id.clone().into(); @@ -275,6 +283,20 @@ async fn query_current_root_session_details( })) } +async fn bootstrap_root_session( + client: &opencode::client::Client, +) -> Result, String> { + client + .session_create( + None, + None, + &opencode::client::types::SessionCreateBody::default(), + ) + .await + .map(|response| Some(response.into_inner())) + .map_err(|err| err.to_string()) +} + fn select_root_session( root_session_candidate: Option, sessions: &[opencode::client::types::Session], @@ -442,6 +464,22 @@ mod tests { .to_string() } + fn single_session_json(session_id: &str, title: &str) -> String { + serde_json::json!({ + "directory": "/workspace", + "id": session_id, + "projectID": "project-1", + "slug": "root", + "time": { + "created": 1, + "updated": 1 + }, + "title": title, + "version": "1" + }) + .to_string() + } + fn transient_snapshot(uri: &str, runtime_id: &str) -> TransientWorkspaceSnapshot { TransientWorkspaceSnapshot { uri: uri.to_string(), @@ -700,6 +738,98 @@ mod tests { }); } + #[test] + fn service_bootstraps_root_session_when_server_starts_empty() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let addr = listener + .local_addr() + .expect("listener should expose local addr"); + let server_task = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + let mut buffer = vec![0_u8; 4096]; + let read = socket.read(&mut buffer).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buffer[..read]); + + if request.contains("GET /question") { + let body = "[]"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.contains("GET /session/status") { + let body = "{}"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.starts_with("POST /session") { + let body = + single_session_json("ses-root-created", "Root session created"); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + if request.contains("GET /session") { + let body = "[]"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + return; + } + + let response = + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + }); + } + }); + + let base_uri = format!("http://{addr}"); + let client = opencode::client::Client::new(&base_uri); + let root_session = query_current_root_session_details(&client) + .await + .expect("query should succeed") + .expect("root session should be bootstrapped when session list is empty"); + assert_eq!(root_session.id, "ses-root-created"); + assert_eq!(root_session.title, "Root session created"); + assert_eq!(root_session.status, None); + + server_task.abort(); + }); + } + #[test] fn service_marks_pending_question_for_root_session() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/tests/apple_container_runtime_integration.rs b/lib/tests/apple_container_runtime_integration.rs index a06e602..671e3af 100644 --- a/lib/tests/apple_container_runtime_integration.rs +++ b/lib/tests/apple_container_runtime_integration.rs @@ -243,7 +243,10 @@ cpu = "300%" .clone() .expect("transient snapshot should be present"); assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); - assert_eq!(transient.runtime.id, "multicode-alpha"); + assert!( + transient.runtime.id.starts_with("multicode-alpha-"), + "apple runtime id should include the workspace key and a unique suffix" + ); assert!(transient.uri.starts_with("http://opencode:")); let commands = read_commands(&fake_container_root.join("commands.log")); @@ -251,7 +254,7 @@ cpu = "300%" .iter() .find(|line| line.starts_with("run ")) .expect("run command should be logged"); - assert!(run_command.contains("--name multicode-alpha")); + assert!(run_command.contains(&format!("--name {}", transient.runtime.id))); assert!(run_command.contains("--cpus 3")); assert!(run_command.contains("--memory 17179869184")); assert!(run_command.contains("--tmpfs /tmp")); @@ -294,14 +297,16 @@ cpu = "300%" let commands = read_commands(&fake_container_root.join("commands.log")); assert!( - commands.iter().any(|line| line == "rm -f multicode-alpha"), + commands + .iter() + .any(|line| line == &format!("rm -f {}", transient.runtime.id)), "stop should remove the container" ); }); } #[test] -fn start_workspace_removes_stale_named_container_before_run() { +fn start_workspace_uses_unique_runtime_id_even_when_stale_named_container_exists() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -370,20 +375,29 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] service .start_workspace("alpha") .await - .expect("workspace should start after removing stale container"); + .expect("workspace should start even if a stale fixed-name container exists"); + let transient = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone() + .transient + .expect("transient snapshot should be present"); let commands = read_commands(&fake_container_root.join("commands.log")); - let stale_rm_index = commands - .iter() - .position(|line| line == "rm -f multicode-alpha") - .expect("stale container should be removed before start"); - let run_index = commands + let run_command = commands .iter() - .position(|line| line.starts_with("run ")) + .find(|line| line.starts_with("run ")) .expect("run command should be logged"); assert!( - stale_rm_index < run_index, - "stale container removal should happen before run" + run_command.contains(&format!("--name {}", transient.runtime.id)), + "apple backend should start a uniquely named runtime" + ); + assert!( + transient.runtime.id != "multicode-alpha", + "apple backend should not reuse the stale fixed container name" ); }); } diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index b04d505..c6e772a 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -1491,6 +1491,8 @@ mod tests { Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1657,6 +1659,8 @@ mod tests { &Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1709,6 +1713,8 @@ mod tests { add_skills_from: vec!["extra-skills".to_string()], ..Default::default() }, + runtime: Default::default(), + autonomous: Default::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1744,6 +1750,8 @@ mod tests { add_skills_from: vec!["workspace-skills".to_string()], ..Default::default() }, + runtime: Default::default(), + autonomous: Default::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -2307,6 +2315,8 @@ mod tests { &Config { workspace_directory: "~/dev/agent-work".to_string(), isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), diff --git a/tui/src/app.rs b/tui/src/app.rs index 8c17a5a..6040952 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -26,6 +26,11 @@ pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { Some(format!("{NERD_FONT_GITHUB_GLYPH} {owner}/{repo}#{number}")) } +pub(crate) fn should_request_autonomous_issue_scan(snapshot: &WorkspaceSnapshot) -> bool { + snapshot.persistent.assigned_repository.is_some() + && snapshot.persistent.automation_issue.is_none() +} + impl TuiState { pub(crate) async fn new( config_path: PathBuf, @@ -63,10 +68,12 @@ impl TuiState { mode: UiMode::Normal, create_input: String::new(), edit_input: String::new(), + repository_input: String::new(), custom_link_input: String::new(), custom_link_kind: None, custom_link_action: None, custom_link_original_value: None, + pending_delete_workspace_key: None, starting_workspace_key: None, started_wait_since: None, previous_machine_cpu_totals: None, @@ -178,6 +185,10 @@ impl TuiState { self.mode = UiMode::Normal; self.edit_input.clear(); } + UiMode::EditRepository => { + self.mode = UiMode::Normal; + self.repository_input.clear(); + } UiMode::EditCustomLink => { self.mode = UiMode::Normal; self.custom_link_input.clear(); @@ -185,6 +196,10 @@ impl TuiState { self.custom_link_action = None; self.custom_link_original_value = None; } + UiMode::ConfirmDelete => { + self.mode = UiMode::Normal; + self.pending_delete_workspace_key = None; + } _ => {} } } @@ -199,8 +214,7 @@ impl TuiState { Some(WorkspaceUiState::Started) => {} Some(WorkspaceUiState::Stopped) => { if let Some(key) = self.starting_workspace_key.as_deref() { - self.status = - format!("Workspace '{key}' failed to start; server is still stopped"); + self.status = starting_modal_failure_status(key, self.snapshots.get(key)); } self.mode = UiMode::Normal; self.starting_workspace_key = None; @@ -225,6 +239,16 @@ impl TuiState { self.running_operation = None; } } + + if self.mode == UiMode::ConfirmDelete + && self + .pending_delete_workspace_key + .as_deref() + .is_some_and(|key| !self.snapshots.contains_key(key)) + { + self.mode = UiMode::Normal; + self.pending_delete_workspace_key = None; + } } pub(crate) fn selected_workspace_key(&self) -> Option<&str> { @@ -525,6 +549,12 @@ impl TuiState { let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { return; }; + let autonomous_scan_requested = self + .snapshots + .get(&workspace_key) + .filter(|snapshot| should_request_autonomous_issue_scan(snapshot)) + .map(|_| self.service.request_workspace_issue_scan(&workspace_key)) + .transpose(); let requested_refreshes = self .selected_workspace_selectable_links() @@ -534,13 +564,32 @@ impl TuiState { .filter(|url| self.service.github_status_service().request_refresh(url)) .count(); - if requested_refreshes > 0 { - self.status = - format!("Requested GitHub status recheck for workspace '{workspace_key}'"); - } else { - self.status = format!( - "No refreshable GitHub status links available for workspace '{workspace_key}'" - ); + match autonomous_scan_requested { + Err(err) => { + self.status = format!( + "Failed to request autonomous issue scan for workspace '{workspace_key}': {err:?}" + ); + } + Ok(Some(())) => { + if requested_refreshes > 0 { + self.status = format!( + "Requested GitHub status recheck and autonomous issue scan for workspace '{workspace_key}'" + ); + } else { + self.status = + format!("Requested autonomous issue scan for workspace '{workspace_key}'"); + } + } + Ok(None) => { + if requested_refreshes > 0 { + self.status = + format!("Requested GitHub status recheck for workspace '{workspace_key}'"); + } else { + self.status = format!( + "No refreshable GitHub status links available for workspace '{workspace_key}'" + ); + } + } } } @@ -618,7 +667,9 @@ impl TuiState { UiMode::Normal => self.handle_normal_key(terminal, key).await, UiMode::CreateModal => self.handle_create_modal_key(key).await, UiMode::EditDescription => self.handle_edit_key(key), + UiMode::EditRepository => self.handle_repository_key(key).await, UiMode::EditCustomLink => self.handle_custom_link_key(key), + UiMode::ConfirmDelete => self.handle_confirm_delete_key(key).await, UiMode::StartingModal => {} UiMode::ToolProgressModal => self.handle_tool_progress_key(key), } @@ -1170,7 +1221,8 @@ impl TuiState { } Err(err) => { self.status = format!( - "Failed to start workspace '{key}' before attaching: {err:?}" + "Failed to start workspace '{key}' before attaching: {}", + err.summary() ); } } @@ -1242,6 +1294,25 @@ impl TuiState { self.mode = UiMode::EditDescription; } } + KeyCode::Char('g') => { + if link_selected { + return; + } + if let Some(key) = self.selected_workspace_key() { + let Some(snapshot) = self.snapshots.get(key) else { + return; + }; + if !workspace_is_usable(snapshot) { + return; + } + self.repository_input = snapshot + .persistent + .assigned_repository + .clone() + .unwrap_or_default(); + self.mode = UiMode::EditRepository; + } + } KeyCode::Char('s') => { if link_selected { return; @@ -1262,7 +1333,10 @@ impl TuiState { match self.service.start_workspace(&key).await { Ok(_) => self.status = format!("Starting workspace '{key}'"), Err(err) => { - self.status = format!("Failed to start workspace: {err:?}") + self.status = format!( + "Failed to start workspace '{key}': {}", + err.summary() + ) } } } @@ -1284,6 +1358,15 @@ impl TuiState { } self.request_selected_workspace_github_status_refresh(); } + KeyCode::Char('x') => { + if link_selected { + return; + } + if let Some(key) = self.selected_workspace_key().map(str::to_string) { + self.pending_delete_workspace_key = Some(key); + self.mode = UiMode::ConfirmDelete; + } + } KeyCode::Char(ch) => { if link_selected { return; @@ -1373,6 +1456,79 @@ impl TuiState { } } + async fn handle_repository_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.mode = UiMode::Normal; + self.repository_input.clear(); + } + KeyCode::Backspace => { + self.repository_input.pop(); + } + KeyCode::Char(ch) => { + self.repository_input.push(ch); + } + KeyCode::Enter => { + let Some(key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let repository = self.repository_input.trim().to_string(); + let repository = (!repository.is_empty()).then_some(repository); + match self + .service + .assign_workspace_repository(&key, repository.as_deref()) + .await + { + Ok(Some(normalized)) => { + self.status = + format!("Assigned repository '{normalized}' to workspace '{key}'"); + self.mode = UiMode::Normal; + self.repository_input.clear(); + } + Ok(None) => { + self.status = + format!("Cleared repository assignment for workspace '{key}'"); + self.mode = UiMode::Normal; + self.repository_input.clear(); + } + Err(err) => { + self.status = format!("Failed to update repository assignment: {err:?}"); + } + } + } + _ => {} + } + } + + async fn handle_confirm_delete_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.mode = UiMode::Normal; + self.pending_delete_workspace_key = None; + } + KeyCode::Enter => { + let Some(workspace_key) = self.pending_delete_workspace_key.clone() else { + self.mode = UiMode::Normal; + return; + }; + match self.service.delete_workspace(&workspace_key).await { + Ok(()) => { + self.status = format!("Deleted workspace '{workspace_key}'"); + } + Err(err) => { + self.status = format!( + "Failed to delete workspace '{workspace_key}': {}", + err.summary() + ); + } + } + self.mode = UiMode::Normal; + self.pending_delete_workspace_key = None; + } + _ => {} + } + } + fn handle_custom_link_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -1484,6 +1640,21 @@ impl TuiState { } } +pub(crate) fn starting_modal_failure_status( + key: &str, + snapshot: Option<&WorkspaceSnapshot>, +) -> String { + if let Some(automation_status) = snapshot + .and_then(|snapshot| snapshot.automation_status.as_deref()) + .map(str::trim) + .filter(|status| !status.is_empty()) + { + return format!("Workspace '{key}' failed to start: {automation_status}"); + } + + format!("Workspace '{key}' failed to start; server is still stopped") +} + pub(crate) async fn dispatch_handler_action( relay_socket: Option<&Path>, program: &str, diff --git a/tui/src/main.rs b/tui/src/main.rs index 43b8d45..43d8035 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -73,13 +73,17 @@ const TOOL_PROGRESS_MODAL_WIDTH: u16 = 72; const TOOL_PROGRESS_MODAL_HEIGHT: u16 = 14; const CUSTOM_LINK_MODAL_WIDTH: u16 = 72; const CUSTOM_LINK_MODAL_HEIGHT: u16 = 10; +const CONFIRM_DELETE_MODAL_WIDTH: u16 = 72; +const CONFIRM_DELETE_MODAL_HEIGHT: u16 = 9; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiMode { Normal, CreateModal, EditDescription, + EditRepository, EditCustomLink, + ConfirmDelete, StartingModal, ToolProgressModal, } @@ -108,10 +112,12 @@ struct TuiState { mode: UiMode, create_input: String, edit_input: String, + repository_input: String, custom_link_input: String, custom_link_kind: Option, custom_link_action: Option, custom_link_original_value: Option, + pending_delete_workspace_key: Option, starting_workspace_key: Option, started_wait_since: Option, previous_machine_cpu_totals: Option, @@ -155,6 +161,7 @@ struct WorkspaceLink { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum WorkspaceLinkSource { Custom, + Automation, AgentProvided, } @@ -393,19 +400,36 @@ fn machine_description( #[cfg(test)] fn description_cell_text(snapshot: &WorkspaceSnapshot, user_description: &str) -> String { + let automation_status = snapshot.automation_status.as_deref().unwrap_or("").trim(); let session_title = snapshot.root_session_title.as_deref().unwrap_or("").trim(); - if session_title.is_empty() { - return user_description.to_string(); + let mut parts = Vec::new(); + if !user_description.is_empty() { + parts.push(user_description.to_string()); + } + if !automation_status.is_empty() { + parts.push(automation_status.to_string()); } - if user_description.is_empty() { - return session_title.to_string(); + if !session_title.is_empty() { + parts.push(session_title.to_string()); } - format!("{user_description} Β· {session_title}") + parts.join(" Β· ") } fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { let mut links = Vec::new(); + links.extend( + snapshot + .persistent + .automation_issue + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::Automation, + }), + ); links.extend( snapshot .persistent @@ -490,7 +514,9 @@ fn description_line_for_snapshot( archived: bool, ) -> Line<'static> { let session_title = snapshot.root_session_title.as_deref().unwrap_or("").trim(); + let automation_status = snapshot.automation_status.as_deref().unwrap_or("").trim(); let has_session_title = !session_title.is_empty(); + let has_automation_status = !automation_status.is_empty(); let has_description = !user_description.is_empty(); let mut spans = Vec::new(); @@ -515,6 +541,19 @@ fn description_line_for_snapshot( } } + if has_automation_status { + if has_content { + spans.push(Span::raw(" Β· ")); + } + let automation_text = if archived || !automation_status_shows_activity(automation_status) { + automation_status.to_string() + } else { + format!("{} {}", automation_activity_glyph(), automation_status) + }; + spans.push(Span::raw(automation_text)); + has_content = true; + } + if has_session_title { if has_content { spans.push(Span::raw(" Β· ")); @@ -525,6 +564,24 @@ fn description_line_for_snapshot( Line::from(spans) } +fn automation_activity_glyph() -> &'static str { + const FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; + let frame = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| ((elapsed.as_millis() / 200) as usize) % FRAMES.len()) + .unwrap_or(0); + FRAMES[frame] +} + +fn automation_status_shows_activity(status: &str) -> bool { + !matches!( + status, + status if status.starts_with("Start failed") + || status.starts_with("Scan failed") + || status.starts_with("No issues") + ) +} + fn first_validated_workspace_link_by_kind( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -726,6 +783,7 @@ fn help_line( selected_link_is_placeholder: bool, selected_link_kind: Option, selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_repository: bool, tool_hotkeys: &[(String, String)], status: &str, ) -> Line<'static> { @@ -780,7 +838,11 @@ fn help_line( push_hotkey(&mut spans, "r", " recheck GH status "); } } + if selected_workspace_can_assign_repository { + push_hotkey(&mut spans, "g", " repository "); + } push_hotkey(&mut spans, "d", " edit description "); + push_hotkey(&mut spans, "x", " delete "); let archive_action = if snapshot.persistent.archived { " unarchive " } else { @@ -805,12 +867,22 @@ fn help_line( push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Esc", " cancel"); } + UiMode::EditRepository => { + spans.push(Span::raw("Assign repository: type owner/repo or URL, ")); + push_hotkey(&mut spans, "Enter", " save, "); + push_hotkey(&mut spans, "Esc", " cancel"); + } UiMode::EditCustomLink => { spans.push(Span::raw("Edit link: type, ")); push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Del", " delete, "); push_hotkey(&mut spans, "Esc", " cancel"); } + UiMode::ConfirmDelete => { + spans.push(Span::raw("Delete workspace: ")); + push_hotkey(&mut spans, "Enter", " confirm, "); + push_hotkey(&mut spans, "Esc", " cancel"); + } UiMode::StartingModal => { spans.push(Span::raw( "Starting workspace and waiting for server readiness...", diff --git a/tui/src/render.rs b/tui/src/render.rs index b33cc45..08a04c2 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -249,7 +249,13 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.selected_workspace_link() .is_some_and(|link| link.value.is_empty()), app.selected_workspace_link().map(|link| link.kind), - app.selected_workspace_has_refreshable_github_link(), + app.selected_workspace_has_refreshable_github_link() + || app + .selected_workspace_snapshot() + .is_some_and(|snapshot| snapshot.persistent.assigned_repository.is_some()), + app.selected_workspace_snapshot() + .is_some_and(workspace_is_usable) + && app.selected_link_index.is_none(), &app.contextual_tool_hotkeys(), &app.status, ); @@ -257,6 +263,8 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { if app.mode == UiMode::CreateModal { draw_create_modal(frame, &app.create_input); + } else if app.mode == UiMode::EditRepository { + draw_repository_modal(frame, &app.repository_input); } else if app.mode == UiMode::EditCustomLink { draw_custom_link_modal( frame, @@ -264,10 +272,18 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.custom_link_action, &app.custom_link_input, ); + } else if app.mode == UiMode::ConfirmDelete + && let Some(workspace_key) = app.pending_delete_workspace_key.as_deref() + { + draw_confirm_delete_modal(frame, workspace_key); } else if app.mode == UiMode::StartingModal && let Some(workspace_key) = app.starting_workspace_key.as_deref() { - draw_starting_modal(frame, workspace_key); + let detail = app + .snapshots + .get(workspace_key) + .and_then(|snapshot| snapshot.automation_status.as_deref()); + draw_starting_modal(frame, workspace_key, detail); } else if app.mode == UiMode::ToolProgressModal && let Some((tool_name, progress)) = app.running_tool_progress() { @@ -322,6 +338,30 @@ fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str) { )); } +fn draw_repository_modal(frame: &mut Frame, input: &str) { + let area = centered_rect_fixed( + CREATE_MODAL_WIDTH.max(72), + CREATE_MODAL_HEIGHT, + frame.area(), + ); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Assign repository ") + .borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Length(3)]) + .split(inner); + frame.render_widget( + Paragraph::new("GitHub repository (owner/repo or URL). Leave empty to clear.") + .wrap(Wrap { trim: true }), + vertical[0], + ); + draw_modal_text_input(frame, vertical[1], input); +} + pub(crate) fn selected_link_tooltip_area( table_area: Rect, selected_row: usize, @@ -490,6 +530,47 @@ pub(crate) fn draw_create_modal(frame: &mut Frame, input: &str) { ); } +fn draw_confirm_delete_modal(frame: &mut Frame, workspace_key: &str) { + let area = centered_rect_fixed( + CONFIRM_DELETE_MODAL_WIDTH, + CONFIRM_DELETE_MODAL_HEIGHT, + frame.area(), + ); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Delete workspace ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(format!( + "Delete workspace '{workspace_key}'? This stops the workspace and removes its files and containers." + )) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + rows[1], + ); + frame.render_widget( + Paragraph::new("Enter to delete Β· Esc to cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)), + rows[2], + ); +} + pub(crate) fn draw_custom_link_modal( frame: &mut Frame, kind: Option, @@ -549,7 +630,7 @@ pub(crate) fn draw_custom_link_modal( ); } -pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str) { +pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str, detail: Option<&str>) { let area = centered_rect_fixed(STARTING_MODAL_WIDTH, STARTING_MODAL_HEIGHT, frame.area()); frame.render_widget(Clear, area); @@ -579,9 +660,19 @@ pub(crate) fn draw_starting_modal(frame: &mut Frame, workspace_key: &str) { frame.render_widget( Paragraph::new("Waiting for server readiness...") .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) .style(Style::default().fg(Color::DarkGray)), rows[2], ); + if let Some(detail) = detail.map(str::trim).filter(|detail| !detail.is_empty()) { + frame.render_widget( + Paragraph::new(detail.to_string()) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::Gray)), + rows[3], + ); + } } pub(crate) fn draw_tool_progress_modal(frame: &mut Frame, tool_name: &str, progress: &str) { diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 09c5957..5484197 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -5,7 +5,7 @@ mod tests { use multicode_lib::services::HandlerConfig; use super::*; - use crate::app::compact_github_tooltip_target; + use crate::app::{compact_github_tooltip_target, starting_modal_failure_status}; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, pr_review_icon_color, @@ -83,6 +83,8 @@ mod tests { root_session_id: None, root_session_title: None, root_session_status: None, + automation_status: None, + automation_scan_request_nonce: 0, usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -106,6 +108,8 @@ mod tests { root_session_id: None, root_session_title: None, root_session_status: None, + automation_status: None, + automation_scan_request_nonce: 0, usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -118,6 +122,24 @@ mod tests { &[].as_slice() } + #[test] + fn should_request_autonomous_issue_scan_for_assigned_workspace_without_active_issue() { + let mut stopped = WorkspaceSnapshot::default(); + stopped.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + assert!(crate::app::should_request_autonomous_issue_scan(&stopped)); + + stopped.persistent.automation_issue = Some( + "https://github.com/micronaut-projects/micronaut-serialization/issues/989".to_string(), + ); + assert!(!crate::app::should_request_autonomous_issue_scan(&stopped)); + + let unassigned = WorkspaceSnapshot::default(); + assert!(!crate::app::should_request_autonomous_issue_scan( + &unassigned + )); + } + #[test] fn workspace_attach_target_requires_started_state() { let err = workspace_attach_target(&snapshot(false, Some("http://example"))) @@ -903,6 +925,7 @@ mod tests { false, Some(WorkspaceLinkKind::Issue), true, + false, no_tool_hotkeys(), "", ); @@ -937,6 +960,7 @@ mod tests { false, Some(WorkspaceLinkKind::Issue), true, + false, no_tool_hotkeys(), "", ); @@ -964,6 +988,7 @@ mod tests { true, Some(WorkspaceLinkKind::Issue), true, + false, no_tool_hotkeys(), "", ); @@ -993,6 +1018,7 @@ mod tests { false, None, true, + false, no_tool_hotkeys(), "", ); @@ -1014,6 +1040,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1038,6 +1065,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1066,6 +1094,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1088,6 +1117,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1100,6 +1130,60 @@ mod tests { assert!(stopped_text.contains("Enter start+attach")); } + #[test] + fn help_line_shows_repository_hotkey_for_usable_workspace_row_focus() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + 0, + None, + false, + false, + None, + false, + true, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("g repository")); + } + + #[test] + fn help_line_shows_delete_hotkey_for_workspace_row_focus() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + 0, + None, + false, + false, + None, + false, + true, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("x delete")); + } + #[test] fn help_line_shows_starting_message_in_starting_modal() { let line = help_line( @@ -1113,6 +1197,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1125,12 +1210,42 @@ mod tests { assert!(text.contains("Starting workspace and waiting for server readiness")); } + #[test] + fn help_line_shows_confirm_delete_message() { + let line = help_line( + UiMode::ConfirmDelete, + 1, + 1, + Some(&snapshot(false, None)), + 0, + None, + false, + false, + None, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("Delete workspace:")); + assert!(text.contains("Enter")); + } + #[test] fn starting_modal_closes_when_workspace_returns_to_stopped() { let mut mode = UiMode::StartingModal; let mut starting_workspace_key = Some("alpha".to_string()); let mut started_wait_since = Some(Instant::now()); let mut status = String::new(); + let mut failed_snapshot = WorkspaceSnapshot::default(); + failed_snapshot.automation_status = + Some("Start failed alpha: workspace start failed".to_string()); let starting_state = Some(WorkspaceUiState::Stopped); match starting_state { @@ -1138,7 +1253,7 @@ mod tests { Some(WorkspaceUiState::Started) => {} Some(WorkspaceUiState::Stopped) => { if let Some(key) = starting_workspace_key.as_deref() { - status = format!("Workspace '{key}' failed to start; server is still stopped"); + status = starting_modal_failure_status(key, Some(&failed_snapshot)); } mode = UiMode::Normal; starting_workspace_key = None; @@ -1154,7 +1269,19 @@ mod tests { assert_eq!(mode, UiMode::Normal); assert!(starting_workspace_key.is_none()); assert!(started_wait_since.is_none()); - assert!(status.contains("failed to start")); + assert!(status.contains("Start failed alpha")); + } + + #[test] + fn starting_modal_failure_status_prefers_workspace_automation_status() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.automation_status = + Some("Start failed serialization: Apple container allocator exhausted".to_string()); + + assert_eq!( + starting_modal_failure_status("serialization", Some(&snapshot)), + "Workspace 'serialization' failed to start: Start failed serialization: Apple container allocator exhausted" + ); } #[test] @@ -1172,6 +1299,7 @@ mod tests { false, None, false, + false, &tool_hotkeys, "", ); @@ -1332,6 +1460,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1355,6 +1484,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1404,6 +1534,19 @@ mod tests { ); } + #[test] + fn description_cell_text_includes_automation_status_before_root_session_title() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.description = "Custom description".to_string(); + started.automation_status = Some("Working on example/repo#42".to_string()); + started.root_session_title = Some("Root session title".to_string()); + + assert_eq!( + description_cell_text(&started, &started.persistent.description), + "Custom description Β· Working on example/repo#42 Β· Root session title" + ); + } + #[test] fn description_line_styles_custom_description_cyan() { let mut started = snapshot(true, Some("http://example")); @@ -1415,6 +1558,19 @@ mod tests { assert_eq!(line.spans[2].content, "Root session title"); } + #[test] + fn description_line_does_not_prefix_spinner_for_failure_status() { + let mut started = snapshot(true, Some("http://example")); + started.automation_status = Some("Start failed repo: workspace start failed".to_string()); + + let line = description_line(&started, "", false); + + assert_eq!( + line.spans[0].content, + "Start failed repo: workspace start failed" + ); + } + #[test] fn description_line_shows_bold_red_oom_prefix_before_description() { let mut started = snapshot(true, Some("http://example")); @@ -1615,6 +1771,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1639,6 +1796,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); @@ -1662,6 +1820,7 @@ mod tests { false, None, false, + false, no_tool_hotkeys(), "", ); From 2c62e0db5aeeb827c2b20abddba9785556f46bb0 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 12:31:49 +0200 Subject: [PATCH 07/75] Support for Codex as Multicode Agent Add Codex as a configurable multicode agent alongside the existing OpenCode flow, including runtime wiring, app-server integration, Apple container support, and documentation for Codex-specific configuration. Extend Apple container isolation so Codex can run inside macOS-backed workspaces with a synthetic CODEX_HOME, mounted skills, host configuration passthrough, and agent-specific images/tooling for Java 25 and related Micronaut workflows. Implement autonomous issue-handling support for Codex-backed workspaces, including repository assignment, issue claiming, explicit automation state tracking, and state-file based status propagation so the TUI can distinguish working, question, review, and idle states more reliably. Harden the live Apple-container path by fixing assigned-issue resume behavior across restarts, making gh issue inspection compatible with the installed gh version, and avoiding false idle transitions caused by expiring automation state during long-running Codex work. Update the TUI flow and integration coverage to reflect the new agent option and autonomous workflow behavior, and document the new Codex configuration and runtime expectations in the README and example config. Co-Authored-By: Codex --- Cargo.lock | 60 ++ README.md | 126 +++- apple-container/Containerfile | 9 +- apple-container/build-local.sh | 20 +- config.codex.yml | 53 ++ config.toml | 19 +- lib/Cargo.toml | 2 + lib/src/lib.rs | 18 + .../services/automation_state_file_service.rs | 193 ++++++ .../services/autonomous_workspace_service.rs | 530 +++++++++++++-- lib/src/services/codex_app_server.rs | 552 ++++++++++++++++ .../services/codex_root_session_service.rs | 476 +++++++++++++ lib/src/services/combined.rs | 623 ++++++++++++++++-- lib/src/services/config.rs | 98 ++- lib/src/services/mod.rs | 10 +- .../services/multicode_metadata_service.rs | 288 +++++++- lib/src/services/persistent_storage.rs | 3 + lib/src/services/resource_usage_service.rs | 103 ++- lib/src/services/runtime.rs | 582 ++++++++++++++-- .../apple_container_runtime_integration.rs | 379 +++++++++++ remote/src/orchestration.rs | 8 +- tui/src/app.rs | 210 ++++-- tui/src/main.rs | 86 ++- tui/src/ops.rs | 86 ++- tui/src/render.rs | 88 ++- tui/src/tests.rs | 140 +++- workspace-skills/autonomous-state/SKILL.md | 32 + 27 files changed, 4451 insertions(+), 343 deletions(-) create mode 100644 config.codex.yml create mode 100644 lib/src/services/automation_state_file_service.rs create mode 100644 lib/src/services/codex_app_server.rs create mode 100644 lib/src/services/codex_root_session_service.rs create mode 100644 workspace-skills/autonomous-state/SKILL.md diff --git a/Cargo.lock b/Cargo.lock index bb8f305..2dece4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,6 +577,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deltae" version = "0.3.2" @@ -1756,6 +1762,7 @@ dependencies = [ "base64", "diesel", "diesel_migrations", + "futures-util", "libsqlite3-sys", "octocrab", "openapiv3", @@ -1772,6 +1779,7 @@ dependencies = [ "syn 2.0.117", "tokio", "tokio-stream", + "tokio-tungstenite", "toml 1.0.6+spec-1.1.0", "tracing", "tracing-subscriber", @@ -3093,6 +3101,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3575,6 +3594,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3761,6 +3796,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3880,6 +3934,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/README.md b/README.md index 51f33a8..7b27845 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # multicode -… runs isolated [opencode](https://opencode.ai/) instances in parallel. +… runs isolated AI coding agent instances in parallel. The [Micronaut Project](https://micronaut.io/) gets hundreds of issue reports from users. Many of them are easy to solve, but still take time to understand, debug and fix. AI agents can solve many of these issues on their own. @@ -20,9 +20,52 @@ cargo run --bin multicode-tui config.toml ## Workspaces *multicode* parallelizes work in **workspaces**. They are short-lived and isolated. Typically, a workspace is used for -a single issue report. A workspace gets its own working directory and OpenCode session, so you can work from a blank +a single issue report. A workspace gets its own working directory and agent session, so you can work from a blank slate. +## Agent configuration + +The agent used inside workspaces is configured globally in `config.toml` with the `[agent]` section. + +OpenCode remains the default: + +```toml +[agent] +provider = "opencode" + +# Backwards-compatible top-level command list. +opencode = ["opencode-cli", "opencode"] + +[agent.opencode] +commands = ["opencode-cli", "opencode"] +``` + +To use Codex instead: + +```toml +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" +``` + +Notes: + +- `provider` is global for the whole multicode instance. A single TUI session uses either OpenCode or Codex for all workspaces. +- `commands` is the host-side command resolution order. The first installed command is used. +- OpenCode keeps the existing top-level `opencode = [...]` setting for backwards compatibility. If `[agent.opencode].commands` is omitted, multicode falls back to that list. +- Codex workspaces use `codex app-server` inside the isolate/container and `codex resume --remote ...` when attaching from the TUI. +- `profile` is optional for Codex. If you set it, it must name a real profile from your host `~/.codex/config.toml`. +- For Codex, `approval-policy = "never"` suppresses approval prompts, `sandbox-mode = "workspace-write"` keeps Codex's own sandbox active, and `sandbox-mode = "external-sandbox"` tells Codex to trust the outer multicode sandbox such as the Apple container runtime. +- `network-access = "enabled"` is the practical setting for issue fixing workflows that need GitHub, dependency downloads, or web access. With `external-sandbox`, this is sent as Codex app-server `sandboxPolicy.networkAccess`. +- `runtime.image` is still the global image override. If you want separate images, use `runtime.opencode-image` and `runtime.codex-image`. + ## Isolation Workspaces are *isolated* from each other. This isolation is for safety and convenience, it **does not provide @@ -39,12 +82,17 @@ Isolation is implemented using `systemd-run` (for resource constraints) and On newer Apple Silicon Macs, there is also an experimental Apple `container` runtime backend. It reuses the existing `[isolation]` configuration for readable, writable, isolated, and `tmpfs` -paths, and maps CPU / memory limits onto container allocation settings: +paths, and maps CPU / memory limits onto container allocation settings. + +OpenCode example: ```toml +[agent] +provider = "opencode" + [runtime] backend = "apple-container" -image = "ghcr.io/example/multicode-java25:latest" +opencode-image = "ghcr.io/example/multicode-opencode-java25:latest" [isolation] writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] @@ -61,6 +109,72 @@ skills, and other OpenCode configuration as the host. This is useful if you mana profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` isolated so session state remains per-workspace. +Codex example: + +```toml +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[runtime] +backend = "apple-container" +codex-image = "ghcr.io/example/multicode-codex-java25:latest" + +[isolation] +add-skills-from = ["./workspace-skills"] +writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] +inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] +memory-max = "16 GiB" +cpu = "300%" +``` + +For Codex, multicode prepares a synthetic per-workspace `CODEX_HOME` inside the isolate/container. +It copies the host `~/.codex/config.toml`, `~/.codex/auth.json`, `~/.codex/AGENTS.md`, and +`~/.codex/skills`, then merges in any `add-skills-from` mounts. This keeps Codex session state +isolated per workspace while still reusing your host configuration and credentials. + +If you want Codex to behave more autonomously inside an Apple container, prefer: + +```toml +[agent.codex] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" +``` + +That combination keeps multicode's Apple container as the real isolation boundary while avoiding repeated Codex approval prompts for normal shell execution. + +If you maintain two images, the practical split is: + +```toml +[runtime] +backend = "apple-container" +opencode-image = "ghcr.io/example/multicode-opencode-java25:latest" +codex-image = "ghcr.io/example/multicode-codex-java25:latest" +``` + +Use the OpenCode image for the existing OpenCode workflow and a Codex image that includes the `codex` CLI and any Codex-specific bootstrap you need. + +To build both local Apple-container images from this repository: + +```bash +./apple-container/build-local.sh +``` + +That script produces: + +- `multicode-java25:latest` and `multicode-opencode-java25:latest` for the OpenCode workflow +- `multicode-codex-java25:latest` for the Codex workflow + +The split keeps the existing OpenCode image compatible while allowing the Codex image to install Codex-specific tooling without changing the OpenCode bootstrap path. + Apple workspaces also expose the host `~/.gitconfig` automatically. The runtime mounts it through an internal read-only path and sets `GIT_CONFIG_GLOBAL` so git can use your host global identity and defaults without requiring a direct file bind. @@ -141,8 +255,8 @@ be moved to the bottom of the UI. You can unarchive it again when needed. *multicode-remote* is a helper tool to run a multicode instance on a remote machine. Features: -* Dependency installation (bubblewrap, opencode, ...) -* Synchronization of local opencode configuration (including authentication details) +* Dependency installation (bubblewrap, opencode, codex, ...) +* Synchronization of local agent configuration (including authentication details) * Synchronization of GitHub credentials * Bi-directional synchronization of the agent workspace * Opening links in the local browser or git diff viewer diff --git a/apple-container/Containerfile b/apple-container/Containerfile index e048edc..f522f28 100644 --- a/apple-container/Containerfile +++ b/apple-container/Containerfile @@ -5,6 +5,8 @@ FROM ghcr.io/graalvm/native-image-community:25 ARG HOST_UID=1000 ARG HOST_GID=1000 ARG GH_VERSION=2.83.2 +ARG INSTALL_OPENCODE=1 +ARG INSTALL_CODEX=0 COPY --from=node /usr/local/ /usr/local/ @@ -35,7 +37,12 @@ RUN set -eux; \ tar -xzf /tmp/gh.tar.gz -C /tmp; \ install "/tmp/gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \ rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${gh_arch}"; \ - npm install -g opencode-ai; \ + if [ "${INSTALL_OPENCODE}" = "1" ]; then \ + npm install -g opencode-ai; \ + fi; \ + if [ "${INSTALL_CODEX}" = "1" ]; then \ + npm install -g @openai/codex; \ + fi; \ if ! getent group "${HOST_GID}" >/dev/null; then \ groupadd --gid "${HOST_GID}" multicode; \ fi; \ diff --git a/apple-container/build-local.sh b/apple-container/build-local.sh index 12d3da1..dde93eb 100755 --- a/apple-container/build-local.sh +++ b/apple-container/build-local.sh @@ -2,10 +2,24 @@ set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +HOST_UID=$(id -u) +HOST_GID=$(id -g) -exec container build \ +container build \ -t multicode-java25:latest \ + -t multicode-opencode-java25:latest \ + -f "$SCRIPT_DIR/Containerfile" \ + --build-arg "HOST_UID=$HOST_UID" \ + --build-arg "HOST_GID=$HOST_GID" \ + --build-arg "INSTALL_OPENCODE=1" \ + --build-arg "INSTALL_CODEX=0" \ + "$SCRIPT_DIR" + +exec container build \ + -t multicode-codex-java25:latest \ -f "$SCRIPT_DIR/Containerfile" \ - --build-arg "HOST_UID=$(id -u)" \ - --build-arg "HOST_GID=$(id -g)" \ + --build-arg "HOST_UID=$HOST_UID" \ + --build-arg "HOST_GID=$HOST_GID" \ + --build-arg "INSTALL_OPENCODE=0" \ + --build-arg "INSTALL_CODEX=1" \ "$SCRIPT_DIR" diff --git a/config.codex.yml b/config.codex.yml new file mode 100644 index 0000000..22ddd10 --- /dev/null +++ b/config.codex.yml @@ -0,0 +1,53 @@ +workspace-directory = "/tmp/multicode-codex-workspaces" +opencode = ["opencode-cli", "opencode"] + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[runtime] +backend = "apple-container" +codex-image = "multicode-codex-java25:latest" + +[github] +token = {env = "GITHUB_MCP_TOKEN"} +populate-git-credentials = true + +[isolation] +add-skills-from = ["./workspace-skills"] +writable = [ + "~/.codex", + "~/.gradle", + "~/.m2/repository", + "~/.config/gh", + "$XDG_RUNTIME_DIR", +] +tmpfs = ["/tmp"] +inherit-env = [ + "XDG_RUNTIME_DIR", + "HOME", + "PATH", + "LANG", + "TERM", + "COLORTERM", + "GITHUB_MCP_TOKEN", +] +memory-high = "6 GiB" +memory-max = "8 GiB" +cpu = "300%" + +[handler] +review = "/usr/bin/open ." +review-pty = false +web = "/usr/bin/open {}" + +[[tool]] +type = "exec" +name = "bash" +key = "b" +exec = "/bin/bash" diff --git a/config.toml b/config.toml index b51cd91..554ad83 100644 --- a/config.toml +++ b/config.toml @@ -2,9 +2,21 @@ workspace-directory = "~/dev/agent-work" opencode = ["opencode-cli", "opencode"] # todo: find a solution that isn't bound to TUI lifecycle +[agent] +provider = "opencode" + +[agent.codex] +commands = ["codex"] +# profile = "default" +# model = "gpt-5-codex" +# model-provider = "openai" +# approval-policy = "never" +# sandbox-mode = "external-sandbox" +# network-access = "enabled" + [runtime] backend = "apple-container" -# Local Apple container image. It should contain Java 25, git, gh, and opencode. +# Local Apple container image. It should contain Java 25, git, gh, opencode, and codex. image = "multicode-java25:latest" [github] @@ -17,6 +29,7 @@ add-skills-from = ["./workspace-skills"] writable = [ "~/.local/share/opencode/bin", "~/.cache/opencode", + "~/.codex", "~/.bun", "~/.cache/bun", "~/.gradle", @@ -82,6 +95,10 @@ dereference-symlinks = true local = "~/.local/share/opencode/auth.json" remote = "~/.local/share/opencode/auth.json" dereference-symlinks = true +[[remote.sync-up]] +local = "~/.codex" +remote = "~/.codex" +dereference-symlinks = true [[remote.sync-bidi]] local = "/var/tmp/multicode-remote-workspace" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 51b71ea..eb5b5b1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -16,6 +16,8 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "str progenitor-client = "0.13" regress = "0.10" tokio-stream = "0.1" +futures-util = "0.3" +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } diesel = { version = "2", features = ["sqlite", "r2d2"] } diesel_migrations = "2" libsqlite3-sys = { version = "0", features = ["bundled"] } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index fd2f282..bb32abb 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -72,6 +72,8 @@ pub struct PersistentWorkspaceSnapshot { #[serde(default)] pub automation_issue: Option, #[serde(default)] + pub automation_paused: bool, + #[serde(default)] pub archive_format: Option, #[serde(default)] pub agent_provided: AgentProvidedPersistentSnapshot, @@ -87,6 +89,7 @@ impl Default for PersistentWorkspaceSnapshot { created_at: None, assigned_repository: None, automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: AgentProvidedPersistentSnapshot::default(), custom_links: CustomLinksPersistentSnapshot::default(), @@ -131,6 +134,15 @@ pub struct TransientWorkspaceSnapshot { pub runtime: RuntimeHandleSnapshot, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutomationAgentState { + Working, + Question, + Review, + Idle, + Stale, +} + /// Holder for the HTTP connection to the opencode server. #[derive(Clone)] pub struct OpencodeClientSnapshot { @@ -154,6 +166,9 @@ pub struct WorkspaceSnapshot { pub root_session_id: Option, pub root_session_title: Option, pub root_session_status: Option, + pub automation_session_id: Option, + pub automation_session_status: Option, + pub automation_agent_state: Option, pub automation_status: Option, pub automation_scan_request_nonce: u64, pub usage_total_tokens: Option, @@ -172,6 +187,9 @@ impl Default for WorkspaceSnapshot { root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, usage_total_tokens: None, diff --git a/lib/src/services/automation_state_file_service.rs b/lib/src/services/automation_state_file_service.rs new file mode 100644 index 0000000..2758d91 --- /dev/null +++ b/lib/src/services/automation_state_file_service.rs @@ -0,0 +1,193 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use tokio::time::{MissedTickBehavior, interval}; + +use super::{ + root_session_service::RootSessionStatus, runtime::automation_state_file_source, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + AutomationAgentState, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, + manager::Workspace, +}; + +const STATE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug)] +pub enum AutomationStateFileServiceError { + Manager(WorkspaceManagerError), +} + +impl From for AutomationStateFileServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub async fn automation_state_file_service( + manager: Arc, + workspace_directory_path: PathBuf, +) -> Result<(), AutomationStateFileServiceError> { + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let workspace_directory_path = workspace_directory_path.clone(); + async move { + tokio::spawn(async move { + watch_workspace( + workspace, + workspace_rx, + automation_state_file_source(&workspace_directory_path, &key), + ) + .await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace( + workspace: Workspace, + mut workspace_rx: tokio::sync::watch::Receiver, + state_file: PathBuf, +) { + let mut refresh = interval(STATE_REFRESH_INTERVAL); + refresh.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + let snapshot = workspace_rx.borrow().clone(); + let should_track = snapshot.transient.is_some() + && !snapshot.persistent.archived + && !snapshot.persistent.automation_paused + && snapshot.persistent.automation_issue.is_some(); + + if should_track { + apply_state_file_snapshot(&workspace, read_state_file(&state_file).await); + } else { + clear_automation_state(&workspace); + } + + tokio::select! { + changed = workspace_rx.changed() => { + if changed.is_err() { + break; + } + } + _ = refresh.tick() => {} + } + } +} + +fn apply_state_file_snapshot(workspace: &Workspace, next: Option) { + workspace.update(|snapshot| { + let next_session_id = next.as_ref().and_then(|state| state.thread_id.clone()); + let next_agent_state = next.as_ref().map(|state| state.state); + let next_session_status = next.as_ref().map(|state| state.state.root_status()); + + let mut changed = false; + if snapshot.automation_session_id != next_session_id { + snapshot.automation_session_id = next_session_id; + changed = true; + } + if snapshot.automation_agent_state != next_agent_state { + snapshot.automation_agent_state = next_agent_state; + changed = true; + } + if snapshot.automation_session_status != next_session_status { + snapshot.automation_session_status = next_session_status; + changed = true; + } + changed + }); +} + +fn clear_automation_state(workspace: &Workspace) { + workspace.update(|snapshot| { + let mut changed = false; + if snapshot.automation_session_id.take().is_some() { + changed = true; + } + if snapshot.automation_agent_state.take().is_some() { + changed = true; + } + if snapshot.automation_session_status.take().is_some() { + changed = true; + } + changed + }); +} + +async fn read_state_file(path: &Path) -> Option { + tokio::fs::metadata(path).await.ok()?; + let contents = tokio::fs::read_to_string(path).await.ok()?; + parse_state_file(&contents) +} + +fn parse_state_file(contents: &str) -> Option { + let trimmed = contents.trim(); + let (state, thread_id) = + trimmed + .split_once(':') + .map_or((trimmed, None), |(state, thread_id)| { + let thread_id = thread_id.trim(); + ( + state.trim(), + (!thread_id.is_empty()).then(|| thread_id.to_string()), + ) + }); + + let state = if state.eq_ignore_ascii_case("working") { + AutomationAgentState::Working + } else if state.eq_ignore_ascii_case("question") { + AutomationAgentState::Question + } else if state.eq_ignore_ascii_case("review") { + AutomationAgentState::Review + } else if state.eq_ignore_ascii_case("idle") { + AutomationAgentState::Idle + } else { + AutomationAgentState::Stale + }; + + Some(ParsedAutomationState { state, thread_id }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedAutomationState { + state: AutomationAgentState, + thread_id: Option, +} + +impl AutomationAgentState { + fn root_status(self) -> RootSessionStatus { + match self { + AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::Question => RootSessionStatus::Question, + AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale => RootSessionStatus::Idle, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_state_file_maps_known_states() { + let parsed = parse_state_file("question:thread-123\n").expect("state exists"); + + assert_eq!(parsed.state, AutomationAgentState::Question); + assert_eq!(parsed.thread_id.as_deref(), Some("thread-123")); + } + + #[test] + fn parse_state_file_marks_unknown_state_as_stale() { + let parsed = parse_state_file("bogus\n").expect("state exists"); + + assert_eq!(parsed.state, AutomationAgentState::Stale); + } +} diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 50f0a64..1b1b031 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{HashMap, HashSet}, - time::Duration, -}; +use std::{collections::HashSet, time::Duration}; use serde::Deserialize; use tokio::{ @@ -10,9 +7,14 @@ use tokio::{ time::{Instant, sleep_until}, }; -use super::{CombinedService, GithubStatus, workspace_watch::monitor_workspace_snapshots}; +use super::{ + CombinedService, GithubStatus, + runtime::{AUTOMATION_STATE_ENV, automation_state_file_source}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{ - RootSessionStatus, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, + AutomationAgentState, RootSessionStatus, WorkspaceManagerError, WorkspaceSnapshot, + manager::Workspace, }; const ISSUE_PRIORITY_LABELS: [&str; 4] = [ @@ -81,6 +83,7 @@ async fn watch_workspace( next_scan_at = None; blocked_start_scan_request_nonce = None; previous_root_status = snapshot.root_session_status; + clear_automation_runtime_state(&workspace); set_automation_status(&workspace, None); if workspace_rx.changed().await.is_err() { break; @@ -90,6 +93,19 @@ async fn watch_workspace( let assigned_repository = assigned_repository.expect("checked above"); let repository_label = compact_repository_label(&assigned_repository); + if snapshot.persistent.automation_paused { + watched_issue_url = None; + issue_status_rx = None; + next_scan_at = None; + blocked_start_scan_request_nonce = None; + previous_root_status = snapshot.root_session_status; + clear_automation_runtime_state(&workspace); + set_automation_status(&workspace, Some(format!("Stopped {repository_label}"))); + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } if scan_requested { blocked_start_scan_request_nonce = None; } @@ -140,7 +156,16 @@ async fn watch_workspace( continue; } - if snapshot.opencode_client.is_none() || snapshot.root_session_id.is_none() { + let agent_ready = snapshot + .transient + .as_ref() + .and_then(|transient| url::Url::parse(&transient.uri).ok()) + .map(|uri| match uri.scheme() { + "ws" | "wss" => snapshot.root_session_id.is_some(), + _ => snapshot.opencode_client.is_some() && snapshot.root_session_id.is_some(), + }) + .unwrap_or(false); + if !agent_ready { set_automation_status(&workspace, Some(format!("Wait server {repository_label}"))); previous_root_status = snapshot.root_session_status; if workspace_rx.changed().await.is_err() { @@ -151,6 +176,24 @@ async fn watch_workspace( let current_issue_url = snapshot.persistent.automation_issue.clone(); if let Some(current_issue_url) = current_issue_url { + let root_status = snapshot.root_session_status.unwrap_or(RootSessionStatus::Idle); + let should_resume_assigned_issue = matches!(root_status, RootSessionStatus::Idle) + && (scan_requested + || (previous_root_status.is_none() + && snapshot.automation_agent_state.is_none() + && snapshot.automation_session_status.is_none())); + + if !should_resume_assigned_issue + && snapshot.automation_session_id.is_none() + && snapshot.root_session_id.is_some() + && !matches!(root_status, RootSessionStatus::Idle) + { + set_automation_runtime_state( + &workspace, + snapshot.root_session_id.clone(), + snapshot.root_session_status, + ); + } if watched_issue_url.as_deref() != Some(current_issue_url.as_str()) { watched_issue_url = Some(current_issue_url.clone()); issue_status_rx = service @@ -158,6 +201,69 @@ async fn watch_workspace( .watch_status(¤t_issue_url); } + if should_resume_assigned_issue { + tracing::info!( + workspace_key, + issue_url = %current_issue_url, + "starting autonomous work for assigned issue" + ); + match start_assigned_issue_work( + &service, + &workspace, + &workspace_key, + &snapshot, + &assigned_repository, + ¤t_issue_url, + ) + .await + { + Ok(Some(issue)) => { + watched_issue_url = Some(issue.url.clone()); + issue_status_rx = service.github_status_service().watch_status(&issue.url); + set_automation_status( + &workspace, + Some(format!("Working {}", issue.display_reference())), + ); + } + Ok(None) => { + clear_automation_issue_claim(&workspace, ¤t_issue_url); + watched_issue_url = None; + issue_status_rx = None; + next_scan_at = Some(Instant::now() + issue_scan_delay); + set_automation_status( + &workspace, + Some(format!( + "Issue unavailable {repository_label}; next {}m", + (issue_scan_delay.as_secs() + 59) / 60 + )), + ); + } + Err(err) => { + tracing::warn!( + workspace_key, + issue_url = %current_issue_url, + error = %err, + "failed to resume assigned autonomous issue work" + ); + set_automation_status( + &workspace, + Some(format!("Issue start failed {repository_label}: {err}")), + ); + } + } + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until( + &mut workspace_rx, + &mut issue_status_rx, + next_scan_at, + ) + .await + { + break; + } + continue; + } + if snapshot.root_session_status == Some(RootSessionStatus::Idle) && previous_root_status != Some(RootSessionStatus::Idle) { @@ -168,14 +274,23 @@ async fn watch_workspace( if issue_is_closed(issue_status_rx.as_ref()) { workspace.update(|next| { + let mut changed = false; if next.persistent.automation_issue.as_deref() == Some(current_issue_url.as_str()) { next.persistent.automation_issue = None; - true - } else { - false + changed = true; + } + if next.automation_session_id.take().is_some() { + changed = true; } + if next.automation_agent_state.take().is_some() { + changed = true; + } + if next.automation_session_status.take().is_some() { + changed = true; + } + changed }); watched_issue_url = None; issue_status_rx = None; @@ -190,7 +305,7 @@ async fn watch_workspace( Some(issue_progress_status( &assigned_repository, ¤t_issue_url, - snapshot.root_session_status, + effective_automation_agent_state(&snapshot), )), ); previous_root_status = snapshot.root_session_status; @@ -310,8 +425,9 @@ async fn claim_next_issue( Some(format!("Claiming {}", issue.display_reference())), ); persist_automation_issue_claim(workspace, assigned_repository, &issue); + clear_automation_state_file(service, workspace_key).await; - if let Err(err) = prompt_root_session(snapshot, assigned_repository, &issue).await { + if let Err(err) = prompt_root_session(service, snapshot, assigned_repository, &issue).await { clear_automation_issue_claim(workspace, &issue.url); tracing::warn!( workspace_key, @@ -322,6 +438,12 @@ async fn claim_next_issue( return Err(err); } + set_automation_runtime_state( + workspace, + snapshot.root_session_id.clone(), + Some(RootSessionStatus::Busy), + ); + if let Err(err) = add_work_started_comment(assigned_repository, &issue, workspace_key, &token).await { @@ -349,6 +471,68 @@ async fn claim_next_issue( Ok(Some(issue)) } +async fn start_assigned_issue_work( + service: &CombinedService, + workspace: &Workspace, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, + assigned_repository: &str, + issue_url: &str, +) -> Result, String> { + let token = resolved_gh_token(service).await?; + let Some(issue) = fetch_issue(assigned_repository, issue_url, &token).await? else { + return Ok(None); + }; + + set_automation_status( + workspace, + Some(format!("Claiming {}", issue.display_reference())), + ); + clear_automation_state_file(service, workspace_key).await; + + if let Err(err) = prompt_root_session(service, snapshot, assigned_repository, &issue).await { + tracing::warn!( + workspace_key, + issue_url = %issue.url, + error = %err, + "failed to start manually assigned issue work" + ); + return Err(err); + } + + set_automation_runtime_state( + workspace, + snapshot.root_session_id.clone(), + Some(RootSessionStatus::Busy), + ); + + if let Err(err) = + add_work_started_comment(assigned_repository, &issue, workspace_key, &token).await + { + tracing::warn!( + workspace_key, + issue_url = %issue.url, + error = %err, + "manual issue prompt started but adding work-started comment failed" + ); + return Err(err); + } + + if let Err(err) = add_issue_label(assigned_repository, &issue, IN_PROGRESS_LABEL, &token).await + { + tracing::warn!( + workspace_key, + issue_url = %issue.url, + label = IN_PROGRESS_LABEL, + error = %err, + "manual issue prompt started but adding in-progress label failed" + ); + return Err(err); + } + + Ok(Some(issue)) +} + fn persist_automation_issue_claim( workspace: &Workspace, assigned_repository: &str, @@ -370,12 +554,65 @@ fn persist_automation_issue_claim( fn clear_automation_issue_claim(workspace: &Workspace, issue_url: &str) { workspace.update(|next| { + let mut changed = false; if next.persistent.automation_issue.as_deref() == Some(issue_url) { next.persistent.automation_issue = None; - true - } else { - false + changed = true; + } + if next.automation_session_id.take().is_some() { + changed = true; + } + if next.automation_agent_state.take().is_some() { + changed = true; + } + if next.automation_session_status.take().is_some() { + changed = true; + } + changed + }); +} + +fn clear_automation_runtime_state(workspace: &Workspace) { + workspace.update(|snapshot| { + let mut changed = false; + if snapshot.automation_session_id.take().is_some() { + changed = true; + } + if snapshot.automation_agent_state.take().is_some() { + changed = true; + } + if snapshot.automation_session_status.take().is_some() { + changed = true; + } + changed + }); +} + +fn set_automation_runtime_state( + workspace: &Workspace, + session_id: Option, + session_status: Option, +) { + workspace.update(|snapshot| { + let mut changed = false; + if snapshot.automation_session_id != session_id { + snapshot.automation_session_id = session_id.clone(); + changed = true; + } + let next_agent_state = session_status.map(|status| match status { + RootSessionStatus::Busy => AutomationAgentState::Working, + RootSessionStatus::Question => AutomationAgentState::Question, + RootSessionStatus::Idle => AutomationAgentState::Idle, + }); + if snapshot.automation_agent_state != next_agent_state { + snapshot.automation_agent_state = next_agent_state; + changed = true; } + if snapshot.automation_session_status != session_status { + snapshot.automation_session_status = session_status; + changed = true; + } + changed }); } @@ -410,17 +647,29 @@ fn set_automation_status(workspace: &Workspace, next_status: Option) { }); } +fn effective_automation_agent_state(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.automation_agent_state.or_else(|| { + snapshot.root_session_status.map(|status| match status { + RootSessionStatus::Busy => AutomationAgentState::Working, + RootSessionStatus::Question => AutomationAgentState::Question, + RootSessionStatus::Idle => AutomationAgentState::Idle, + }) + }) +} + fn issue_progress_status( assigned_repository: &str, issue_url: &str, - root_status: Option, + agent_state: Option, ) -> String { let issue_ref = issue_reference(issue_url).unwrap_or_else(|| issue_url.to_string()); let _ = assigned_repository; - match root_status.unwrap_or(RootSessionStatus::Idle) { - RootSessionStatus::Busy => format!("Working {issue_ref}"), - RootSessionStatus::Question => format!("Question {issue_ref}"), - RootSessionStatus::Idle => format!("Wait close {issue_ref}"), + match agent_state.unwrap_or(AutomationAgentState::Idle) { + AutomationAgentState::Working => format!("Working {issue_ref}"), + AutomationAgentState::Question => format!("Question {issue_ref}"), + AutomationAgentState::Review => format!("Review {issue_ref}"), + AutomationAgentState::Idle => format!("Wait close {issue_ref}"), + AutomationAgentState::Stale => format!("Stalled {issue_ref}"), } } @@ -469,50 +718,24 @@ async fn wait_for_workspace_change_until( } async fn prompt_root_session( + service: &CombinedService, snapshot: &WorkspaceSnapshot, assigned_repository: &str, issue: &SelectedIssue, ) -> Result<(), String> { - let opencode_client = snapshot - .opencode_client - .as_ref() - .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; - let root_session_id = snapshot - .root_session_id - .clone() - .ok_or_else(|| "workspace has no root session id".to_string())?; - let session_id = root_session_id - .parse::() - .map_err(|err| format!("invalid root session id '{root_session_id}': {err}"))?; let prompt = build_issue_prompt(assigned_repository, issue); - let prompt_body = opencode::client::types::SessionPromptAsyncBody { - agent: None, - format: None, - message_id: None, - model: None, - no_reply: None, - parts: vec![ - opencode::client::types::TextPartInput { - id: None, - ignored: None, - metadata: Default::default(), - synthetic: None, - text: prompt, - time: None, - type_: opencode::client::types::TextPartInputType::Text, - } - .into(), - ], - system: None, - tools: HashMap::new(), - variant: None, - }; - opencode_client - .client - .session_prompt_async(&session_id, None, None, &prompt_body) - .await - .map_err(|err| format!("failed to send autonomous issue prompt: {err}"))?; - Ok(()) + service.prompt_root_session(snapshot, &prompt).await +} + +async fn clear_automation_state_file(service: &CombinedService, workspace_key: &str) { + let path = automation_state_file_source(service.workspace_directory_path(), workspace_key); + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + tracing::warn!(workspace_key, error = %err, "failed to clear automation state file"); + } + } } fn build_issue_prompt(assigned_repository: &str, issue: &SelectedIssue) -> String { @@ -520,14 +743,19 @@ fn build_issue_prompt(assigned_repository: &str, issue: &SelectedIssue) -> Strin "You are operating in an autonomous multicode workspace for repository {assigned_repository}.\n\ Start work on GitHub issue {issue_url}.\n\ Issue title: {issue_title}\n\ +Before you proceed, load and follow these workspace skills as appropriate: `independent-fix`, `machine-readable-clone`, `machine-readable-issue`, `machine-readable-pr`, `git-commit-coauthorship`, `micronaut-projects-guide`, and `autonomous-state`.\n\ +The environment variable `{automation_state_env}` points to a multicode-owned state file. Maintain it throughout the run using the `autonomous-state` skill so multicode can track whether you are working, waiting for a question, or ready for review.\n\ Your job is to:\n\ 1. Ensure the repository is available in this workspace.\n\ 2. Understand and reproduce the issue, creating a minimal reproducer or failing test when possible.\n\ 3. Implement the fix.\n\ 4. Run focused verification and summarize the evidence.\n\ -5. Open or update a pull request, request review, and emit the machine-readable repository / issue / PR tags while you work.\n\ +5. Emit the machine-readable repository / issue / PR tags while you work.\n\ +6. Run repository commands, builds, Gradle tasks, and focused tests as needed without asking for permission.\n\ +7. Do not commit, push, comment, or open/update a pull request until the user explicitly approves publishing. When the change is ready, stop and ask for permission.\n\ \n\ Prefer an upstream pull request if you have write access. Keep going until the workspace is ready for review or you need human feedback.", + automation_state_env = AUTOMATION_STATE_ENV, issue_url = issue.url, issue_title = issue.title ) @@ -589,6 +817,39 @@ async fn list_issues_for_label( .map_err(|err| format!("failed to parse gh search issues output: {err}")) } +async fn fetch_issue( + assigned_repository: &str, + issue_url: &str, + token: &str, +) -> Result, String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "view", + issue_url, + "--repo", + assigned_repository, + "--json", + "number,title,createdAt,labels,url,state", + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue view for {issue_url}: {err}"))?; + + if !output.status.success() { + return Err(format!( + "gh issue view failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let issue = serde_json::from_slice::(&output.stdout) + .map_err(|err| format!("failed to parse gh issue view output: {err}"))?; + Ok(issue.is_open_issue_candidate().then_some(issue)) +} + fn issue_search_args(assigned_repository: &str, label: &str) -> Vec { vec![ "search".to_string(), @@ -727,7 +988,7 @@ fn normalize_github_repository_path(path: &str) -> Option { (!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}")) } -fn issue_reference(url: &str) -> Option { +pub(crate) fn issue_reference(url: &str) -> Option { let stripped = url.strip_prefix("https://github.com/")?; let segments = stripped.split('/').collect::>(); if segments.len() < 4 { @@ -739,6 +1000,70 @@ fn issue_reference(url: &str) -> Option { Some(format!("{owner}/{repo}#{number}")) } +pub(crate) fn normalize_github_issue_spec( + assigned_repository: &str, + input: &str, +) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix('#') + && let Ok(number) = rest.parse::() + { + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Ok(number) = trimmed.parse::() { + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Some((repository, issue_number)) = trimmed.split_once('#') + && normalize_github_repository_spec(repository)? == assigned_repository + { + let number = issue_number.trim().parse::().ok()?; + return Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )); + } + + if let Some(rest) = trimmed.strip_prefix("https://github.com/") { + return normalize_github_issue_path(assigned_repository, rest); + } + if let Some(rest) = trimmed.strip_prefix("http://github.com/") { + return normalize_github_issue_path(assigned_repository, rest); + } + + normalize_github_issue_path(assigned_repository, trimmed) +} + +fn normalize_github_issue_path(assigned_repository: &str, path: &str) -> Option { + let segments = path + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + let [owner, repo, kind, number, ..] = segments.as_slice() else { + return None; + }; + if *kind != "issues" { + return None; + } + let repository = normalize_github_repository_spec(&format!("{owner}/{repo}"))?; + if repository != assigned_repository { + return None; + } + let number = number.parse::().ok()?; + Some(format!( + "https://github.com/{assigned_repository}/issues/{number}" + )) +} + #[derive(Debug, Clone, Deserialize)] struct SelectedIssue { number: u64, @@ -824,6 +1149,37 @@ mod tests { assert_eq!(normalize_github_repository_spec("invalid"), None); } + #[test] + fn normalize_github_issue_spec_accepts_numbers_refs_and_urls_for_assigned_repo() { + let repository = "micronaut-projects/micronaut-core"; + assert_eq!( + normalize_github_issue_spec(repository, "42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec(repository, "#42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec(repository, "micronaut-projects/micronaut-core#42"), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec( + repository, + "https://github.com/micronaut-projects/micronaut-core/issues/42", + ), + Some("https://github.com/micronaut-projects/micronaut-core/issues/42".to_string()) + ); + assert_eq!( + normalize_github_issue_spec( + repository, + "https://github.com/micronaut-projects/micronaut-test/issues/42", + ), + None + ); + } + #[test] fn start_retry_is_blocked_only_for_same_scan_nonce() { assert!(start_retry_is_blocked(Some(4), 4)); @@ -1051,4 +1407,56 @@ mod tests { Some("example/repo") ); } + + #[test] + fn issue_progress_status_uses_explicit_automation_states() { + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Working), + ), + "Working example/repo#42" + ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Review), + ), + "Review example/repo#42" + ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::Idle), + ), + "Wait close example/repo#42" + ); + } + + #[test] + fn build_issue_prompt_requires_skills_and_publish_approval() { + let issue = SelectedIssue { + number: 980, + title: "candidate".to_string(), + url: "https://github.com/example/repo/issues/980".to_string(), + created_at: "2026-04-09T10:00:00Z".to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + labels: vec![], + }; + + let prompt = build_issue_prompt("example/repo", &issue); + + assert!(prompt.contains("`independent-fix`")); + assert!(prompt.contains("`machine-readable-pr`")); + assert!(prompt.contains("`autonomous-state`")); + assert!(prompt.contains(AUTOMATION_STATE_ENV)); + assert!(prompt.contains( + "Run repository commands, builds, Gradle tasks, and focused tests as needed without asking for permission." + )); + assert!(prompt.contains("Do not commit, push, comment, or open/update a pull request until the user explicitly approves publishing.")); + } } diff --git a/lib/src/services/codex_app_server.rs b/lib/src/services/codex_app_server.rs new file mode 100644 index 0000000..27012d0 --- /dev/null +++ b/lib/src/services/codex_app_server.rs @@ -0,0 +1,552 @@ +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use super::config::{CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}; + +const INITIALIZE_REQUEST_ID: i64 = 1; +const REQUEST_ID: i64 = 2; + +#[derive(Debug, Clone)] +pub struct CodexAppServerClient { + uri: String, +} + +impl CodexAppServerClient { + pub fn new(uri: impl Into) -> Self { + Self { uri: uri.into() } + } + + pub fn uri(&self) -> &str { + &self.uri + } + + pub async fn probe(&self) -> Result<(), String> { + let _: ThreadListResponse = self + .request( + "thread/list", + json!({ + "limit": 1, + "archived": false, + "sourceKinds": ["appServer"], + }), + ) + .await?; + Ok(()) + } + + pub async fn thread_list(&self, cwd: &str) -> Result { + self.request( + "thread/list", + json!({ + "cwd": cwd, + "archived": false, + "limit": 100, + "sourceKinds": ["appServer"], + "sortKey": "updated_at", + }), + ) + .await + } + + pub async fn thread_read(&self, thread_id: &str) -> Result { + self.request( + "thread/read", + json!({ + "threadId": thread_id, + "includeTurns": true, + }), + ) + .await + } + + pub async fn thread_start( + &self, + cwd: &str, + config: &CodexAgentConfig, + ) -> Result { + self.request("thread/start", build_thread_start_params(cwd, config)) + .await + } + + pub async fn turn_start( + &self, + thread_id: &str, + prompt: &str, + config: &CodexAgentConfig, + ) -> Result { + self.request( + "turn/start", + build_turn_start_params(thread_id, prompt, config), + ) + .await + } + + pub async fn stream_notifications( + &self, + tx: tokio::sync::broadcast::Sender, + ) -> Result<(), String> { + let mut socket = self.connect_initialized().await?; + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let Ok(notification) = serde_json::from_str::(&text) + else { + continue; + }; + let _ = tx.send(notification.into_notification()); + } + Ok(()) + } + + async fn request(&self, method: &str, params: Value) -> Result + where + T: for<'de> Deserialize<'de>, + { + let mut socket = self.connect_initialized().await?; + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": REQUEST_ID, + "method": method, + "params": params, + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(REQUEST_ID) { + continue; + } + if let Some(result) = value.get("result") { + return serde_json::from_value(result.clone()).map_err(|err| err.to_string()); + } + if let Some(error) = value.get("error") { + return Err(error.to_string()); + } + } + + Err(format!( + "codex app-server connection to '{}' closed", + self.uri + )) + } + + async fn connect_initialized( + &self, + ) -> Result< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + String, + > { + let (mut socket, _) = connect_async(self.uri.as_str()) + .await + .map_err(|err| err.to_string())?; + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": INITIALIZE_REQUEST_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "multicode", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { + "experimentalApi": true, + }, + } + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(INITIALIZE_REQUEST_ID) { + continue; + } + if value.get("error").is_some() { + return Err(value["error"].to_string()); + } + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "method": "initialized", + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + return Ok(socket); + } + + Err(format!( + "codex app-server initialize did not complete for '{}'", + self.uri + )) + } +} + +fn build_thread_start_params(cwd: &str, config: &CodexAgentConfig) -> Value { + json!({ + "cwd": cwd, + "model": config.model.as_deref(), + "modelProvider": config.model_provider.as_deref(), + "sandbox": thread_start_sandbox(config.sandbox_mode), + "approvalPolicy": approval_policy_value(config.approval_policy), + "personality": "pragmatic", + }) +} + +fn build_turn_start_params(thread_id: &str, prompt: &str, config: &CodexAgentConfig) -> Value { + json!({ + "threadId": thread_id, + "approvalPolicy": approval_policy_value(config.approval_policy), + "personality": "pragmatic", + "sandboxPolicy": sandbox_policy_value(config.sandbox_mode, config.network_access), + "input": [ + { + "type": "text", + "text": prompt, + } + ], + }) +} + +fn approval_policy_value(policy: CodexApprovalPolicy) -> &'static str { + match policy { + CodexApprovalPolicy::Untrusted => "untrusted", + CodexApprovalPolicy::OnFailure => "on-failure", + CodexApprovalPolicy::OnRequest => "on-request", + CodexApprovalPolicy::Never => "never", + } +} + +fn thread_start_sandbox(mode: CodexSandboxMode) -> &'static str { + match mode { + CodexSandboxMode::ReadOnly => "read-only", + CodexSandboxMode::WorkspaceWrite => "workspace-write", + CodexSandboxMode::DangerFullAccess | CodexSandboxMode::ExternalSandbox => { + "danger-full-access" + } + } +} + +fn sandbox_policy_value(mode: CodexSandboxMode, network: CodexNetworkAccess) -> Value { + match mode { + CodexSandboxMode::ReadOnly => json!({ + "type": "readOnly", + "networkAccess": matches!(network, CodexNetworkAccess::Enabled), + }), + CodexSandboxMode::WorkspaceWrite => json!({ + "type": "workspaceWrite", + "networkAccess": matches!(network, CodexNetworkAccess::Enabled), + }), + CodexSandboxMode::DangerFullAccess => json!({ + "type": "dangerFullAccess", + }), + CodexSandboxMode::ExternalSandbox => json!({ + "type": "externalSandbox", + "networkAccess": match network { + CodexNetworkAccess::Restricted => "restricted", + CodexNetworkAccess::Enabled => "enabled", + }, + }), + } +} + +fn message_to_text(message: Message) -> Option { + match message { + Message::Text(text) => Some(text.to_string()), + Message::Binary(bytes) => String::from_utf8(bytes.to_vec()).ok(), + Message::Close(_) => None, + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thread_start_params_include_codex_permissions() { + let params = build_thread_start_params( + "/workspace", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!(params["cwd"], "/workspace"); + assert_eq!(params["model"], "gpt-5-codex"); + assert_eq!(params["modelProvider"], "openai"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(params["sandbox"], "danger-full-access"); + assert_eq!(params["personality"], "pragmatic"); + } + + #[test] + fn turn_start_params_use_external_sandbox_policy() { + let params = build_turn_start_params( + "thread-123", + "Fix the bug", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!(params["threadId"], "thread-123"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(params["personality"], "pragmatic"); + assert_eq!(params["sandboxPolicy"]["type"], "externalSandbox"); + assert_eq!(params["sandboxPolicy"]["networkAccess"], "enabled"); + assert_eq!(params["input"][0]["type"], "text"); + assert_eq!(params["input"][0]["text"], "Fix the bug"); + } + + #[test] + fn turn_start_params_use_workspace_write_policy() { + let params = build_turn_start_params( + "thread-123", + "Fix the bug", + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::OnRequest, + sandbox_mode: CodexSandboxMode::WorkspaceWrite, + network_access: CodexNetworkAccess::Restricted, + }, + ); + + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(params["sandboxPolicy"]["networkAccess"], false); + } + + #[test] + fn codex_thread_status_treats_command_approvals_as_human_input() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert!(status.waits_for_human_input()); + } + + #[test] + fn codex_thread_status_marks_system_error_for_replacement() { + assert!(CodexThreadStatus::SystemError.requires_replacement()); + assert!(!CodexThreadStatus::Idle.requires_replacement()); + } +} + +pub async fn forward_codex_notifications_forever( + client: CodexAppServerClient, + tx: tokio::sync::broadcast::Sender, +) { + loop { + let _ = client.stream_notifications(tx.clone()).await; + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadListResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadStartResponse { + pub thread: CodexThread, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ThreadReadResponse { + pub thread: CodexThreadRead, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TurnStartResponse { + pub turn: CodexTurn, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexTurn { + pub id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThreadRead { + #[serde(default)] + pub turns: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThreadTurn { + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CodexThread { + pub id: String, + #[serde(default)] + pub title: Option, + pub status: CodexThreadStatus, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum CodexThreadStatus { + #[serde(rename = "notLoaded")] + NotLoaded, + #[serde(rename = "idle")] + Idle, + #[serde(rename = "systemError")] + SystemError, + #[serde(rename = "active")] + Active { + #[serde(default, rename = "activeFlags")] + active_flags: Vec, + }, +} + +impl CodexThreadStatus { + pub fn is_idle(&self) -> bool { + matches!(self, Self::Idle) + } + + pub fn requires_replacement(&self) -> bool { + matches!(self, Self::SystemError) + } + + pub fn waits_for_human_input(&self) -> bool { + matches!( + self, + Self::Active { + active_flags + } if active_flags.contains(&CodexThreadActiveFlag::WaitingOnUserInput) + || active_flags.contains(&CodexThreadActiveFlag::WaitingOnApproval) + ) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub enum CodexThreadActiveFlag { + #[serde(rename = "waitingOnApproval")] + WaitingOnApproval, + #[serde(rename = "waitingOnUserInput")] + WaitingOnUserInput, +} + +#[derive(Debug, Clone)] +pub enum CodexServerNotification { + ThreadStarted { + thread: CodexThread, + }, + ThreadStatusChanged { + thread_id: String, + status: CodexThreadStatus, + }, + TurnStarted { + thread_id: String, + }, + TurnCompleted { + thread_id: String, + }, + Other, +} + +#[derive(Debug, Deserialize)] +struct CodexServerNotificationEnvelope { + method: String, + #[serde(default)] + params: Value, +} + +impl CodexServerNotificationEnvelope { + fn into_notification(self) -> CodexServerNotification { + match self.method.as_str() { + "thread/started" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::ThreadStarted { + thread: params.thread, + }) + .unwrap_or(CodexServerNotification::Other), + "thread/status/changed" => { + serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::ThreadStatusChanged { + thread_id: params.thread_id, + status: params.status, + }) + .unwrap_or(CodexServerNotification::Other) + } + "turn/started" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::TurnStarted { + thread_id: params.thread_id, + }) + .unwrap_or(CodexServerNotification::Other), + "turn/completed" => serde_json::from_value::(self.params) + .map(|params| CodexServerNotification::TurnCompleted { + thread_id: params.thread_id, + }) + .unwrap_or(CodexServerNotification::Other), + _ => CodexServerNotification::Other, + } + } +} + +#[derive(Debug, Deserialize)] +struct ThreadStartedNotification { + thread: CodexThread, +} + +#[derive(Debug, Deserialize)] +struct ThreadStatusChangedNotification { + #[serde(rename = "threadId")] + thread_id: String, + status: CodexThreadStatus, +} + +#[derive(Debug, Deserialize)] +struct TurnLifecycleNotification { + #[serde(rename = "threadId")] + thread_id: String, +} diff --git a/lib/src/services/codex_root_session_service.rs b/lib/src/services/codex_root_session_service.rs new file mode 100644 index 0000000..773d683 --- /dev/null +++ b/lib/src/services/codex_root_session_service.rs @@ -0,0 +1,476 @@ +use std::{path::PathBuf, sync::Arc}; + +use tokio::sync::broadcast; + +use super::{ + codex_app_server::{ + CodexAppServerClient, CodexServerNotification, CodexThread, + forward_codex_notifications_forever, + }, + config::CodexAgentConfig, + workspace_task_watch::watch_workspace_task, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{RootSessionStatus, WorkspaceManager, WorkspaceManagerError, manager::Workspace}; + +#[derive(Debug)] +pub enum CodexRootSessionServiceError { + Manager(WorkspaceManagerError), +} + +impl From for CodexRootSessionServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +#[derive(Clone, PartialEq, Eq)] +struct RootSessionTaskKey { + uri: String, + cwd: String, +} + +pub async fn codex_root_session_service( + manager: Arc, + workspace_directory_path: PathBuf, + config: CodexAgentConfig, +) -> Result<(), CodexRootSessionServiceError> { + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let workspace_directory_path = workspace_directory_path.clone(); + let config = config.clone(); + async move { + let cwd = workspace_directory_path + .join(key) + .to_string_lossy() + .into_owned(); + tokio::spawn(async move { + watch_workspace_snapshot(workspace, workspace_rx, cwd, config).await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace_snapshot( + workspace: Workspace, + workspace_rx: tokio::sync::watch::Receiver, + cwd: String, + config: CodexAgentConfig, +) { + watch_workspace_task( + workspace, + workspace_rx, + |snapshot| { + let transient = snapshot.transient.as_ref()?; + let parsed = url::Url::parse(&transient.uri).ok()?; + if !matches!(parsed.scheme(), "ws" | "wss") { + return None; + } + Some(RootSessionTaskKey { + uri: transient.uri.clone(), + cwd: cwd.clone(), + }) + }, + clear_root_session_if_detached, + clear_root_session_on_uri_change, + move |workspace, key| { + let task_workspace = workspace.clone(); + let task_key = key.clone(); + let task_config = config.clone(); + tokio::spawn(async move { + sync_root_session(task_workspace, task_key, task_config).await; + }) + }, + ) + .await; +} + +fn clear_root_session_if_detached(workspace: &Workspace) { + workspace.update(|snapshot| { + let should_clear = snapshot.transient.is_none() + && (snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some()); + if should_clear { + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + true + } else { + false + } + }); +} + +fn clear_root_session_on_uri_change( + workspace: &Workspace, + next_key: &RootSessionTaskKey, + previous_key: Option, +) { + if previous_key + .as_ref() + .is_none_or(|previous_key| previous_key.uri == next_key.uri) + { + return; + } + + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(next_key.uri.as_str()) + { + return false; + } + let changed = snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some(); + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + changed + }); +} + +async fn sync_root_session( + workspace: Workspace, + key: RootSessionTaskKey, + config: CodexAgentConfig, +) { + let client = CodexAppServerClient::new(key.uri.clone()); + let event_tx = broadcast::channel(256).0; + let forwarder = tokio::spawn(forward_codex_notifications_forever( + client.clone(), + event_tx.clone(), + )); + let mut event_rx = event_tx.subscribe(); + let mut refresh_interval = tokio::time::interval(std::time::Duration::from_secs(5)); + refresh_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut active_turn_thread_id: Option = None; + + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + + loop { + tokio::select! { + _ = refresh_interval.tick() => { + refresh_root_session(&workspace, &client, &key, &config, active_turn_thread_id.as_deref()).await; + } + event = event_rx.recv() => { + match event { + Ok(CodexServerNotification::ThreadStarted { thread }) => { + update_from_started_thread( + &workspace, + &key, + &thread, + active_turn_thread_id.as_deref(), + ); + } + Ok(CodexServerNotification::ThreadStatusChanged { thread_id, status }) => { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.as_deref() != Some(thread_id.as_str()) { + return false; + } + let next_status = + effective_status(&thread_id, &status, active_turn_thread_id.as_deref()); + if snapshot.root_session_status == Some(next_status) { + false + } else { + snapshot.root_session_status = Some(next_status); + true + } + }); + } + Ok(CodexServerNotification::TurnStarted { thread_id }) => { + active_turn_thread_id = Some(thread_id.clone()); + workspace.update(|snapshot| { + if snapshot.root_session_id.as_deref() == Some(thread_id.as_str()) + && snapshot.root_session_status != Some(RootSessionStatus::Busy) + { + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + } else { + false + } + }); + } + Ok(CodexServerNotification::TurnCompleted { thread_id }) => { + if active_turn_thread_id.as_deref() == Some(thread_id.as_str()) { + active_turn_thread_id = None; + } + if workspace.subscribe().borrow().root_session_id.as_deref() + == Some(thread_id.as_str()) + { + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + } + } + Ok(CodexServerNotification::Other) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + refresh_root_session( + &workspace, + &client, + &key, + &config, + active_turn_thread_id.as_deref(), + ) + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + + forwarder.abort(); +} + +async fn refresh_root_session( + workspace: &Workspace, + client: &CodexAppServerClient, + key: &RootSessionTaskKey, + config: &CodexAgentConfig, + active_turn_thread_id: Option<&str>, +) { + let response = match client.thread_list(&key.cwd).await { + Ok(response) => response, + Err(_) => return, + }; + + let current_root_session_id = workspace.subscribe().borrow().root_session_id.clone(); + let thread = + match select_thread_for_tracking(current_root_session_id.as_deref(), &response.data) { + Some(thread) => thread, + None => match client.thread_start(&key.cwd, config).await { + Ok(response) => response.thread, + Err(_) => return, + }, + }; + + update_from_thread(workspace, key, &thread, active_turn_thread_id); +} + +fn select_thread_for_tracking( + current_thread_id: Option<&str>, + threads: &[CodexThread], +) -> Option { + if let Some(current_thread_id) = current_thread_id + && let Some(thread) = threads + .iter() + .find(|thread| thread.id == current_thread_id && !thread.status.requires_replacement()) + { + return Some(thread.clone()); + } + + threads + .iter() + .find(|thread| !thread.status.requires_replacement()) + .cloned() +} + +fn update_from_started_thread( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread: &CodexThread, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.is_some() + && snapshot.root_session_id.as_deref() != Some(thread.id.as_str()) + { + return false; + } + + let next_title = thread.title.clone().unwrap_or_else(|| "Codex".to_string()); + let next_status = effective_status(&thread.id, &thread.status, active_turn_thread_id); + if snapshot.root_session_id.as_deref() == Some(thread.id.as_str()) + && snapshot.root_session_title.as_deref() == Some(next_title.as_str()) + && snapshot.root_session_status == Some(next_status) + { + return false; + } + + snapshot.root_session_id = Some(thread.id.clone()); + snapshot.root_session_title = Some(next_title); + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn update_from_thread( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread: &CodexThread, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + + let next_title = thread.title.clone().unwrap_or_else(|| "Codex".to_string()); + let next_status = effective_status(&thread.id, &thread.status, active_turn_thread_id); + if snapshot.root_session_id.as_deref() == Some(thread.id.as_str()) + && snapshot.root_session_title.as_deref() == Some(next_title.as_str()) + && snapshot.root_session_status == Some(next_status) + { + return false; + } + + snapshot.root_session_id = Some(thread.id.clone()); + snapshot.root_session_title = Some(next_title); + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn effective_status( + thread_id: &str, + status: &super::codex_app_server::CodexThreadStatus, + active_turn_thread_id: Option<&str>, +) -> RootSessionStatus { + match map_status(status) { + RootSessionStatus::Idle if active_turn_thread_id == Some(thread_id) => { + RootSessionStatus::Busy + } + other => other, + } +} + +fn map_status(status: &super::codex_app_server::CodexThreadStatus) -> RootSessionStatus { + if status.waits_for_human_input() { + RootSessionStatus::Question + } else if status.is_idle() { + RootSessionStatus::Idle + } else { + RootSessionStatus::Busy + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; + use crate::{ + RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, WorkspaceSnapshot, + }; + + #[test] + fn map_status_treats_waiting_on_approval_as_question() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert_eq!(map_status(&status), RootSessionStatus::Question); + } + + #[test] + fn effective_status_keeps_idle_thread_busy_while_turn_is_active() { + assert_eq!( + effective_status("thread-1", &CodexThreadStatus::Idle, Some("thread-1")), + RootSessionStatus::Busy + ); + assert_eq!( + effective_status("thread-2", &CodexThreadStatus::Idle, Some("thread-1")), + RootSessionStatus::Idle + ); + } + + #[test] + fn select_thread_for_tracking_prefers_current_thread_over_newer_idle_thread() { + let current_busy = CodexThread { + id: "thread-busy".to_string(), + title: Some("Busy".to_string()), + status: CodexThreadStatus::Active { + active_flags: Vec::new(), + }, + }; + let newer_idle = CodexThread { + id: "thread-idle".to_string(), + title: Some("Idle".to_string()), + status: CodexThreadStatus::Idle, + }; + + let selected = select_thread_for_tracking( + Some("thread-busy"), + &[newer_idle.clone(), current_busy.clone()], + ) + .expect("current thread should be selected"); + + assert_eq!(selected.id, current_busy.id); + } + + #[test] + fn update_from_started_thread_ignores_unrelated_new_thread_when_already_tracking_one() { + let workspace = WorkspaceSnapshot::default(); + let workspace = crate::manager::Workspace::new(workspace); + workspace.update(|snapshot| { + snapshot.transient = Some(TransientWorkspaceSnapshot { + uri: "ws://127.0.0.1:31337".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-current".to_string()); + snapshot.root_session_title = Some("Current".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + }); + + update_from_started_thread( + &workspace, + &RootSessionTaskKey { + uri: "ws://127.0.0.1:31337".to_string(), + cwd: "/tmp/workspace".to_string(), + }, + &CodexThread { + id: "thread-other".to_string(), + title: Some("Other".to_string()), + status: CodexThreadStatus::Idle, + }, + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.root_session_id.as_deref(), Some("thread-current")); + assert_eq!(snapshot.root_session_title.as_deref(), Some("Current")); + assert_eq!(snapshot.root_session_status, Some(RootSessionStatus::Busy)); + } +} diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 77cdf7f..b02593c 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, io::ErrorKind, path::{Path, PathBuf}, process::{ExitStatus, Stdio}, @@ -17,15 +18,19 @@ pub struct SpawnCommand { use crate::{ WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, database::Database, logging, + opencode, }; use super::{ GithubStatusService, GithubStatusServiceError, WorkspaceDirectoryError, + automation_state_file_service::automation_state_file_service, autonomous_workspace_service::autonomous_workspace_service, + codex_app_server::CodexAppServerClient, + codex_root_session_service::codex_root_session_service, config::{ - AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, inherited_env_value, - read_config, resolve_opencode_command, validate_handler_config, validate_remote_config, - validate_tool_config_entries, validate_workspace_key, + AddedSkillMount, AgentProvider, Config, ExpandedIsolationConfig, expand_shell_path, + inherited_env_value, read_config, resolve_agent_command, validate_handler_config, + validate_remote_config, validate_tool_config_entries, validate_workspace_key, }, multicode_metadata_service, opencode_client_service, persistent_storage, resource_usage_service, root_session_service, @@ -44,7 +49,8 @@ pub struct CombinedService { github_status_service: GithubStatusService, workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, - opencode_command: String, + agent_command: String, + agent_provider: AgentProvider, runtime: WorkspaceRuntime, github_git_credentials_env: Option, } @@ -75,9 +81,10 @@ impl CombinedService { validate_tool_config_entries(&config.tool)?; validate_handler_config(&config.handler)?; validate_remote_config(config.remote.as_ref())?; - let opencode_command = resolve_opencode_command(&config.opencode)?; - let container_opencode_command = - resolve_container_opencode_command(config.runtime.backend, &config.opencode); + let agent_provider = config.agent.provider; + let agent_command = resolve_agent_command(&agent_command_candidates(&config))?; + let container_agent_command = + resolve_container_agent_command(agent_provider, config.runtime.backend, &config); let workspace_directory_path = expand_shell_path(&config.workspace_directory)?; if let Err(err) = logging::enable_workspace_file_logging(&workspace_directory_path).await { logging::log_file_enable_failed( @@ -108,8 +115,10 @@ impl CombinedService { config.runtime.clone(), workspace_directory_path.clone(), expanded_isolation.clone(), - opencode_command.clone(), - container_opencode_command, + agent_provider, + agent_command.clone(), + container_agent_command, + config.agent.codex.clone(), ); let persistent_path = workspace_directory_path @@ -126,11 +135,20 @@ impl CombinedService { ); spawn_transient_storage(manager.clone(), transient_link); spawn_runtime_reconciliation_service(manager.clone(), runtime.clone()); - spawn_opencode_client_service(manager.clone()); - spawn_root_session_service(manager.clone()); + if agent_provider == AgentProvider::Opencode { + spawn_opencode_client_service(manager.clone()); + spawn_root_session_service(manager.clone()); + } else { + spawn_codex_root_session_service( + manager.clone(), + workspace_directory_path.clone(), + config.agent.codex.clone(), + ); + } spawn_multicode_metadata_service(manager.clone()); spawn_usage_aggregation_service(manager.clone()); spawn_resource_usage_service(manager.clone()); + spawn_automation_state_file_service(manager.clone(), workspace_directory_path.clone()); let service = Self { config, @@ -139,7 +157,8 @@ impl CombinedService { github_status_service, workspace_directory_path, expanded_isolation, - opencode_command, + agent_command, + agent_provider, runtime, github_git_credentials_env, }; @@ -178,6 +197,17 @@ impl CombinedService { Ok(()) } + pub async fn create_workspace_with_repository( + &self, + key: &str, + repository: &str, + ) -> Result { + let normalized = normalize_repository_spec(repository)?; + self.create_workspace(key).await?; + self.set_workspace_repository(key, Some(normalized.clone()))?; + Ok(normalized) + } + pub async fn start_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; @@ -201,6 +231,11 @@ impl CombinedService { workspace.update(|snapshot| { if snapshot.transient.is_none() { snapshot.transient = Some(start.transient.clone()); + snapshot.persistent.automation_paused = false; + if snapshot.persistent.assigned_repository.is_some() { + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + } replaced = true; true } else { @@ -224,31 +259,46 @@ impl CombinedService { repository: Option<&str>, ) -> Result, CombinedServiceError> { let key = validate_workspace_key(key)?; - let normalized = repository - .map(|repository| { - super::autonomous_workspace_service::normalize_github_repository_spec(repository) - .ok_or_else(|| { - CombinedServiceError::InvalidRepositorySpec(repository.trim().to_string()) - }) - }) - .transpose()?; + let normalized = repository.map(normalize_repository_spec).transpose()?; + self.set_workspace_repository(&key, normalized.clone())?; + Ok(normalized) + } + pub async fn assign_workspace_issue( + &self, + key: &str, + issue: Option<&str>, + ) -> Result, CombinedServiceError> { + let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; + let assigned_repository = workspace + .subscribe() + .borrow() + .persistent + .assigned_repository + .clone() + .ok_or_else(|| CombinedServiceError::WorkspaceRepositoryRequired(key.clone()))?; + let normalized = issue + .map(|issue| normalize_issue_spec(&assigned_repository, issue)) + .transpose()?; + workspace.update(|snapshot| { - if snapshot.persistent.assigned_repository == normalized { - return false; - } - snapshot.persistent.assigned_repository = normalized.clone(); - snapshot.persistent.automation_issue = None; - snapshot.automation_status = normalized - .as_ref() - .map(|repository| format!("Repository assigned; scan queued for {repository}")); - if normalized.is_some() { - snapshot.automation_scan_request_nonce = - snapshot.automation_scan_request_nonce.saturating_add(1); - } + snapshot.persistent.automation_issue = normalized.clone(); + snapshot.persistent.automation_paused = false; + snapshot.automation_status = Some(match normalized.as_ref() { + Some(issue_url) => format!( + "Issue assigned; start queued for {}", + format_issue_reference(issue_url) + ), + None => { + format!("Issue cleared; scan queued for {assigned_repository}") + } + }); + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); true }); + Ok(normalized) } @@ -261,6 +311,7 @@ impl CombinedService { snapshot.automation_status = Some(format!("Scan requested for {repository}")); } } + snapshot.persistent.automation_paused = false; snapshot.automation_scan_request_nonce = snapshot.automation_scan_request_nonce.saturating_add(1); true @@ -268,6 +319,31 @@ impl CombinedService { Ok(()) } + fn set_workspace_repository( + &self, + key: &str, + repository: Option, + ) -> Result<(), CombinedServiceError> { + let workspace = self.manager.get_workspace(key)?; + workspace.update(|snapshot| { + if snapshot.persistent.assigned_repository == repository { + return false; + } + snapshot.persistent.assigned_repository = repository.clone(); + snapshot.persistent.automation_issue = None; + snapshot.persistent.automation_paused = false; + snapshot.automation_status = repository + .as_ref() + .map(|repository| format!("Repository assigned; scan queued for {repository}")); + if repository.is_some() { + snapshot.automation_scan_request_nonce = + snapshot.automation_scan_request_nonce.saturating_add(1); + } + true + }); + Ok(()) + } + pub async fn stop_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; @@ -283,6 +359,12 @@ impl CombinedService { workspace.update(|snapshot| { if snapshot.transient.is_some() { snapshot.transient = None; + snapshot.persistent.automation_paused = true; + snapshot.automation_status = snapshot + .persistent + .assigned_repository + .as_ref() + .map(|repository| format!("Stopped {repository}")); true } else { false @@ -499,8 +581,98 @@ impl CombinedService { Ok(()) } - pub fn opencode_command(&self) -> &str { - &self.opencode_command + pub fn agent_command(&self) -> &str { + &self.agent_command + } + + pub fn agent_provider(&self) -> AgentProvider { + self.agent_provider + } + + pub async fn prompt_root_session( + &self, + snapshot: &crate::WorkspaceSnapshot, + prompt: &str, + ) -> Result<(), String> { + let root_session_id = snapshot + .root_session_id + .clone() + .ok_or_else(|| "workspace has no root session id".to_string())?; + + match self.agent_provider { + AgentProvider::Opencode => { + let opencode_client = snapshot + .opencode_client + .as_ref() + .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; + let session_id = root_session_id + .parse::() + .map_err(|err| format!("invalid root session id '{root_session_id}': {err}"))?; + let prompt_body = opencode::client::types::SessionPromptAsyncBody { + agent: None, + format: None, + message_id: None, + model: None, + no_reply: None, + parts: vec![ + opencode::client::types::TextPartInput { + id: None, + ignored: None, + metadata: Default::default(), + synthetic: None, + text: prompt.to_string(), + time: None, + type_: opencode::client::types::TextPartInputType::Text, + } + .into(), + ], + system: None, + tools: HashMap::new(), + variant: None, + }; + opencode_client + .client + .session_prompt_async(&session_id, None, None, &prompt_body) + .await + .map(|_| ()) + .map_err(|err| format!("failed to send prompt: {err}")) + } + AgentProvider::Codex => { + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| "workspace has no active runtime uri".to_string())?; + tracing::info!( + root_session_id, + uri = %uri, + prompt_len = prompt.len(), + "dispatching codex root-session prompt" + ); + let response = CodexAppServerClient::new(uri.clone()) + .turn_start(&root_session_id, prompt, &self.config.agent.codex) + .await; + match response { + Ok(_) => { + tracing::info!( + root_session_id, + uri = %uri, + "codex root-session prompt dispatched" + ); + Ok(()) + } + Err(err) => { + tracing::warn!( + root_session_id, + uri = %uri, + error = %err, + "codex root-session prompt dispatch failed" + ); + Err(err) + } + } + } + } } #[cfg_attr(not(test), allow(dead_code))] @@ -719,10 +891,29 @@ impl CombinedService { } } -fn resolve_container_opencode_command( +fn agent_command_candidates(config: &Config) -> Vec { + match config.agent.provider { + AgentProvider::Opencode => { + if config.agent.opencode.commands.is_empty() { + config.opencode.clone() + } else { + config.agent.opencode.commands.clone() + } + } + AgentProvider::Codex => config.agent.codex.commands.clone(), + } +} + +fn resolve_container_agent_command( + provider: AgentProvider, backend: crate::RuntimeBackend, - candidates: &[String], + config: &Config, ) -> String { + let candidates = agent_command_candidates(config); + if provider == AgentProvider::Codex { + return "codex".to_string(); + } + if backend == crate::RuntimeBackend::AppleContainer { return candidates .iter() @@ -954,7 +1145,9 @@ pub enum CombinedServiceError { }, InvalidToolExecution(String), InvalidRepositorySpec(String), + InvalidIssueSpec(String), UnsupportedRuntimeBackend(String), + WorkspaceRepositoryRequired(String), WorkspaceArchived(String), WorkspaceNotArchived(String), ArchiveWorkspaceRunning(String), @@ -967,7 +1160,7 @@ pub enum CombinedServiceError { key: String, status: Option, }, - OpencodeCommandNotFound { + AgentCommandNotFound { candidates: Vec, }, } @@ -986,6 +1179,24 @@ impl CombinedServiceError { } } +fn normalize_repository_spec(repository: &str) -> Result { + super::autonomous_workspace_service::normalize_github_repository_spec(repository) + .ok_or_else(|| CombinedServiceError::InvalidRepositorySpec(repository.trim().to_string())) +} + +fn normalize_issue_spec( + assigned_repository: &str, + issue: &str, +) -> Result { + super::autonomous_workspace_service::normalize_github_issue_spec(assigned_repository, issue) + .ok_or_else(|| CombinedServiceError::InvalidIssueSpec(issue.trim().to_string())) +} + +fn format_issue_reference(issue_url: &str) -> String { + super::autonomous_workspace_service::issue_reference(issue_url) + .unwrap_or_else(|| issue_url.to_string()) +} + pub fn summarize_workspace_start_failure(status: Option, stderr: &str) -> String { let stderr = compact_process_stderr(stderr); if stderr.contains("no free indices are available for allocation") { @@ -1123,6 +1334,20 @@ fn spawn_root_session_service(manager: Arc) { }); } +fn spawn_codex_root_session_service( + manager: Arc, + workspace_directory_path: PathBuf, + config: super::config::CodexAgentConfig, +) { + tokio::spawn(async move { + if let Err(err) = + codex_root_session_service(manager, workspace_directory_path, config).await + { + tracing::error!(error = ?err, "codex root session service exited with error"); + } + }); +} + fn spawn_multicode_metadata_service(manager: Arc) { tokio::spawn(async move { if let Err(err) = multicode_metadata_service(manager).await { @@ -1147,6 +1372,17 @@ fn spawn_resource_usage_service(manager: Arc) { }); } +fn spawn_automation_state_file_service( + manager: Arc, + workspace_directory_path: PathBuf, +) { + tokio::spawn(async move { + if let Err(err) = automation_state_file_service(manager, workspace_directory_path).await { + tracing::error!(error = ?err, "automation state file service terminated"); + } + }); +} + fn spawn_autonomous_workspace_service(service: CombinedService) { tokio::spawn(async move { if let Err(err) = autonomous_workspace_service(service).await { @@ -1160,6 +1396,7 @@ mod tests { use super::*; use crate::services::{ GithubTokenConfig, ToolType, + config::{CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}, runtime::{MountKind, MountSpec}, }; use crate::test_support::ENV_VAR_LOCK; @@ -1269,6 +1506,21 @@ mod tests { ) } + fn default_config() -> Config { + Config { + workspace_directory: "/tmp/workspaces".to_string(), + isolation: Default::default(), + runtime: Default::default(), + autonomous: Default::default(), + agent: Default::default(), + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + tool: Vec::new(), + handler: Default::default(), + remote: None, + github: Default::default(), + } + } + #[test] fn config_parses_github_token_env_source() { let config: Config = toml::from_str( @@ -1293,29 +1545,140 @@ token = { env = "GITHUB_TOKEN" } } #[test] - fn resolve_container_opencode_command_prefers_opencode_for_apple_backend() { + fn config_parses_codex_agent_provider_settings() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex-nightly", "codex"] +profile = "default" +model = "gpt-5-codex" +model-provider = "openai" + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!(config.agent.provider, AgentProvider::Codex); + assert_eq!(config.agent.codex.commands, vec!["codex-nightly", "codex"]); + assert_eq!(config.agent.codex.profile.as_deref(), Some("default")); + assert_eq!(config.agent.codex.model.as_deref(), Some("gpt-5-codex")); + assert_eq!(config.agent.codex.model_provider.as_deref(), Some("openai")); assert_eq!( - resolve_container_opencode_command( + config.agent.codex.approval_policy, + CodexApprovalPolicy::OnRequest + ); + assert_eq!( + config.agent.codex.sandbox_mode, + CodexSandboxMode::WorkspaceWrite + ); + assert_eq!( + config.agent.codex.network_access, + CodexNetworkAccess::Enabled + ); + } + + #[test] + fn config_parses_codex_autonomy_settings() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[agent] +provider = "codex" + +[agent.codex] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!(config.agent.provider, AgentProvider::Codex); + assert_eq!( + config.agent.codex.approval_policy, + CodexApprovalPolicy::Never + ); + assert_eq!( + config.agent.codex.sandbox_mode, + CodexSandboxMode::ExternalSandbox + ); + assert_eq!( + config.agent.codex.network_access, + CodexNetworkAccess::Enabled + ); + } + + #[test] + fn runtime_config_prefers_provider_specific_images_when_global_override_is_absent() { + let config: Config = toml::from_str( + r#" +workspace-directory = "/tmp/workspaces" + +[runtime] +backend = "apple-container" +opencode-image = "example/opencode:latest" +codex-image = "example/codex:latest" + +[isolation] +"#, + ) + .expect("config should parse"); + + assert_eq!( + config.runtime.resolved_image(AgentProvider::Opencode), + Some("example/opencode:latest") + ); + assert_eq!( + config.runtime.resolved_image(AgentProvider::Codex), + Some("example/codex:latest") + ); + } + + #[test] + fn resolve_container_agent_command_prefers_opencode_for_apple_backend() { + assert_eq!( + resolve_container_agent_command( + AgentProvider::Opencode, crate::RuntimeBackend::AppleContainer, - &["opencode-cli".to_string(), "opencode".to_string()] + &Config { + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + ..default_config() + } ), "opencode" ); assert_eq!( - resolve_container_opencode_command( + resolve_container_agent_command( + AgentProvider::Opencode, crate::RuntimeBackend::AppleContainer, - &["/opt/homebrew/bin/opencode-cli".to_string()] + &Config { + opencode: vec!["/opt/homebrew/bin/opencode-cli".to_string()], + ..default_config() + } ), "opencode" ); } #[test] - fn resolve_container_opencode_command_keeps_first_candidate_for_linux_backend() { + fn resolve_container_agent_command_keeps_first_candidate_for_linux_backend() { assert_eq!( - resolve_container_opencode_command( + resolve_container_agent_command( + AgentProvider::Opencode, crate::RuntimeBackend::LinuxSystemdBwrap, - &["opencode-cli".to_string(), "opencode".to_string()] + &Config { + opencode: vec!["opencode-cli".to_string(), "opencode".to_string()], + ..default_config() + } ), "opencode-cli" ); @@ -1670,7 +2033,7 @@ populate-git-credentials = true } #[test] - fn combined_service_resolves_first_available_opencode_command() { + fn combined_service_resolves_first_available_agent_command() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1713,12 +2076,12 @@ populate-git-credentials = true .expect("combined service should start"); assert_eq!(service.config.opencode, vec!["opencode-cli", "opencode"]); - assert_eq!(service.opencode_command(), fallback.to_string_lossy()); + assert_eq!(service.agent_command(), fallback.to_string_lossy()); }); } #[test] - fn combined_service_fails_when_no_opencode_command_is_available() { + fn combined_service_fails_when_no_agent_command_is_available() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1757,7 +2120,7 @@ populate-git-credentials = true .expect_err("missing commands should fail"); match err { - CombinedServiceError::OpencodeCommandNotFound { candidates } => { + CombinedServiceError::AgentCommandNotFound { candidates } => { assert_eq!(candidates, vec!["missing-a", "missing-b"]); } other => panic!("unexpected error: {other:?}"), @@ -1932,6 +2295,166 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] }); } + #[test] + fn assign_workspace_issue_normalizes_and_requests_autonomous_start() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + let normalized = service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + assert_eq!( + normalized.as_deref(), + Some("https://github.com/example/repo/issues/42") + ); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert_eq!( + snapshot.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/42") + ); + assert_eq!(snapshot.automation_scan_request_nonce, 2); + + let cleared = service + .assign_workspace_issue("alpha", None) + .await + .expect("clearing issue assignment should succeed"); + assert!(cleared.is_none()); + + let cleared_snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert!(cleared_snapshot.persistent.automation_issue.is_none()); + assert_eq!(cleared_snapshot.automation_scan_request_nonce, 3); + }); + } + + #[test] + fn request_workspace_issue_scan_clears_manual_pause() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.persistent.automation_paused = true; + true + }); + + service + .request_workspace_issue_scan("alpha") + .expect("scan request should succeed"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + + assert!(!snapshot.persistent.automation_paused); + assert_eq!(snapshot.automation_scan_request_nonce, 2); + }); + } + #[test] fn delete_workspace_stops_runtime_and_removes_workspace_state() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -2298,7 +2821,7 @@ cpu = "400%" assert!(contains_sequence( &args, &[ - service.opencode_command(), + service.agent_command(), "serve", "--hostname", "127.0.0.1", diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 5cc494d..ba2f46a 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -20,6 +20,8 @@ pub struct Config { pub runtime: RuntimeConfig, #[serde(default)] pub autonomous: AutonomousConfig, + #[serde(default)] + pub agent: AgentConfig, #[serde(default = "default_opencode_commands")] pub opencode: Vec, #[serde(default)] @@ -42,6 +44,79 @@ pub struct AutonomousConfig { pub issue_scan_delay_seconds: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AgentProvider { + #[default] + Opencode, + Codex, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct AgentConfig { + #[serde(default)] + pub provider: AgentProvider, + #[serde(default)] + pub opencode: OpencodeAgentConfig, + #[serde(default)] + pub codex: CodexAgentConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct OpencodeAgentConfig { + #[serde(default)] + pub commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct CodexAgentConfig { + #[serde(default = "default_codex_commands")] + pub commands: Vec, + #[serde(default)] + pub profile: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub model_provider: Option, + #[serde(default)] + pub approval_policy: CodexApprovalPolicy, + #[serde(default)] + pub sandbox_mode: CodexSandboxMode, + #[serde(default)] + pub network_access: CodexNetworkAccess, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexApprovalPolicy { + Untrusted, + OnFailure, + #[default] + OnRequest, + Never, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexSandboxMode { + ReadOnly, + #[default] + WorkspaceWrite, + DangerFullAccess, + ExternalSandbox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CodexNetworkAccess { + Restricted, + #[default] + Enabled, +} + impl Default for AutonomousConfig { fn default() -> Self { Self { @@ -57,6 +132,19 @@ pub struct RuntimeConfig { pub backend: RuntimeBackend, #[serde(default)] pub image: Option, + #[serde(default)] + pub opencode_image: Option, + #[serde(default)] + pub codex_image: Option, +} + +impl RuntimeConfig { + pub fn resolved_image(&self, provider: AgentProvider) -> Option<&str> { + self.image.as_deref().or(match provider { + AgentProvider::Opencode => self.opencode_image.as_deref(), + AgentProvider::Codex => self.codex_image.as_deref(), + }) + } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -131,6 +219,10 @@ fn default_opencode_commands() -> Vec { vec!["opencode-cli".to_string(), "opencode".to_string()] } +fn default_codex_commands() -> Vec { + vec!["codex".to_string()] +} + fn default_remote_sync_interval_seconds() -> u64 { 2 } @@ -296,9 +388,7 @@ pub async fn read_config(config_path: &Path) -> Result Result { +pub(super) fn resolve_agent_command(candidates: &[String]) -> Result { let normalized = candidates .iter() .map(|candidate| candidate.trim()) @@ -312,7 +402,7 @@ pub(super) fn resolve_opencode_command( } } - Err(CombinedServiceError::OpencodeCommandNotFound { + Err(CombinedServiceError::AgentCommandNotFound { candidates: normalized, }) } diff --git a/lib/src/services/mod.rs b/lib/src/services/mod.rs index 573739e..c949711 100644 --- a/lib/src/services/mod.rs +++ b/lib/src/services/mod.rs @@ -1,4 +1,7 @@ +pub mod automation_state_file_service; pub mod autonomous_workspace_service; +pub mod codex_app_server; +pub mod codex_root_session_service; pub mod combined; pub mod config; pub mod github_status_service; @@ -17,10 +20,13 @@ pub(crate) mod workspace_task_watch; pub(crate) mod workspace_watch; pub use crate::database::{Database, DatabaseError}; +pub use automation_state_file_service::{ + AutomationStateFileServiceError, automation_state_file_service, +}; pub use combined::{CombinedService, CombinedServiceError, summarize_workspace_start_failure}; pub use config::{ - AutonomousConfig, Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, - ToolType, parse_optional_size_bytes, + AgentConfig, AgentProvider, AutonomousConfig, CodexAgentConfig, Config, GithubTokenConfig, + HandlerConfig, RuntimeConfig, ToolConfig, ToolType, parse_optional_size_bytes, }; pub use github_status_service::{ GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, diff --git a/lib/src/services/multicode_metadata_service.rs b/lib/src/services/multicode_metadata_service.rs index 22de5fa..4cc6b34 100644 --- a/lib/src/services/multicode_metadata_service.rs +++ b/lib/src/services/multicode_metadata_service.rs @@ -3,7 +3,11 @@ use std::{collections::BTreeSet, sync::Arc, time::Duration}; use tokio::sync::{broadcast, watch}; use super::{ - workspace_task_watch::watch_workspace_task, workspace_watch::monitor_workspace_snapshots, + codex_app_server::{ + CodexAppServerClient, CodexServerNotification, forward_codex_notifications_forever, + }, + workspace_task_watch::watch_workspace_task, + workspace_watch::monitor_workspace_snapshots, }; use crate::{ WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, @@ -33,6 +37,56 @@ struct MulticodeMetadata { prs: BTreeSet, } +#[derive(Clone)] +enum MetadataTaskKey { + Opencode { + session_id: String, + client: Arc, + event_tx: broadcast::Sender, + uri: String, + }, + Codex { + thread_id: String, + uri: String, + }, +} + +impl PartialEq for MetadataTaskKey { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + Self::Opencode { + session_id: left_session_id, + client: left_client, + uri: left_uri, + .. + }, + Self::Opencode { + session_id: right_session_id, + client: right_client, + uri: right_uri, + .. + }, + ) => { + left_session_id == right_session_id + && left_uri == right_uri + && Arc::ptr_eq(left_client, right_client) + } + ( + Self::Codex { + thread_id: left_thread_id, + uri: left_uri, + }, + Self::Codex { + thread_id: right_thread_id, + uri: right_uri, + }, + ) => left_thread_id == right_thread_id && left_uri == right_uri, + _ => false, + } + } +} + /// Watch the agent transcript for machine-readable metadata as specified by /// /workspace-skills/machine-readable-* pub async fn multicode_metadata_service( @@ -47,22 +101,6 @@ pub async fn multicode_metadata_service( .await } -#[derive(Clone)] -struct MetadataTaskKey { - session_id: String, - client: Arc, - event_tx: broadcast::Sender, - uri: String, -} - -impl PartialEq for MetadataTaskKey { - fn eq(&self, other: &Self) -> bool { - self.session_id == other.session_id - && self.uri == other.uri - && Arc::ptr_eq(&self.client, &other.client) - } -} - async fn watch_workspace_snapshot( workspace: Workspace, workspace_rx: watch::Receiver, @@ -71,30 +109,51 @@ async fn watch_workspace_snapshot( workspace, workspace_rx, |snapshot| { - Some(MetadataTaskKey { - session_id: snapshot.root_session_id.clone()?, - client: snapshot.opencode_client.as_ref()?.client.clone(), - event_tx: snapshot.opencode_client.as_ref()?.events.clone(), - uri: normalize_base_uri(&snapshot.transient.as_ref()?.uri), + let session_or_thread_id = snapshot.root_session_id.clone()?; + let transient_uri = snapshot.transient.as_ref()?.uri.clone(); + let parsed_uri = url::Url::parse(&transient_uri).ok()?; + + if matches!(parsed_uri.scheme(), "ws" | "wss") { + return Some(MetadataTaskKey::Codex { + thread_id: session_or_thread_id, + uri: normalize_base_uri(&transient_uri), + }); + } + + let opencode_client = snapshot.opencode_client.as_ref()?; + Some(MetadataTaskKey::Opencode { + session_id: session_or_thread_id, + client: opencode_client.client.clone(), + event_tx: opencode_client.events.clone(), + uri: normalize_base_uri(&transient_uri), }) }, |_: &Workspace| {}, |_: &Workspace, _: &MetadataTaskKey, _: Option| {}, |workspace: &Workspace, key: &MetadataTaskKey| { let task_workspace = workspace.clone(); - let task_client = key.client.clone(); - let task_session_id = key.session_id.clone(); - let task_uri = key.uri.clone(); - let task_event_tx = key.event_tx.clone(); + let task_key = key.clone(); tokio::spawn(async move { - sync_multicode_metadata_from_history_and_events( - task_workspace, - task_client, - task_event_tx, - task_session_id, - task_uri, - ) - .await; + match task_key { + MetadataTaskKey::Opencode { + session_id, + client, + event_tx, + uri, + } => { + sync_multicode_metadata_from_history_and_events( + task_workspace, + client, + event_tx, + session_id, + uri, + ) + .await; + } + MetadataTaskKey::Codex { thread_id, uri } => { + sync_codex_multicode_metadata(task_workspace, thread_id, uri).await; + } + } }) }, ) @@ -340,6 +399,128 @@ fn normalize_base_uri(uri: &str) -> String { uri.trim_end_matches('/').to_string() } +async fn sync_codex_multicode_metadata( + workspace: Workspace, + thread_id: String, + expected_uri: String, +) { + let client = CodexAppServerClient::new(expected_uri.clone()); + let event_tx = broadcast::channel(256).0; + let forwarder = tokio::spawn(forward_codex_notifications_forever( + client.clone(), + event_tx.clone(), + )); + let mut event_rx = event_tx.subscribe(); + + refresh_snapshot_codex_multicode_metadata(&workspace, &client, &thread_id, &expected_uri).await; + + loop { + match event_rx.recv().await { + Ok(CodexServerNotification::ThreadStarted { thread }) if thread.id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(CodexServerNotification::TurnCompleted { + thread_id: completed_thread_id, + }) if completed_thread_id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(CodexServerNotification::ThreadStatusChanged { + thread_id: changed_thread_id, + .. + }) if changed_thread_id == thread_id => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Ok(_) => {} + Err(broadcast::error::RecvError::Lagged(_)) => { + refresh_snapshot_codex_multicode_metadata( + &workspace, + &client, + &thread_id, + &expected_uri, + ) + .await; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + + forwarder.abort(); +} + +async fn refresh_snapshot_codex_multicode_metadata( + workspace: &Workspace, + client: &CodexAppServerClient, + thread_id: &str, + expected_uri: &str, +) { + let Ok(response) = client.thread_read(thread_id).await else { + return; + }; + let metadata = collect_metadata_from_codex_turns(response.thread.turns.iter()); + let repositories = metadata.repositories.iter().cloned().collect::>(); + let issues = metadata.issues.iter().cloned().collect::>(); + let prs = metadata.prs.iter().cloned().collect::>(); + + workspace.update(|snapshot| { + let still_tracking_same_session = snapshot.root_session_id.as_deref() == Some(thread_id); + let still_attached_to_expected_uri = snapshot + .transient + .as_ref() + .map(|transient| normalize_base_uri(&transient.uri)) + .as_deref() + == Some(expected_uri); + let should_update = still_tracking_same_session + && still_attached_to_expected_uri + && (snapshot.persistent.agent_provided.repo != repositories + || snapshot.persistent.agent_provided.issue != issues + || snapshot.persistent.agent_provided.pr != prs); + if should_update { + snapshot.persistent.agent_provided.repo = repositories; + snapshot.persistent.agent_provided.issue = issues; + snapshot.persistent.agent_provided.pr = prs; + true + } else { + false + } + }); +} + +fn collect_metadata_from_codex_turns<'a>( + turns: impl IntoIterator, +) -> MulticodeMetadata { + let mut metadata = MulticodeMetadata::default(); + for turn in turns { + for item in &turn.items { + if item.get("type").and_then(serde_json::Value::as_str) != Some("agentMessage") { + continue; + } + let Some(text) = item.get("text").and_then(serde_json::Value::as_str) else { + continue; + }; + merge_text_metadata(text, &mut metadata); + } + } + metadata +} + #[cfg(test)] mod tests { use super::*; @@ -576,4 +757,43 @@ mod tests { "ses-root", )); } + + #[test] + fn collects_metadata_from_codex_turn_items() { + let turns = vec![super::super::codex_app_server::CodexThreadTurn { + items: vec![ + serde_json::json!({ + "type": "agentMessage", + "text": "/srv/work/core" + }), + serde_json::json!({ + "type": "agentMessage", + "text": "https://github.com/acme/core/issue/42" + }), + serde_json::json!({ + "type": "agentMessage", + "text": "https://github.com/acme/core/pull/99" + }), + serde_json::json!({ + "type": "userMessage", + "content": [{ "type": "text", "text": "ignored" }] + }), + ], + }]; + + let metadata = collect_metadata_from_codex_turns(turns.iter()); + + assert_eq!( + metadata.repositories, + BTreeSet::from(["/srv/work/core".to_string()]) + ); + assert_eq!( + metadata.issues, + BTreeSet::from(["https://github.com/acme/core/issue/42".to_string()]) + ); + assert_eq!( + metadata.prs, + BTreeSet::from(["https://github.com/acme/core/pull/99".to_string()]) + ); + } } diff --git a/lib/src/services/persistent_storage.rs b/lib/src/services/persistent_storage.rs index 2e5ea2b..16eabee 100644 --- a/lib/src/services/persistent_storage.rs +++ b/lib/src/services/persistent_storage.rs @@ -284,6 +284,7 @@ mod tests { created_at: Some(UNIX_EPOCH + Duration::from_secs(10)), assigned_repository: None, automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), @@ -372,6 +373,7 @@ mod tests { created_at: Some(UNIX_EPOCH + Duration::from_secs(20)), assigned_repository: None, automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), @@ -490,6 +492,7 @@ mod tests { created_at: None, assigned_repository: None, automation_issue: None, + automation_paused: false, archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), diff --git a/lib/src/services/resource_usage_service.rs b/lib/src/services/resource_usage_service.rs index 4cfae37..4590c0a 100644 --- a/lib/src/services/resource_usage_service.rs +++ b/lib/src/services/resource_usage_service.rs @@ -7,7 +7,7 @@ use std::{ use tokio::{process::Command, sync::watch}; use super::{ - runtime::{RuntimeUsageSample, RuntimeUsageState, WorkspaceRuntime}, + runtime::{RuntimeActivity, RuntimeUsageSample, RuntimeUsageState, WorkspaceRuntime}, workspace_watch::monitor_workspace_snapshots, }; use crate::{WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace}; @@ -69,6 +69,20 @@ async fn watch_workspace_snapshot( let now = Instant::now(); if should_sample_usage(now, next_sample_at) { + match WorkspaceRuntime::read_activity(&transient.runtime).await { + RuntimeActivity::Stopped => { + previous_cpu_sample = None; + clear_stale_runtime_for_unit(&workspace, &transient.runtime.id); + next_sample_at = Some(now + RESOURCE_MONITOR_INTERVAL); + let wait_timeout = next_poll_timeout(Instant::now(), next_sample_at); + if !wait_for_change_or_timeout(&mut workspace_rx, wait_timeout).await { + break; + } + continue; + } + RuntimeActivity::Active | RuntimeActivity::Unknown => {} + } + match WorkspaceRuntime::read_usage(&transient.runtime).await { RuntimeUsageSample { state: Some(RuntimeUsageState::Active), @@ -169,6 +183,62 @@ fn clear_resource_usage_for_unit(workspace: &Workspace, unit: &str) { }); } +fn clear_stale_runtime_for_unit(workspace: &Workspace, unit: &str) { + workspace.update(|snapshot| { + let still_tracking_same_unit = snapshot + .transient + .as_ref() + .map(|transient| transient.runtime.id.as_str() == unit) + .unwrap_or(false); + if !still_tracking_same_unit { + return false; + } + + let mut changed = false; + if snapshot.transient.is_some() { + snapshot.transient = None; + changed = true; + } + if snapshot.opencode_client.is_some() { + snapshot.opencode_client = None; + changed = true; + } + if snapshot.root_session_id.is_some() { + snapshot.root_session_id = None; + changed = true; + } + if snapshot.root_session_title.is_some() { + snapshot.root_session_title = None; + changed = true; + } + if snapshot.root_session_status.is_some() { + snapshot.root_session_status = None; + changed = true; + } + if snapshot.automation_session_id.is_some() { + snapshot.automation_session_id = None; + changed = true; + } + if snapshot.automation_session_status.is_some() { + snapshot.automation_session_status = None; + changed = true; + } + if snapshot.automation_agent_state.is_some() { + snapshot.automation_agent_state = None; + changed = true; + } + if snapshot.usage_cpu_percent.is_some() { + snapshot.usage_cpu_percent = None; + changed = true; + } + if snapshot.usage_ram_bytes.is_some() { + snapshot.usage_ram_bytes = None; + changed = true; + } + changed + }); +} + fn cpu_percent_from_sample( previous_sample: Option<(u64, Instant)>, current_cpu_usage_nsec: Option, @@ -360,6 +430,37 @@ mod tests { assert_eq!(next_sample, None); } + #[test] + fn clear_stale_runtime_for_unit_clears_matching_runtime_state() { + let workspace = crate::manager::Workspace::new(crate::WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.transient = Some(crate::TransientWorkspaceSnapshot { + uri: "ws://127.0.0.1:1234".to_string(), + runtime: crate::RuntimeHandleSnapshot { + backend: crate::RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-1".to_string()); + snapshot.root_session_title = Some("Codex".to_string()); + snapshot.root_session_status = Some(crate::RootSessionStatus::Busy); + snapshot.usage_cpu_percent = Some(25); + snapshot.usage_ram_bytes = Some(1024); + true + }); + + clear_stale_runtime_for_unit(&workspace, "runtime-1"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.transient.is_none()); + assert!(snapshot.root_session_id.is_none()); + assert!(snapshot.root_session_title.is_none()); + assert!(snapshot.root_session_status.is_none()); + assert!(snapshot.usage_cpu_percent.is_none()); + assert!(snapshot.usage_ram_bytes.is_none()); + } + #[test] fn should_sample_usage_only_when_interval_has_elapsed() { let now = Instant::now(); diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index de2b9b0..edb09e6 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -10,13 +10,20 @@ use uuid::Uuid; use super::{ combined::{CombinedServiceError, SpawnCommand}, - config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, + config::{ + AgentProvider, CodexAgentConfig, CodexApprovalPolicy, CodexSandboxMode, + ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file, + }, }; use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; const APPLE_GITCONFIG_DIR: &str = "/multicode-host/git"; const APPLE_GITCONFIG_FILE_NAME: &str = ".gitconfig"; +const SYNTHETIC_CODEX_HOME: &str = "/multicode-agent/codex-home"; +pub(crate) const AUTOMATION_STATE_DIR: &str = "/multicode-agent/automation"; +pub(crate) const AUTOMATION_STATE_ENV: &str = "MULTICODE_AUTONOMOUS_STATE_PATH"; +pub(crate) const AUTOMATION_STATE_FILE_NAME: &str = "state"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RuntimeActivity { @@ -49,8 +56,10 @@ struct RuntimeContext { runtime: RuntimeConfig, workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, - host_opencode_command: String, - container_opencode_command: String, + agent_provider: AgentProvider, + host_agent_command: String, + container_agent_command: String, + codex: CodexAgentConfig, } #[derive(Debug, Clone)] @@ -59,20 +68,228 @@ pub(super) enum WorkspaceRuntime { AppleContainer(AppleContainerRuntime), } +fn append_agent_env(context: &RuntimeContext, env: &mut Vec<(String, String)>, password: &str) { + env.push(( + AUTOMATION_STATE_ENV.to_string(), + format!("{AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}"), + )); + match context.agent_provider { + AgentProvider::Opencode => { + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + } + AgentProvider::Codex => { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } + } +} + +fn start_command_args( + context: &RuntimeContext, + command: &str, + host: &str, + port: u16, +) -> Vec { + match context.agent_provider { + AgentProvider::Opencode => vec![ + command.to_string(), + "serve".to_string(), + "--hostname".to_string(), + host.to_string(), + "--port".to_string(), + port.to_string(), + ], + AgentProvider::Codex => { + vec![ + command.to_string(), + "app-server".to_string(), + "--listen".to_string(), + format!("ws://{host}:{port}"), + ] + } + } +} + +fn server_uri(context: &RuntimeContext, password: &str, port: u16) -> String { + match context.agent_provider { + AgentProvider::Opencode => format!("http://opencode:{password}@127.0.0.1:{port}/"), + AgentProvider::Codex => format!("ws://127.0.0.1:{port}"), + } +} + +fn synthetic_codex_home_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("codex") + .join(key) + .join("home") +} + +pub(crate) fn automation_state_dir_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("automation") + .join(key) +} + +pub(crate) fn automation_state_file_source(workspace_directory_path: &Path, key: &str) -> PathBuf { + automation_state_dir_source(workspace_directory_path, key).join(AUTOMATION_STATE_FILE_NAME) +} + +async fn prepare_synthetic_codex_home( + source_root: &Path, + added_skills: &[super::config::AddedSkillMount], + config: &CodexAgentConfig, +) -> Result<(), CombinedServiceError> { + tokio::fs::create_dir_all(source_root).await?; + let host_home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| { + CombinedServiceError::ShellExpand("HOME environment variable not found".to_string()) + })?; + let host_codex_home = host_home.join(".codex"); + let target_config = source_root.join("config.toml"); + + copy_optional_host_file(&host_codex_home.join("config.toml"), &target_config).await?; + write_synthetic_codex_config(&target_config, config).await?; + copy_optional_host_file( + &host_codex_home.join("auth.json"), + &source_root.join("auth.json"), + ) + .await?; + copy_optional_host_file( + &host_codex_home.join("AGENTS.md"), + &source_root.join("AGENTS.md"), + ) + .await?; + + let target_skills_root = source_root.join("skills"); + clear_directory_contents(&target_skills_root).await?; + let host_skills_root = host_codex_home.join("skills"); + if tokio::fs::metadata(&host_skills_root) + .await + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + copy_directory_tree(&host_skills_root, &target_skills_root).await?; + } + for skill in added_skills { + let Some(skill_name) = skill.target.file_name() else { + return Err(CombinedServiceError::InvalidRuntimeConfig { + field: "isolation.add-skills-from".to_string(), + message: format!( + "added skill target '{}' is missing a terminal directory name", + skill.target.display() + ), + }); + }; + copy_directory_tree(&skill.source, &target_skills_root.join(skill_name)).await?; + } + + Ok(()) +} + +async fn write_synthetic_codex_config( + target: &Path, + config: &CodexAgentConfig, +) -> Result<(), std::io::Error> { + let mut contents = match tokio::fs::read_to_string(target).await { + Ok(existing) => existing, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } + contents.push_str(&render_multicode_codex_config_overrides(config)); + tokio::fs::write(target, contents).await +} + +fn render_multicode_codex_config_overrides(config: &CodexAgentConfig) -> String { + let mut lines = vec!["# Managed by multicode".to_string()]; + + if let Some(profile) = config.profile.as_deref() { + lines.push(format!( + "profile = {}", + toml::Value::String(profile.to_string()) + )); + } + if let Some(model) = config.model.as_deref() { + lines.push(format!( + "model = {}", + toml::Value::String(model.to_string()) + )); + } + if let Some(model_provider) = config.model_provider.as_deref() { + lines.push(format!( + "model_provider = {}", + toml::Value::String(model_provider.to_string()) + )); + } + lines.push(format!( + "approval_policy = {}", + toml::Value::String(codex_approval_policy_config_value(config.approval_policy).to_string()) + )); + lines.push(format!( + "sandbox_mode = {}", + toml::Value::String(codex_sandbox_mode_config_value(config.sandbox_mode).to_string()) + )); + + lines.join("\n") + "\n" +} + +fn codex_approval_policy_config_value(policy: CodexApprovalPolicy) -> &'static str { + match policy { + CodexApprovalPolicy::Untrusted => "untrusted", + CodexApprovalPolicy::OnFailure => "on-failure", + CodexApprovalPolicy::OnRequest => "on-request", + CodexApprovalPolicy::Never => "never", + } +} + +fn codex_sandbox_mode_config_value(mode: CodexSandboxMode) -> &'static str { + match mode { + CodexSandboxMode::ReadOnly => "read-only", + CodexSandboxMode::WorkspaceWrite => "workspace-write", + CodexSandboxMode::DangerFullAccess => "danger-full-access", + CodexSandboxMode::ExternalSandbox => "danger-full-access", + } +} + +async fn copy_optional_host_file(source: &Path, target: &Path) -> Result<(), std::io::Error> { + let Ok(metadata) = tokio::fs::metadata(source).await else { + return Ok(()); + }; + if !metadata.is_file() { + return Ok(()); + } + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(source, target).await?; + Ok(()) +} + impl WorkspaceRuntime { pub(super) fn new( runtime: RuntimeConfig, workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, - host_opencode_command: String, - container_opencode_command: String, + agent_provider: AgentProvider, + host_agent_command: String, + container_agent_command: String, + codex: CodexAgentConfig, ) -> Self { let context = RuntimeContext { runtime: runtime.clone(), workspace_directory_path, expanded_isolation, - host_opencode_command, - container_opencode_command, + agent_provider, + host_agent_command, + container_agent_command, + codex, }; match runtime.backend { RuntimeBackend::LinuxSystemdBwrap => Self::Linux(LinuxSystemdBwrapRuntime { context }), @@ -186,12 +403,13 @@ impl WorkspaceRuntime { let mut parts = vec![ format!("backend={:?}", context.runtime.backend), + format!("agent-provider={:?}", context.agent_provider), format!( "image={}", context.runtime.image.as_deref().unwrap_or_default() ), - format!("host-opencode={}", context.host_opencode_command), - format!("container-opencode={}", context.container_opencode_command), + format!("host-agent={}", context.host_agent_command), + format!("container-agent={}", context.container_agent_command), format!( "readable={}", format_path_list(&context.expanded_isolation.readable) @@ -285,7 +503,7 @@ impl LinuxSystemdBwrapRuntime { Ok(RuntimeStartResult { transient: TransientWorkspaceSnapshot { - uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + uri: server_uri(&self.context, &password, port), runtime: RuntimeHandleSnapshot { backend: RuntimeBackend::LinuxSystemdBwrap, id: unit, @@ -330,13 +548,17 @@ impl LinuxSystemdBwrapRuntime { command: Vec, ) -> Result { let unit = generate_linux_runtime_id(); + let mut env = inherited_env.to_vec(); + if self.context.agent_provider == AgentProvider::Codex { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } let mut args = vec![ "--user".to_string(), "--wait".to_string(), "--collect".to_string(), "--pty".to_string(), ]; - append_systemd_run_inherit_env(&mut args, inherited_env); + append_systemd_run_inherit_env(&mut args, &env); args.push("--unit".to_string()); args.push(unit); self.append_systemd_limits(&mut args); @@ -346,7 +568,7 @@ impl LinuxSystemdBwrapRuntime { Ok(SpawnCommand { program: "systemd-run".to_string(), args, - inherited_env: inherited_env.to_vec(), + inherited_env: env, }) } @@ -360,23 +582,19 @@ impl LinuxSystemdBwrapRuntime { ) -> Result { let mut args = vec!["--user".to_string(), "--no-block".to_string()]; let mut env = inherited_env.to_vec(); - env.push(( - "OPENCODE_SERVER_USERNAME".to_string(), - "opencode".to_string(), - )); - env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + append_agent_env(&self.context, &mut env, password); append_systemd_run_inherit_env(&mut args, &env); args.push("--unit".to_string()); args.push(unit.to_string()); self.append_systemd_limits(&mut args); self.append_bwrap_sandbox_args(&mut args, key).await?; - args.push(self.context.host_opencode_command.clone()); - args.push("serve".to_string()); - args.push("--hostname".to_string()); - args.push("127.0.0.1".to_string()); - args.push("--port".to_string()); - args.push(port.to_string()); + args.extend(start_command_args( + &self.context, + &self.context.host_agent_command, + "127.0.0.1", + port, + )); Ok(SpawnCommand { program: "systemd-run".to_string(), @@ -467,6 +685,28 @@ impl LinuxSystemdBwrapRuntime { .cloned() .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), ); + mount_specs.push(MountSpec::new( + PathBuf::from(AUTOMATION_STATE_DIR), + Some(automation_state_dir_source( + &self.context.workspace_directory_path, + key, + )), + MountKind::Writable, + )); + if self.context.agent_provider == AgentProvider::Codex { + let source = synthetic_codex_home_source(&self.context.workspace_directory_path, key); + prepare_synthetic_codex_home( + &source, + &self.context.expanded_isolation.added_skills, + &self.context.codex, + ) + .await?; + mount_specs.push(MountSpec::new( + PathBuf::from(SYNTHETIC_CODEX_HOME), + Some(source), + MountKind::Writable, + )); + } mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -622,7 +862,7 @@ impl AppleContainerRuntime { Ok(RuntimeStartResult { transient: TransientWorkspaceSnapshot { - uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + uri: server_uri(&self.context, &password, port), runtime: RuntimeHandleSnapshot { backend: RuntimeBackend::AppleContainer, id: container_name, @@ -726,12 +966,20 @@ impl AppleContainerRuntime { .await; } - let image = self.context.runtime.image.as_deref().ok_or_else(|| { - CombinedServiceError::InvalidRuntimeConfig { + let image = self + .context + .runtime + .resolved_image(self.context.agent_provider) + .ok_or_else(|| CombinedServiceError::InvalidRuntimeConfig { field: "runtime.image".to_string(), - message: "apple-container backend requires a runtime image".to_string(), - } - })?; + message: format!( + "apple-container backend requires a runtime image for provider '{}'", + match self.context.agent_provider { + AgentProvider::Opencode => "opencode", + AgentProvider::Codex => "codex", + } + ), + })?; let mut env = inherited_env.to_vec(); let host_gitconfig = self.host_gitconfig_path_for_env(&env); self.append_implicit_env(&mut env, host_gitconfig.as_deref()) @@ -852,18 +1100,22 @@ impl AppleContainerRuntime { port: u16, inherited_env: &[(String, String)], ) -> Result { - let image = self.context.runtime.image.as_deref().ok_or_else(|| { - CombinedServiceError::InvalidRuntimeConfig { + let image = self + .context + .runtime + .resolved_image(self.context.agent_provider) + .ok_or_else(|| CombinedServiceError::InvalidRuntimeConfig { field: "runtime.image".to_string(), - message: "apple-container backend requires a runtime image".to_string(), - } - })?; + message: format!( + "apple-container backend requires a runtime image for provider '{}'", + match self.context.agent_provider { + AgentProvider::Opencode => "opencode", + AgentProvider::Codex => "codex", + } + ), + })?; let mut env = inherited_env.to_vec(); - env.push(( - "OPENCODE_SERVER_USERNAME".to_string(), - "opencode".to_string(), - )); - env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + append_agent_env(&self.context, &mut env, password); let host_gitconfig = self.host_gitconfig_path_for_env(&env); self.append_implicit_env(&mut env, host_gitconfig.as_deref()) .await?; @@ -889,12 +1141,12 @@ impl AppleContainerRuntime { self.append_container_mounts(&mut args, key, host_gitconfig.as_deref()) .await?; args.push(image.to_string()); - args.push(self.context.container_opencode_command.clone()); - args.push("serve".to_string()); - args.push("--hostname".to_string()); - args.push("0.0.0.0".to_string()); - args.push("--port".to_string()); - args.push(port.to_string()); + args.extend(start_command_args( + &self.context, + &self.context.container_agent_command, + "0.0.0.0", + port, + )); Ok(SpawnCommand { program: container_program(), @@ -995,6 +1247,28 @@ impl AppleContainerRuntime { if let Some(implicit_gitconfig_mount) = implicit_gitconfig_mount { mount_specs.push(implicit_gitconfig_mount); } + mount_specs.push(MountSpec::new( + PathBuf::from(AUTOMATION_STATE_DIR), + Some(automation_state_dir_source( + &self.context.workspace_directory_path, + key, + )), + MountKind::Writable, + )); + if self.context.agent_provider == AgentProvider::Codex { + let source = synthetic_codex_home_source(&self.context.workspace_directory_path, key); + prepare_synthetic_codex_home( + &source, + &self.context.expanded_isolation.added_skills, + &self.context.codex, + ) + .await?; + mount_specs.push(MountSpec::new( + PathBuf::from(SYNTHETIC_CODEX_HOME), + Some(source), + MountKind::Writable, + )); + } mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -1034,6 +1308,13 @@ impl AppleContainerRuntime { env: &mut Vec<(String, String)>, host_gitconfig: Option<&Path>, ) -> Result<(), CombinedServiceError> { + env.push(( + AUTOMATION_STATE_ENV.to_string(), + format!("{AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}"), + )); + if self.context.agent_provider == AgentProvider::Codex { + env.push(("CODEX_HOME".to_string(), SYNTHETIC_CODEX_HOME.to_string())); + } if host_gitconfig.is_some() { env.push(( "GIT_CONFIG_GLOBAL".to_string(), @@ -1587,7 +1868,10 @@ impl ResolvedMountSpec { #[cfg(test)] mod tests { use super::*; - use crate::services::config::{AddedSkillMount, IsolationConfig}; + use crate::services::config::{ + AddedSkillMount, AgentProvider, CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, + CodexSandboxMode, IsolationConfig, + }; use std::fs; struct TestDir { @@ -1624,11 +1908,40 @@ mod tests { runtime: RuntimeConfig { backend: RuntimeBackend::AppleContainer, image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, + }, + workspace_directory_path: root.path().join("workspaces"), + expanded_isolation, + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), + }, + } + } + + fn apple_codex_runtime( + root: &TestDir, + isolation: IsolationConfig, + codex: CodexAgentConfig, + ) -> AppleContainerRuntime { + let expanded_isolation = + ExpandedIsolationConfig::from_config(&isolation, None).expect("config should expand"); + AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, }, workspace_directory_path: root.path().join("workspaces"), expanded_isolation, - host_opencode_command: "/opt/opencode/bin/opencode".to_string(), - container_opencode_command: "opencode".to_string(), + agent_provider: AgentProvider::Codex, + host_agent_command: "/opt/homebrew/bin/codex".to_string(), + container_agent_command: "codex".to_string(), + codex, }, } } @@ -1766,6 +2079,155 @@ mod tests { }); } + #[test] + fn apple_container_run_command_supports_codex_provider() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let host_codex = home.join(".codex"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(host_codex.join("skills")) + .expect("host codex skills directory should exist"); + fs::write(host_codex.join("config.toml"), "model = \"gpt-5-codex\"\n") + .expect("codex config should exist"); + fs::write(host_codex.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should exist"); + fs::write(host_codex.join("skills/example.md"), "# example") + .expect("codex skill should exist"); + + let previous_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &home); + } + + let runtime = apple_codex_runtime( + &root, + IsolationConfig::default(), + CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[("HOME".to_string(), home.to_string_lossy().into_owned())], + ) + .await + .expect("command should build"); + let server_env = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + + if let Some(previous_home) = previous_home { + unsafe { + std::env::set_var("HOME", previous_home); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + + assert!(contains_sequence( + &command.args, + &[ + "ghcr.io/example/multicode-java25:latest", + "codex", + "app-server", + "--listen", + "ws://0.0.0.0:31337", + ] + )); + assert!( + command.args.iter().any(|arg| { + arg.contains("type=bind") + && arg.contains(&format!("target={SYNTHETIC_CODEX_HOME}")) + }), + "apple backend should mount a synthetic CODEX_HOME" + ); + assert!( + command.args.iter().any(|arg| { + arg.contains("type=bind") + && arg.contains(&format!("target={AUTOMATION_STATE_DIR}")) + }), + "apple backend should mount the automation state directory" + ); + + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!( + env_contents.contains(&format!("CODEX_HOME={SYNTHETIC_CODEX_HOME}")), + "apple backend should export CODEX_HOME for codex" + ); + assert!( + env_contents.contains(&format!( + "{AUTOMATION_STATE_ENV}={AUTOMATION_STATE_DIR}/{AUTOMATION_STATE_FILE_NAME}" + )), + "apple backend should export the automation state file path" + ); + assert_eq!( + fs::read_to_string( + workspace_root + .join(".multicode") + .join("codex") + .join("alpha") + .join("home") + .join("config.toml") + ) + .expect("synthetic codex config should exist"), + concat!( + "model = \"gpt-5-codex\"\n", + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ) + ); + }); + } + + #[test] + fn synthetic_codex_config_overrides_external_sandbox_with_dangerous_access() { + assert_eq!( + render_multicode_codex_config_overrides(&CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }), + concat!( + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ) + ); + } + #[test] fn apple_container_implicitly_mounts_host_gitconfig() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -2045,6 +2507,8 @@ mod tests { runtime: RuntimeConfig { backend: RuntimeBackend::AppleContainer, image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, }, workspace_directory_path: workspace_root.clone(), expanded_isolation: ExpandedIsolationConfig { @@ -2067,8 +2531,10 @@ mod tests { memory_max_bytes: None, cpu: None, }, - host_opencode_command: "/opt/opencode/bin/opencode".to_string(), - container_opencode_command: "opencode".to_string(), + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), }, }; @@ -2139,6 +2605,8 @@ mod tests { runtime: RuntimeConfig { backend: RuntimeBackend::AppleContainer, image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, }, workspace_directory_path: workspace_root.clone(), expanded_isolation: ExpandedIsolationConfig { @@ -2155,8 +2623,10 @@ mod tests { memory_max_bytes: None, cpu: None, }, - host_opencode_command: "/opt/opencode/bin/opencode".to_string(), - container_opencode_command: "opencode".to_string(), + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), }, }; @@ -2218,6 +2688,8 @@ mod tests { runtime: RuntimeConfig { backend: RuntimeBackend::AppleContainer, image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + opencode_image: None, + codex_image: None, }, workspace_directory_path: workspace_root.clone(), expanded_isolation: ExpandedIsolationConfig { @@ -2234,8 +2706,10 @@ mod tests { memory_max_bytes: None, cpu: None, }, - host_opencode_command: "/opt/opencode/bin/opencode".to_string(), - container_opencode_command: "opencode".to_string(), + agent_provider: AgentProvider::Opencode, + host_agent_command: "/opt/opencode/bin/opencode".to_string(), + container_agent_command: "opencode".to_string(), + codex: CodexAgentConfig::default(), }, }; diff --git a/lib/tests/apple_container_runtime_integration.rs b/lib/tests/apple_container_runtime_integration.rs index 671e3af..6a70b22 100644 --- a/lib/tests/apple_container_runtime_integration.rs +++ b/lib/tests/apple_container_runtime_integration.rs @@ -148,6 +148,11 @@ fn write_fake_opencode(path: &Path) { make_executable(path); } +fn write_fake_codex(path: &Path) { + fs::write(path, "#!/bin/bash\nexit 0\n").expect("fake codex should be written"); + make_executable(path); +} + fn read_commands(path: &Path) -> Vec { fs::read_to_string(path) .expect("commands log should be readable") @@ -305,6 +310,283 @@ cpu = "300%" }); } +#[test] +fn starts_workspace_with_apple_container_backend_and_codex_provider() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let host_codex_dir = home.join(".codex"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + fs::create_dir_all(host_codex_dir.join("skills")).expect("host codex skills should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_codex(&bin_dir.join("codex")); + fs::write( + host_codex_dir.join("config.toml"), + "model = \"gpt-5-codex\"\n", + ) + .expect("codex config should be written"); + fs::write(host_codex_dir.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should be written"); + fs::write(host_codex_dir.join("AGENTS.md"), "# Host instructions\n") + .expect("codex AGENTS should be written"); + fs::write( + host_codex_dir.join("skills/host-skill.md"), + "# host skill\n", + ) + .expect("codex skill should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43124"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +model = "gpt-5-codex" +model-provider = "openai" + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["{home}/.gradle", "{home}/.config/gh"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "16 GiB" +cpu = "300%" +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + assert!(transient.uri.starts_with("ws://127.0.0.1:43124")); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!(run_command.contains(&format!("--name {}", transient.runtime.id))); + assert!(run_command.contains("--cpus 3")); + assert!(run_command.contains("--memory 17179869184")); + assert!(run_command.contains("codex app-server --listen ws://0.0.0.0:43124")); + + let server_env = workspace_directory + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!(env_contents.contains("CODEX_HOME=/multicode-agent/codex-home")); + assert!(env_contents.contains(&format!("HOME={}", home.display()))); + + let synthetic_codex_home = workspace_directory + .join(".multicode") + .join("codex") + .join("alpha") + .join("home"); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("config.toml")) + .expect("synthetic codex config should exist"), + "model = \"gpt-5-codex\"\n" + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("auth.json")) + .expect("synthetic codex auth should exist"), + r#"{"token":"codex"}"# + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("AGENTS.md")) + .expect("synthetic codex AGENTS should exist"), + "# Host instructions\n" + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/host-skill.md")) + .expect("synthetic codex skill should exist"), + "# host skill\n" + ); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + }); +} + +#[test] +fn apple_container_codex_provider_merges_added_skills_into_synthetic_home() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let host_codex_dir = home.join(".codex"); + let workspace_skills = root.path().join("workspace-skills"); + let added_skill = workspace_skills.join("workspace-skill"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + fs::create_dir_all(host_codex_dir.join("skills/host-skill")) + .expect("host codex skills should exist"); + fs::create_dir_all(&added_skill).expect("added skill should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_codex(&bin_dir.join("codex")); + fs::write( + host_codex_dir.join("config.toml"), + "model = \"gpt-5-codex\"\n", + ) + .expect("codex config should be written"); + fs::write(host_codex_dir.join("auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should be written"); + fs::write( + host_codex_dir.join("skills/host-skill/SKILL.md"), + "# Host Skill\n", + ) + .expect("host skill should be written"); + fs::write(added_skill.join("SKILL.md"), "# Workspace Skill\n") + .expect("added skill should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43125"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +add-skills-from = ["./workspace-skills"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let synthetic_codex_home = workspace_directory + .join(".multicode") + .join("codex") + .join("alpha") + .join("home"); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/host-skill/SKILL.md")) + .expect("host skill should be copied"), + "# Host Skill\n" + ); + assert_eq!( + fs::read_to_string(synthetic_codex_home.join("skills/workspace-skill/SKILL.md")) + .expect("added skill should be copied"), + "# Workspace Skill\n" + ); + }); +} + #[test] fn start_workspace_uses_unique_runtime_id_even_when_stale_named_container_exists() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -702,3 +984,100 @@ cpu = "100%" .expect("workspace should stop"); }); } + +#[test] +#[ignore = "requires a real Apple container image with codex installed"] +fn real_apple_container_backend_starts_and_stops_codex_with_supplied_image() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let image = std::env::var("MULTICODE_APPLE_CONTAINER_TEST_IMAGE") + .expect("set MULTICODE_APPLE_CONTAINER_TEST_IMAGE to a real image that contains codex"); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(home.join(".codex/skills")).expect("codex skills dir should exist"); + fs::write(home.join(".codex/config.toml"), "model = \"gpt-5-codex\"\n") + .expect("codex config should exist"); + fs::write(home.join(".codex/auth.json"), r#"{"token":"codex"}"#) + .expect("codex auth should exist"); + + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" + +[agent] +provider = "codex" + +[agent.codex] +commands = ["codex"] +approval-policy = "never" +sandbox-mode = "external-sandbox" +network-access = "enabled" + +[runtime] +backend = "apple-container" +image = "{image}" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "4 GiB" +cpu = "100%" +"#, + workspace_directory = workspace_directory.display(), + image = image, + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start with real codex container backend"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert!( + transient.uri.starts_with("ws://127.0.0.1:"), + "codex runtime should publish a websocket uri" + ); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop with real codex container backend"); + }); +} diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index c6e772a..515c9fc 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -1410,8 +1410,7 @@ fn remote_tui_sync_mapping( #[cfg(test)] mod tests { use super::*; - use multicode_lib::services::config::IsolationConfig; - use multicode_lib::services::config::RemoteConfig; + use multicode_lib::services::config::{AgentConfig, IsolationConfig, RemoteConfig}; use std::fs; #[test] @@ -1493,6 +1492,7 @@ mod tests { isolation: Default::default(), runtime: Default::default(), autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1661,6 +1661,7 @@ mod tests { isolation: Default::default(), runtime: Default::default(), autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1715,6 +1716,7 @@ mod tests { }, runtime: Default::default(), autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -1752,6 +1754,7 @@ mod tests { }, runtime: Default::default(), autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), @@ -2317,6 +2320,7 @@ mod tests { isolation: Default::default(), runtime: Default::default(), autonomous: Default::default(), + agent: AgentConfig::default(), opencode: vec!["opencode-cli".to_string()], tool: Vec::new(), handler: Default::default(), diff --git a/tui/src/app.rs b/tui/src/app.rs index 6040952..3e29153 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -5,6 +5,7 @@ use multicode_lib::services::GithubTokenConfig; use std::os::unix::fs::FileTypeExt; const NERD_FONT_GITHUB_GLYPH: &str = "\u{f408}"; +const CODEX_AUTO_RESUME_PROMPT: &str = "Continue autonomously from where you left off. Do not wait for approval for repository commands, builds, Gradle tasks, or focused tests. Only stop to ask before committing, pushing, commenting on GitHub, or opening or updating a pull request."; pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { let url = Url::parse(target).ok()?; @@ -31,6 +32,13 @@ pub(crate) fn should_request_autonomous_issue_scan(snapshot: &WorkspaceSnapshot) && snapshot.persistent.automation_issue.is_none() } +pub(crate) fn should_auto_resume_autonomous_codex_after_attach( + snapshot: &WorkspaceSnapshot, +) -> bool { + snapshot.persistent.assigned_repository.is_some() + && snapshot.persistent.automation_issue.is_some() +} + impl TuiState { pub(crate) async fn new( config_path: PathBuf, @@ -67,8 +75,10 @@ impl TuiState { selected_link_target_index: 0, mode: UiMode::Normal, create_input: String::new(), - edit_input: String::new(), repository_input: String::new(), + create_field: CreateModalField::Key, + edit_input: String::new(), + issue_input: String::new(), custom_link_input: String::new(), custom_link_kind: None, custom_link_action: None, @@ -185,9 +195,9 @@ impl TuiState { self.mode = UiMode::Normal; self.edit_input.clear(); } - UiMode::EditRepository => { + UiMode::EditIssue => { self.mode = UiMode::Normal; - self.repository_input.clear(); + self.issue_input.clear(); } UiMode::EditCustomLink => { self.mode = UiMode::Normal; @@ -654,6 +664,24 @@ impl TuiState { .and_then(workspace_attach_target) } + fn attach_env_for_workspace(&self, key: &str) -> Vec<(String, String)> { + if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + return Vec::new(); + } + + vec![( + "CODEX_HOME".to_string(), + self.service + .workspace_directory_path() + .join(".multicode") + .join("codex") + .join(key) + .join("home") + .to_string_lossy() + .into_owned(), + )] + } + pub(crate) async fn handle_key( &mut self, terminal: &mut Terminal>, @@ -667,7 +695,7 @@ impl TuiState { UiMode::Normal => self.handle_normal_key(terminal, key).await, UiMode::CreateModal => self.handle_create_modal_key(key).await, UiMode::EditDescription => self.handle_edit_key(key), - UiMode::EditRepository => self.handle_repository_key(key).await, + UiMode::EditIssue => self.handle_issue_key(key).await, UiMode::EditCustomLink => self.handle_custom_link_key(key), UiMode::ConfirmDelete => self.handle_confirm_delete_key(key).await, UiMode::StartingModal => {} @@ -696,15 +724,16 @@ impl TuiState { .unwrap_or_default(); match attach_in_tmux( terminal, - self.service.opencode_command(), + self.service.agent_command(), &target, + &self.attach_env_for_workspace(&key), &key, &custom_description, ) .await { Ok(_) => { - self.status = format!("Detached from workspace '{key}' opencode client"); + self.handle_attach_exit(&key).await; } Err(err) => { self.status = format!("Failed to attach to workspace '{key}': {err}"); @@ -717,6 +746,51 @@ impl TuiState { } } + async fn handle_attach_exit(&mut self, key: &str) { + if self.maybe_resume_autonomous_codex_after_attach(key).await { + return; + } + self.status = format!("Detached from workspace '{key}' agent session"); + } + + async fn maybe_resume_autonomous_codex_after_attach(&mut self, key: &str) -> bool { + if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + return false; + } + + for _ in 0..10 { + self.sync_from_manager(); + let snapshot = self.snapshots.get(key).cloned(); + let Some(snapshot) = snapshot else { + return false; + }; + + if should_auto_resume_autonomous_codex_after_attach(&snapshot) { + match self + .service + .prompt_root_session(&snapshot, CODEX_AUTO_RESUME_PROMPT) + .await + { + Ok(()) => { + self.status = format!( + "Detached from workspace '{key}'; autonomous Codex work was no longer running interactively, so multicode resumed it automatically" + ); + } + Err(err) => { + self.status = format!( + "Detached from workspace '{key}'; autonomous Codex work stopped after attach, but multicode failed to resume it automatically: {err}" + ); + } + } + return true; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + + false + } + pub(crate) fn poll_running_prompt_tool(&mut self) { let completion = match self.running_operation.as_mut() { Some(running_tool) => match running_tool.result_rx.try_recv() { @@ -939,16 +1013,6 @@ impl TuiState { tool_name: &str, prompt: &str, ) { - let Some(opencode_client) = snapshot.opencode_client.as_ref() else { - self.status = format!( - "Tool '{}' requires a started workspace with a healthy client", - tool_name - ); - return; - }; - - let client = opencode_client.client.clone(); - let events = opencode_client.events.clone(); let Some(root_session_id) = snapshot.root_session_id.clone() else { self.status = format!( "Tool '{}' requires a known root session ID for workspace '{}'", @@ -963,11 +1027,33 @@ impl TuiState { let (progress_tx, progress_rx) = watch::channel(format!("Preparing tool '{}'...", tool_name_owned)); let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let snapshot = snapshot.clone(); + let task_tool_name = tool_name_owned.clone(); tokio::spawn(async move { let result = - run_prompt_tool_workflow(client, events, root_session_id, prompt_text, progress_tx) - .await; + if service.agent_provider() == multicode_lib::services::AgentProvider::Opencode { + let Some(opencode_client) = snapshot.opencode_client.as_ref() else { + let _ = result_tx + .send(Err("workspace has no healthy opencode client".to_string())); + return; + }; + run_prompt_tool_workflow( + opencode_client.client.clone(), + opencode_client.events.clone(), + root_session_id, + prompt_text, + progress_tx, + ) + .await + } else { + let _ = progress_tx.send(format!("Starting tool '{}'...", task_tool_name)); + service + .prompt_root_session(&snapshot, &prompt_text) + .await + .map_err(|err| err.to_string()) + }; let _ = result_tx.send(result); }); @@ -1138,6 +1224,8 @@ impl TuiState { } KeyCode::Enter if self.selected_row == 0 => { self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; self.mode = UiMode::CreateModal; } KeyCode::Enter => { @@ -1171,17 +1259,16 @@ impl TuiState { .unwrap_or_default(); match attach_in_tmux( terminal, - self.service.opencode_command(), + self.service.agent_command(), &target, + &self.attach_env_for_workspace(&key), &key, &custom_description, ) .await { Ok(_) => { - self.status = format!( - "Detached from workspace '{key}' opencode client" - ) + self.handle_attach_exit(&key).await; } Err(err) => { self.status = format!( @@ -1294,7 +1381,7 @@ impl TuiState { self.mode = UiMode::EditDescription; } } - KeyCode::Char('g') => { + KeyCode::Char('i') => { if link_selected { return; } @@ -1305,12 +1392,16 @@ impl TuiState { if !workspace_is_usable(snapshot) { return; } - self.repository_input = snapshot + if snapshot.persistent.assigned_repository.is_none() { + return; + } + self.issue_input = snapshot .persistent - .assigned_repository + .automation_issue .clone() + .and_then(|issue| issue.rsplit('/').next().map(ToOwned::to_owned)) .unwrap_or_default(); - self.mode = UiMode::EditRepository; + self.mode = UiMode::EditIssue; } } KeyCode::Char('s') => { @@ -1382,25 +1473,48 @@ impl TuiState { KeyCode::Esc => { self.mode = UiMode::Normal; self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; } KeyCode::Backspace => { - self.create_input.pop(); + self.active_create_modal_input_mut().pop(); } KeyCode::Char(ch) => { - self.create_input.push(ch); + self.active_create_modal_input_mut().push(ch); + } + KeyCode::Tab | KeyCode::Down => { + self.create_field = CreateModalField::Repository; + } + KeyCode::BackTab | KeyCode::Up => { + self.create_field = CreateModalField::Key; } KeyCode::Enter => { let key = self.create_input.trim().to_string(); + let repository = self.repository_input.trim().to_string(); if key.is_empty() { self.status = "Workspace key cannot be empty".to_string(); + self.create_field = CreateModalField::Key; + return; + } + if repository.is_empty() { + self.status = "Repository cannot be empty".to_string(); + self.create_field = CreateModalField::Repository; return; } - match self.service.create_workspace(&key).await { - Ok(_) => { - self.status = format!("Created workspace '{key}'"); + match self + .service + .create_workspace_with_repository(&key, &repository) + .await + { + Ok(normalized_repository) => { + self.status = format!( + "Created workspace '{key}' for repository '{normalized_repository}'" + ); self.mode = UiMode::Normal; self.create_input.clear(); + self.repository_input.clear(); + self.create_field = CreateModalField::Key; self.sync_from_manager(); if let Some(position) = self.ordered_keys.iter().position(|item| item == &key) @@ -1409,7 +1523,7 @@ impl TuiState { } } Err(err) => { - self.status = format!("Failed to create workspace: {err:?}"); + self.status = format!("Failed to create workspace: {}", err.summary()); } } } @@ -1417,6 +1531,13 @@ impl TuiState { } } + fn active_create_modal_input_mut(&mut self) -> &mut String { + match self.create_field { + CreateModalField::Key => &mut self.create_input, + CreateModalField::Repository => &mut self.repository_input, + } + } + fn handle_edit_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -1456,43 +1577,42 @@ impl TuiState { } } - async fn handle_repository_key(&mut self, key: KeyEvent) { + async fn handle_issue_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { self.mode = UiMode::Normal; - self.repository_input.clear(); + self.issue_input.clear(); } KeyCode::Backspace => { - self.repository_input.pop(); + self.issue_input.pop(); } KeyCode::Char(ch) => { - self.repository_input.push(ch); + self.issue_input.push(ch); } KeyCode::Enter => { let Some(key) = self.selected_workspace_key().map(str::to_string) else { return; }; - let repository = self.repository_input.trim().to_string(); - let repository = (!repository.is_empty()).then_some(repository); + let issue = self.issue_input.trim().to_string(); + let issue = (!issue.is_empty()).then_some(issue); match self .service - .assign_workspace_repository(&key, repository.as_deref()) + .assign_workspace_issue(&key, issue.as_deref()) .await { Ok(Some(normalized)) => { - self.status = - format!("Assigned repository '{normalized}' to workspace '{key}'"); + self.status = format!("Assigned issue '{normalized}' to workspace '{key}'"); self.mode = UiMode::Normal; - self.repository_input.clear(); + self.issue_input.clear(); } Ok(None) => { self.status = - format!("Cleared repository assignment for workspace '{key}'"); + format!("Cleared direct issue assignment for workspace '{key}'"); self.mode = UiMode::Normal; - self.repository_input.clear(); + self.issue_input.clear(); } Err(err) => { - self.status = format!("Failed to update repository assignment: {err:?}"); + self.status = format!("Failed to update issue assignment: {err:?}"); } } } diff --git a/tui/src/main.rs b/tui/src/main.rs index 43d8035..40566d6 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -14,7 +14,7 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use multicode_lib::{ - RootSessionStatus, WorkspaceSnapshot, logging, opencode, + AutomationAgentState, RootSessionStatus, WorkspaceSnapshot, logging, opencode, services::{ CombinedService, GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, GithubStatus, ToolConfig, ToolType, @@ -65,8 +65,8 @@ const MACHINE_USAGE_SAMPLE_INTERVAL: Duration = Duration::from_secs(2); const ROOT_SESSION_ATTACH_WAIT_TIMEOUT: Duration = Duration::from_secs(1); const PROMPT_TOOL_IDLE_TIMEOUT: Duration = Duration::from_secs(300); const UI_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(16); -const CREATE_MODAL_WIDTH: u16 = 56; -const CREATE_MODAL_HEIGHT: u16 = 9; +const CREATE_MODAL_WIDTH: u16 = 72; +const CREATE_MODAL_HEIGHT: u16 = 13; const STARTING_MODAL_WIDTH: u16 = 62; const STARTING_MODAL_HEIGHT: u16 = 8; const TOOL_PROGRESS_MODAL_WIDTH: u16 = 72; @@ -81,13 +81,19 @@ enum UiMode { Normal, CreateModal, EditDescription, - EditRepository, + EditIssue, EditCustomLink, ConfirmDelete, StartingModal, ToolProgressModal, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CreateModalField { + Key, + Repository, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CustomLinkModalAction { Add, @@ -111,8 +117,10 @@ struct TuiState { selected_link_target_index: usize, mode: UiMode, create_input: String, - edit_input: String, repository_input: String, + create_field: CreateModalField, + edit_input: String, + issue_input: String, custom_link_input: String, custom_link_kind: Option, custom_link_action: Option, @@ -217,11 +225,17 @@ impl WorkspaceLinkKind { } #[derive(Debug, Clone, PartialEq, Eq)] -struct AttachTarget { - uri: String, - username: String, - password: String, - session_id: Option, +enum AttachTarget { + Opencode { + uri: String, + username: String, + password: String, + session_id: Option, + }, + Codex { + uri: String, + thread_id: Option, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -244,10 +258,16 @@ struct DiskUsage { } fn workspace_state(snapshot: &WorkspaceSnapshot) -> WorkspaceUiState { - match ( - snapshot.transient.is_some(), - snapshot.opencode_client.is_some(), - ) { + let agent_ready = snapshot + .transient + .as_ref() + .and_then(|transient| url::Url::parse(&transient.uri).ok()) + .map(|uri| match uri.scheme() { + "ws" | "wss" => snapshot.root_session_id.is_some(), + _ => snapshot.opencode_client.is_some(), + }) + .unwrap_or(false); + match (snapshot.transient.is_some(), agent_ready) { (false, _) => WorkspaceUiState::Stopped, (true, false) => WorkspaceUiState::Starting, (true, true) => WorkspaceUiState::Started, @@ -283,10 +303,7 @@ fn server_cell_label(snapshot: &WorkspaceSnapshot) -> &'static str { match workspace_state(snapshot) { WorkspaceUiState::Stopped => "", WorkspaceUiState::Starting => "Starting", - WorkspaceUiState::Started => match snapshot - .root_session_status - .unwrap_or(RootSessionStatus::Idle) - { + WorkspaceUiState::Started => match effective_server_status(snapshot) { RootSessionStatus::Idle => "Idle", RootSessionStatus::Busy => "Busy", RootSessionStatus::Question => "Question", @@ -294,6 +311,26 @@ fn server_cell_label(snapshot: &WorkspaceSnapshot) -> &'static str { } } +fn effective_server_status(snapshot: &WorkspaceSnapshot) -> RootSessionStatus { + if snapshot.persistent.automation_issue.is_some() { + if let Some(agent_state) = snapshot.automation_agent_state { + return match agent_state { + AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::Question => RootSessionStatus::Question, + AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale => RootSessionStatus::Idle, + }; + } + if let Some(status) = snapshot.automation_session_status { + return status; + } + } + snapshot + .root_session_status + .unwrap_or(RootSessionStatus::Idle) +} + fn format_tokens_spaced(tokens: u64) -> String { let digits = tokens.to_string(); let mut reversed = String::with_capacity(digits.len() + digits.len() / 3); @@ -783,7 +820,7 @@ fn help_line( selected_link_is_placeholder: bool, selected_link_kind: Option, selected_workspace_has_refreshable_github_link: bool, - selected_workspace_can_assign_repository: bool, + selected_workspace_can_assign_issue: bool, tool_hotkeys: &[(String, String)], status: &str, ) -> Line<'static> { @@ -838,8 +875,8 @@ fn help_line( push_hotkey(&mut spans, "r", " recheck GH status "); } } - if selected_workspace_can_assign_repository { - push_hotkey(&mut spans, "g", " repository "); + if selected_workspace_can_assign_issue { + push_hotkey(&mut spans, "i", " issue "); } push_hotkey(&mut spans, "d", " edit description "); push_hotkey(&mut spans, "x", " delete "); @@ -858,7 +895,8 @@ fn help_line( push_hotkey(&mut spans, "q", " quit"); } UiMode::CreateModal => { - spans.push(Span::raw("Create workspace: type key, ")); + spans.push(Span::raw("Create workspace: type key and repository, ")); + push_hotkey(&mut spans, "Tab", " next field, "); push_hotkey(&mut spans, "Enter", " confirm, "); push_hotkey(&mut spans, "Esc", " cancel"); } @@ -867,8 +905,8 @@ fn help_line( push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Esc", " cancel"); } - UiMode::EditRepository => { - spans.push(Span::raw("Assign repository: type owner/repo or URL, ")); + UiMode::EditIssue => { + spans.push(Span::raw("Assign issue: type number or GitHub issue URL, ")); push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Esc", " cancel"); } diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 5206b8a..2034214 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -39,6 +39,13 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul let mut parsed = Url::parse(uri) .map_err(|err| io::Error::other(format!("workspace attach URI is invalid: {err}")))?; + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(AttachTarget::Codex { + uri: parsed.to_string(), + thread_id: snapshot.root_session_id.clone(), + }); + } + let username = parsed.username().to_string(); if username.is_empty() { return Err(io::Error::other( @@ -57,7 +64,7 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul .set_password(None) .map_err(|_| io::Error::other("failed to sanitize workspace attach URI password"))?; - Ok(AttachTarget { + Ok(AttachTarget::Opencode { uri: parsed.to_string(), username, password, @@ -156,14 +163,34 @@ pub(crate) async fn validate_workspace_link_target( } } -pub(crate) fn attach_cli_args(opencode_command: &str, target: &AttachTarget) -> Vec { - let mut args = vec![opencode_command.to_string(), "attach".to_string()]; - if let Some(session_id) = target.session_id.as_deref() { - args.push("--session".to_string()); - args.push(session_id.to_string()); +pub(crate) fn attach_cli_args(agent_command: &str, target: &AttachTarget) -> Vec { + match target { + AttachTarget::Opencode { + uri, session_id, .. + } => { + let mut args = vec![agent_command.to_string(), "attach".to_string()]; + if let Some(session_id) = session_id.as_deref() { + args.push("--session".to_string()); + args.push(session_id.to_string()); + } + args.push(uri.clone()); + args + } + AttachTarget::Codex { uri, thread_id } => { + let mut args = vec![ + agent_command.to_string(), + "resume".to_string(), + "--remote".to_string(), + uri.clone(), + ]; + // Remote Codex resumes are more reliable when the app-server picks the + // latest thread instead of trusting a cached local snapshot id. + if thread_id.is_some() { + args.push("--last".to_string()); + } + args + } } - args.push(target.uri.clone()); - args } pub(crate) fn tmux_session_command( @@ -181,18 +208,26 @@ pub(crate) fn tmux_session_command( pub(crate) async fn attach_in_tmux( terminal: &mut Terminal>, - opencode_command: &str, + agent_command: &str, target: &AttachTarget, + extra_env: &[(String, String)], workspace_key: &str, custom_description: &str, ) -> io::Result<()> { let original_term = std::env::var("TERM").ok(); - let attach_command = vec![ - format!("OPENCODE_SERVER_USERNAME={}", target.username), - format!("OPENCODE_SERVER_PASSWORD={}", target.password), - ]; - let mut attach_command = tmux_session_command(attach_command, original_term.as_deref()); - attach_command.extend(attach_cli_args(opencode_command, target)); + let mut attach_env = extra_env + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>(); + if let AttachTarget::Opencode { + username, password, .. + } = target + { + attach_env.push(format!("OPENCODE_SERVER_USERNAME={username}")); + attach_env.push(format!("OPENCODE_SERVER_PASSWORD={password}")); + } + let mut attach_command = tmux_session_command(attach_env, original_term.as_deref()); + attach_command.extend(attach_cli_args(agent_command, target)); run_tmux_new_session_command( terminal, &[], @@ -315,9 +350,11 @@ pub(crate) async fn run_tmux_new_session_command( { Ok(status) if status.success() => {} Ok(status) => { - run_error = Some(io::Error::other(format!( - "tmux attach-session exited with status {status}" - ))); + if tmux_session_exists(&session_name).await? { + run_error = Some(io::Error::other(format!( + "tmux attach-session exited with status {status}" + ))); + } } Err(err) => { run_error = Some(err); @@ -424,6 +461,19 @@ pub(crate) async fn set_tmux_session_option( } } +async fn tmux_session_exists(session_name: &str) -> io::Result { + let status = Command::new("tmux") + .arg("has-session") + .arg("-t") + .arg(session_name) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await?; + Ok(status.success()) +} + pub(crate) fn generate_tmux_session_name(workspace_key: &str) -> String { let sanitized: String = workspace_key .chars() diff --git a/tui/src/render.rs b/tui/src/render.rs index 08a04c2..39e2ed8 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -253,18 +253,23 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { || app .selected_workspace_snapshot() .is_some_and(|snapshot| snapshot.persistent.assigned_repository.is_some()), - app.selected_workspace_snapshot() - .is_some_and(workspace_is_usable) - && app.selected_link_index.is_none(), + app.selected_workspace_snapshot().is_some_and(|snapshot| { + workspace_is_usable(snapshot) && snapshot.persistent.assigned_repository.is_some() + }) && app.selected_link_index.is_none(), &app.contextual_tool_hotkeys(), &app.status, ); frame.render_widget(Paragraph::new(help), chunks[1]); if app.mode == UiMode::CreateModal { - draw_create_modal(frame, &app.create_input); - } else if app.mode == UiMode::EditRepository { - draw_repository_modal(frame, &app.repository_input); + draw_create_modal( + frame, + &app.create_input, + &app.repository_input, + app.create_field, + ); + } else if app.mode == UiMode::EditIssue { + draw_issue_modal(frame, &app.issue_input); } else if app.mode == UiMode::EditCustomLink { draw_custom_link_modal( frame, @@ -321,8 +326,14 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } } -fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str) { - let block = Block::default().borders(Borders::ALL); +fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str, active: bool) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(if active { + Style::default().fg(Color::LightBlue) + } else { + Style::default() + }); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget( @@ -330,15 +341,17 @@ fn draw_modal_text_input(frame: &mut Frame, area: Rect, input: &str) { inner, ); - let cursor_offset = input.chars().count() as u16; - let max_offset = inner.width.saturating_sub(1); - frame.set_cursor_position(( - inner.x.saturating_add(cursor_offset.min(max_offset)), - inner.y, - )); + if active { + let cursor_offset = input.chars().count() as u16; + let max_offset = inner.width.saturating_sub(1); + frame.set_cursor_position(( + inner.x.saturating_add(cursor_offset.min(max_offset)), + inner.y, + )); + } } -fn draw_repository_modal(frame: &mut Frame, input: &str) { +fn draw_issue_modal(frame: &mut Frame, input: &str) { let area = centered_rect_fixed( CREATE_MODAL_WIDTH.max(72), CREATE_MODAL_HEIGHT, @@ -346,7 +359,7 @@ fn draw_repository_modal(frame: &mut Frame, input: &str) { ); frame.render_widget(Clear, area); let block = Block::default() - .title(" Assign repository ") + .title(" Assign issue ") .borders(Borders::ALL); let inner = block.inner(area); frame.render_widget(block, area); @@ -355,11 +368,11 @@ fn draw_repository_modal(frame: &mut Frame, input: &str) { .constraints([Constraint::Length(2), Constraint::Length(3)]) .split(inner); frame.render_widget( - Paragraph::new("GitHub repository (owner/repo or URL). Leave empty to clear.") + Paragraph::new("Issue number or GitHub issue URL. Leave empty to clear.") .wrap(Wrap { trim: true }), vertical[0], ); - draw_modal_text_input(frame, vertical[1], input); + draw_modal_text_input(frame, vertical[1], input, true); } pub(crate) fn selected_link_tooltip_area( @@ -493,7 +506,12 @@ fn status_icon_cell(kind: StatusIconKind, color: Color, reversed: bool) -> Cell< Cell::from(format!("{} ", icon_glyph(kind))).style(style) } -pub(crate) fn draw_create_modal(frame: &mut Frame, input: &str) { +pub(crate) fn draw_create_modal( + frame: &mut Frame, + key_input: &str, + repository_input: &str, + active_field: CreateModalField, +) { let area = centered_rect_fixed(CREATE_MODAL_WIDTH, CREATE_MODAL_HEIGHT, frame.area()); frame.render_widget(Clear, area); @@ -507,26 +525,40 @@ pub(crate) fn draw_create_modal(frame: &mut Frame, input: &str) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Fill(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), - Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), ]) .split(inner); frame.render_widget( - Paragraph::new("Enter a workspace key") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)), + Paragraph::new("Workspace key").style(Style::default().fg(Color::DarkGray)), + rows[0], + ); + draw_modal_text_input( + frame, rows[1], + key_input, + active_field == CreateModalField::Key, ); - draw_modal_text_input(frame, rows[2], input); frame.render_widget( - Paragraph::new("Enter to create Β· Esc to cancel") - .alignment(Alignment::Center) + Paragraph::new("GitHub repository (owner/repo or URL)") .style(Style::default().fg(Color::DarkGray)), + rows[2], + ); + draw_modal_text_input( + frame, rows[3], + repository_input, + active_field == CreateModalField::Repository, + ); + frame.render_widget( + Paragraph::new("Tab to switch fields Β· Enter to create Β· Esc to cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)), + rows[4], ); } @@ -621,7 +653,7 @@ pub(crate) fn draw_custom_link_modal( .style(Style::default().fg(Color::DarkGray)), rows[1], ); - draw_modal_text_input(frame, rows[2], input); + draw_modal_text_input(frame, rows[2], input, true); frame.render_widget( Paragraph::new(footer) .alignment(Alignment::Center) diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 5484197..3cfa6af 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -2,10 +2,13 @@ use crate::*; #[cfg(test)] mod tests { - use multicode_lib::services::HandlerConfig; + use multicode_lib::{AutomationAgentState, services::HandlerConfig}; use super::*; - use crate::app::{compact_github_tooltip_target, starting_modal_failure_status}; + use crate::app::{ + compact_github_tooltip_target, should_auto_resume_autonomous_codex_after_attach, + starting_modal_failure_status, + }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, pr_review_icon_color, @@ -83,6 +86,9 @@ mod tests { root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, usage_total_tokens: None, @@ -108,6 +114,9 @@ mod tests { root_session_id: None, root_session_title: None, root_session_status: None, + automation_session_id: None, + automation_session_status: None, + automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, usage_total_tokens: None, @@ -140,6 +149,27 @@ mod tests { )); } + #[test] + fn auto_resume_after_attach_requires_autonomous_issue_workspace() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.assigned_repository = Some("example/repo".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Question); + + assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.root_session_status = Some(RootSessionStatus::Idle); + assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.root_session_status = Some(RootSessionStatus::Busy); + assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.root_session_status = Some(RootSessionStatus::Question); + snapshot.persistent.automation_issue = None; + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + } + #[test] fn workspace_attach_target_requires_started_state() { let err = workspace_attach_target(&snapshot(false, Some("http://example"))) @@ -167,7 +197,7 @@ mod tests { .expect("started workspace should expose attach target with auth"); assert_eq!( target, - AttachTarget { + AttachTarget::Opencode { uri: "http://127.0.0.1:3000/".to_string(), username: "opencode".to_string(), password: "secret".to_string(), @@ -184,12 +214,20 @@ mod tests { let target = workspace_attach_target(&started) .expect("started workspace should expose attach target with latest root session id"); - assert_eq!(target.session_id.as_deref(), Some("ses-root-latest")); + assert_eq!( + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-root-latest".to_string()), + } + ); } #[test] fn attach_cli_args_appends_session_when_present() { - let target = AttachTarget { + let target = AttachTarget::Opencode { uri: "http://127.0.0.1:3000/".to_string(), username: "opencode".to_string(), password: "secret".to_string(), @@ -210,7 +248,7 @@ mod tests { #[test] fn attach_cli_args_omits_session_when_unavailable() { - let target = AttachTarget { + let target = AttachTarget::Opencode { uri: "http://127.0.0.1:3000/".to_string(), username: "opencode".to_string(), password: "secret".to_string(), @@ -227,6 +265,42 @@ mod tests { ); } + #[test] + fn workspace_attach_target_uses_codex_variant_for_websocket_uri() { + let mut started = snapshot(false, Some("ws://127.0.0.1:3456")); + started.root_session_id = Some("thread-123".to_string()); + + let target = workspace_attach_target(&started) + .expect("codex workspace should expose websocket attach target"); + + assert_eq!( + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456".to_string(), + thread_id: Some("thread-123".to_string()), + } + ); + } + + #[test] + fn attach_cli_args_use_codex_resume_for_codex_target() { + let target = AttachTarget::Codex { + uri: "ws://127.0.0.1:3456".to_string(), + thread_id: Some("thread-123".to_string()), + }; + + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "resume".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "--last".to_string(), + ] + ); + } + #[test] fn tmux_session_command_restores_original_term_inside_session() { let command = vec!["opencode".to_string(), "attach".to_string()]; @@ -1131,8 +1205,9 @@ mod tests { } #[test] - fn help_line_shows_repository_hotkey_for_usable_workspace_row_focus() { - let started = snapshot(true, Some("http://example")); + fn help_line_shows_issue_hotkey_for_workspace_with_assigned_repository() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = Some("example/repo".to_string()); let line = help_line( UiMode::Normal, 1, @@ -1154,7 +1229,34 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("g repository")); + assert!(text.contains("i issue")); + } + + #[test] + fn help_line_hides_issue_hotkey_without_assigned_repository() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + 0, + None, + false, + false, + None, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(!text.contains("i issue")); } #[test] @@ -1522,6 +1624,26 @@ mod tests { assert_eq!(server_cell_label(&started), "Question"); } + #[test] + fn server_cell_label_uses_automation_question_state() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + started.automation_agent_state = Some(AutomationAgentState::Question); + + assert_eq!(server_cell_label(&started), "Question"); + } + + #[test] + fn server_cell_label_uses_automation_review_state_as_idle() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + started.automation_agent_state = Some(AutomationAgentState::Review); + + assert_eq!(server_cell_label(&started), "Idle"); + } + #[test] fn description_cell_text_appends_root_session_title_after_description() { let mut started = snapshot(true, Some("http://example")); diff --git a/workspace-skills/autonomous-state/SKILL.md b/workspace-skills/autonomous-state/SKILL.md new file mode 100644 index 0000000..cd4d754 --- /dev/null +++ b/workspace-skills/autonomous-state/SKILL.md @@ -0,0 +1,32 @@ +--- +name: autonomous-state +description: Maintain the multicode autonomous state file while working autonomously so the host can detect working, question, review, idle, and stalled states. +--- + +When operating in a multicode autonomous workspace, the environment variable `MULTICODE_AUTONOMOUS_STATE_PATH` +points to a writable state file owned by multicode. You must keep this file updated. + +Write exactly one line to that file: + +- `working` +- `question` +- `review` +- `idle` + +Use shell commands like: + +```sh +mkdir -p "$(dirname "$MULTICODE_AUTONOMOUS_STATE_PATH")" +printf '%s\n' working > "$MULTICODE_AUTONOMOUS_STATE_PATH" +``` + +Required workflow: + +- As soon as you begin autonomous work, write `working`. +- Before any potentially long-running investigation, edit, build, or test step, write `working` again to refresh the heartbeat. +- If you need human input, approval, clarification, or are blocked waiting for a response, write `question` before stopping. +- When the change is ready for human review or publish approval, write `review` before you stop. +- Only write `idle` if the issue is fully complete and no further action is pending. +- After resuming from an interruption, attach, or restart, immediately write the current state again before continuing. + +Do not write anything except the single state word to this file. From b132b1d878f1b8b0ac40e534a561197fcae98469 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 12:37:20 +0200 Subject: [PATCH 08/75] Avoid resuming Codex from review state on attach Only auto-resume autonomous Codex work after detaching from an attach session when the workspace was still actively working. Do not send a synthetic resume prompt when the autonomous state is review, question, idle, or stale, so a workspace that is waiting for human review or publish approval is not kicked back into execution on reattach. Add focused TUI coverage for the attach auto-resume decision. Co-Authored-By: Codex --- tui/src/app.rs | 21 +++++++++++++++++++-- tui/src/tests.rs | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 3e29153..83e48b6 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -35,8 +35,25 @@ pub(crate) fn should_request_autonomous_issue_scan(snapshot: &WorkspaceSnapshot) pub(crate) fn should_auto_resume_autonomous_codex_after_attach( snapshot: &WorkspaceSnapshot, ) -> bool { - snapshot.persistent.assigned_repository.is_some() - && snapshot.persistent.automation_issue.is_some() + if snapshot.persistent.assigned_repository.is_none() + || snapshot.persistent.automation_issue.is_none() + { + return false; + } + + match snapshot.automation_agent_state { + Some(AutomationAgentState::Working) => true, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale, + ) => false, + None => matches!( + snapshot.automation_session_status.or(snapshot.root_session_status), + Some(RootSessionStatus::Busy) + ), + } } impl TuiState { diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 3cfa6af..f2f29a2 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -150,23 +150,30 @@ mod tests { } #[test] - fn auto_resume_after_attach_requires_autonomous_issue_workspace() { + fn auto_resume_after_attach_only_resumes_active_autonomous_work() { let mut snapshot = WorkspaceSnapshot::default(); snapshot.persistent.assigned_repository = Some("example/repo".to_string()); snapshot.persistent.automation_issue = Some("https://github.com/example/repo/issues/42".to_string()); - snapshot.root_session_status = Some(RootSessionStatus::Question); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); - snapshot.root_session_status = Some(RootSessionStatus::Idle); - assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); + snapshot.automation_agent_state = Some(AutomationAgentState::Review); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + + snapshot.automation_agent_state = Some(AutomationAgentState::Question); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + snapshot.automation_agent_state = None; snapshot.root_session_status = Some(RootSessionStatus::Busy); assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); - snapshot.root_session_status = Some(RootSessionStatus::Question); + snapshot.root_session_status = Some(RootSessionStatus::Idle); + assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); + snapshot.persistent.automation_issue = None; + snapshot.root_session_status = Some(RootSessionStatus::Busy); assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); } From deefa019767bfde93b479cf68e9b301a438372e4 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 12:41:13 +0200 Subject: [PATCH 09/75] Normalize TERM for Apple PTY shells Force Apple-container PTY sessions to use a portable terminal definition so interactive bash sessions opened from the TUI do not inherit unsupported host terminal types inside the container. Set TERM to xterm-256color and provide COLORTERM=truecolor for the generated exec env used by both one-shot PTY runs and container exec reuse. Add focused runtime tests covering both Apple PTY code paths. Co-Authored-By: Codex --- lib/src/services/runtime.rs | 51 +++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index edb09e6..25ef217 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -981,6 +981,7 @@ impl AppleContainerRuntime { ), })?; let mut env = inherited_env.to_vec(); + ensure_pty_terminal_env(&mut env); let host_gitconfig = self.host_gitconfig_path_for_env(&env); self.append_implicit_env(&mut env, host_gitconfig.as_deref()) .await?; @@ -1017,6 +1018,7 @@ impl AppleContainerRuntime { command: Vec, ) -> Result { let mut env = inherited_env.to_vec(); + ensure_pty_terminal_env(&mut env); let host_gitconfig = self.host_gitconfig_path_for_env(&env); self.append_implicit_env(&mut env, host_gitconfig.as_deref()) .await?; @@ -1455,6 +1457,21 @@ impl AppleContainerRuntime { } } +fn ensure_pty_terminal_env(env: &mut Vec<(String, String)>) { + upsert_env(env, "TERM", "xterm-256color"); + if env.iter().all(|(name, _)| name != "COLORTERM") { + env.push(("COLORTERM".to_string(), "truecolor".to_string())); + } +} + +fn upsert_env(env: &mut Vec<(String, String)>, name: &str, value: &str) { + if let Some((_, current)) = env.iter_mut().find(|(candidate, _)| candidate == name) { + *current = value.to_string(); + } else { + env.push((name.to_string(), value.to_string())); + } +} + fn format_path_list(paths: &[PathBuf]) -> String { paths .iter() @@ -2332,7 +2349,12 @@ mod tests { &command.args, &["run", "--rm", "--tty", "--interactive"] )); - assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + let env_file = command + .args + .iter() + .find(|arg| arg.ends_with("exec.env")) + .expect("exec env file should be present") + .clone(); assert!( command .args @@ -2340,6 +2362,16 @@ mod tests { .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") ); assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + let env_contents = + fs::read_to_string(env_file).expect("exec env file should be written"); + assert!( + env_contents.contains("TERM=xterm-256color\n"), + "apple PTY runs should normalize TERM for container shells" + ); + assert!( + env_contents.contains("COLORTERM=truecolor\n"), + "apple PTY runs should set a portable COLORTERM for container shells" + ); assert!(command.inherited_env.is_empty()); }); } @@ -2380,7 +2412,12 @@ mod tests { &command.args, &["exec", "--tty", "--interactive", "--env-file",] )); - assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + let env_file = command + .args + .iter() + .find(|arg| arg.ends_with("exec.env")) + .expect("exec env file should be present") + .clone(); assert!(contains_sequence( &command.args, &[ @@ -2394,6 +2431,16 @@ mod tests { !command.args.iter().any(|arg| arg == "run"), "running workspaces should reuse the active container" ); + let env_contents = + fs::read_to_string(env_file).expect("exec env file should be written"); + assert!( + env_contents.contains("TERM=xterm-256color\n"), + "apple PTY exec should normalize TERM for container shells" + ); + assert!( + env_contents.contains("COLORTERM=truecolor\n"), + "apple PTY exec should set a portable COLORTERM for container shells" + ); assert!(command.inherited_env.is_empty()); }); } From de1e32eeef1a1600c5339e4bd4bb781f7f94b926 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 12:46:24 +0200 Subject: [PATCH 10/75] Report Apple container CPU and RAM usage Implement Apple-container runtime usage sampling through the local container stats command so multicode can populate the CPU and RAM columns for macOS-backed workspaces. Parse one-shot JSON stats output, map memory usage directly, and convert cumulative CPU usage from microseconds into the nanosecond counter format already used by the shared resource usage service. Add focused parser tests covering normal stats output, empty results, and invalid JSON. Co-Authored-By: Codex --- lib/src/services/runtime.rs | 105 ++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index 25ef217..8947d7a 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -5,6 +5,7 @@ use std::{ sync::OnceLock, }; +use serde::Deserialize; use tokio::{process::Command, sync::Mutex}; use uuid::Uuid; @@ -1088,10 +1089,35 @@ impl AppleContainerRuntime { } async fn read_usage(_runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { - RuntimeUsageSample { - state: Some(RuntimeUsageState::Unknown), - ..Default::default() + let output = match run_blocking_process( + container_program(), + vec![ + "stats".to_string(), + "--format".to_string(), + "json".to_string(), + "--no-stream".to_string(), + _runtime_handle.id.clone(), + ], + ) + .await + { + Ok(output) => output, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + if !output.status.success() { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; } + + parse_apple_container_usage(&String::from_utf8_lossy(&output.stdout)) } async fn build_run_command( @@ -1480,6 +1506,41 @@ fn format_path_list(paths: &[PathBuf]) -> String { .join(",") } +#[derive(Debug, Clone, Deserialize)] +struct AppleContainerStatsEntry { + #[serde(rename = "memoryUsageBytes")] + memory_usage_bytes: Option, + #[serde(rename = "cpuUsageUsec")] + cpu_usage_usec: Option, +} + +fn parse_apple_container_usage(output: &str) -> RuntimeUsageSample { + let entries = match serde_json::from_str::>(output) { + Ok(entries) => entries, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + let Some(entry) = entries.into_iter().next() else { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + }; + + RuntimeUsageSample { + memory_current: entry.memory_usage_bytes, + cpu_usage_nsec: entry + .cpu_usage_usec + .map(|value| value.saturating_mul(1_000)), + state: Some(RuntimeUsageState::Active), + } +} + fn format_skill_mounts(skills: &[super::config::AddedSkillMount]) -> String { let mut pairs = skills .iter() @@ -2792,4 +2853,42 @@ mod tests { ); }); } + + #[test] + fn parse_apple_container_usage_reads_memory_and_cpu() { + let output = r#"[{"memoryUsageBytes":4075261952,"cpuUsageUsec":437059128}]"#; + + assert_eq!( + parse_apple_container_usage(output), + RuntimeUsageSample { + memory_current: Some(4_075_261_952), + cpu_usage_nsec: Some(437_059_128_000), + state: Some(RuntimeUsageState::Active), + } + ); + } + + #[test] + fn parse_apple_container_usage_reports_stopped_for_empty_results() { + assert_eq!( + parse_apple_container_usage("[]"), + RuntimeUsageSample { + memory_current: None, + cpu_usage_nsec: None, + state: Some(RuntimeUsageState::Stopped), + } + ); + } + + #[test] + fn parse_apple_container_usage_reports_unknown_for_invalid_json() { + assert_eq!( + parse_apple_container_usage("not-json"), + RuntimeUsageSample { + memory_current: None, + cpu_usage_nsec: None, + state: Some(RuntimeUsageState::Unknown), + } + ); + } } From a95b8543878c65f160559b785ff32e09f9079f31 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 13:20:08 +0200 Subject: [PATCH 11/75] Add workspace compare shortcut Add a VS Code compare action for workspace rows and only surface the hotkey when a usable repository root can be resolved. Compare now prefers validated review metadata and falls back to the assigned repository checkout or autonomous issue worktree inside the workspace, so live workspaces without explicit review links still open on the correct repo root. Add tests covering review-path precedence, workspace fallback resolution, and the compare hotkey visibility path. Co-authored-by: Codex --- tui/src/app.rs | 78 +++++++++++++++++++++++++- tui/src/main.rs | 51 +++++++++++++++++ tui/src/ops.rs | 125 ++++++++++++++++++++++++++++++++++++++++++ tui/src/render.rs | 1 + tui/src/tests.rs | 136 +++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 389 insertions(+), 2 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 83e48b6..abc826e 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -50,7 +50,9 @@ pub(crate) fn should_auto_resume_autonomous_codex_after_attach( | AutomationAgentState::Stale, ) => false, None => matches!( - snapshot.automation_session_status.or(snapshot.root_session_status), + snapshot + .automation_session_status + .or(snapshot.root_session_status), Some(RootSessionStatus::Busy) ), } @@ -293,6 +295,28 @@ impl TuiState { .and_then(|key| self.snapshots.get(key)) } + pub(crate) fn selected_workspace_can_compare(&self) -> bool { + if self.selected_link_index.is_some() { + return false; + } + + self.selected_workspace_snapshot() + .is_some_and(workspace_is_usable) + && self.selected_workspace_compare_target_path().is_some() + && vscode_is_available() + } + + fn selected_workspace_compare_target_path(&self) -> Option { + let key = self.selected_workspace_key()?; + let snapshot = self.snapshots.get(key)?; + let workspace_path = self.service.workspace_directory_path().join(key); + compare_target_path( + snapshot, + &self.workspace_link_validation_results, + &workspace_path, + ) + } + fn selected_workspace_link_targets(&self) -> Vec<(WorkspaceLink, String)> { let Some(link) = self.selected_workspace_link() else { return Vec::new(); @@ -1378,6 +1402,58 @@ impl TuiState { self.status = format!("{} workspace '{}'", operation_name, key); } } + KeyCode::Char('c') => { + if link_selected { + return; + } + if let Some(key) = self.selected_workspace_key().map(str::to_string) { + let Some(snapshot) = self.snapshots.get(&key) else { + return; + }; + if !workspace_is_usable(snapshot) { + self.status = format!("Workspace '{key}' is archived and cannot be opened"); + return; + } + let Some(repo_path) = self.selected_workspace_compare_target_path() else { + self.status = + format!("Workspace '{key}' does not have a repository to compare"); + return; + }; + + match write_compare_preview(&repo_path, &key).await { + Ok((program, args)) => { + tracing::info!( + command = %format_command_line(&program, &args), + workspace = %key, + repo = %repo_path.display(), + "opening workspace compare in vscode" + ); + let mut command = Command::new(&program); + match command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(_) => { + self.status = + format!("Opened compare for workspace '{key}' in VS Code"); + } + Err(err) => { + self.status = format!( + "Failed to open compare for workspace '{key}': {err}" + ); + } + } + } + Err(err) => { + self.status = + format!("Failed to open compare for workspace '{key}': {err}"); + } + } + } + } KeyCode::Char('d') => { if link_selected { if let Some(link) = self diff --git a/tui/src/main.rs b/tui/src/main.rs index 40566d6..c72045a 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -650,6 +650,53 @@ fn validated_workspace_links_by_kind( .collect() } +fn compare_target_path( + snapshot: &WorkspaceSnapshot, + validations: &HashMap, + workspace_path: &Path, +) -> Option { + first_validated_workspace_link_by_kind(snapshot, validations, WorkspaceLinkKind::Review) + .and_then(|link| match validations.get(&link) { + Some(WorkspaceLinkValidationResult::Valid(path)) => Some(PathBuf::from(path)), + _ => None, + }) + .or_else(|| compare_target_path_from_workspace(snapshot, workspace_path)) +} + +fn compare_target_path_from_workspace( + snapshot: &WorkspaceSnapshot, + workspace_path: &Path, +) -> Option { + let repo_name = snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(|repository| repository.rsplit('/').next()) + .filter(|segment| !segment.is_empty()); + let issue_number = snapshot + .persistent + .automation_issue + .as_deref() + .and_then(|issue| issue.rsplit('/').next()) + .filter(|segment| !segment.is_empty()); + + let mut candidates = Vec::new(); + if let (Some(repo_name), Some(issue_number)) = (repo_name, issue_number) { + candidates.push( + workspace_path + .join("work") + .join(format!("{repo_name}-{issue_number}")), + ); + } + if let Some(repo_name) = repo_name { + candidates.push(workspace_path.join(repo_name)); + } + + candidates + .into_iter() + .find(|candidate| candidate.join(".git").is_dir()) +} + fn visible_workspace_links( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -821,6 +868,7 @@ fn help_line( selected_link_kind: Option, selected_workspace_has_refreshable_github_link: bool, selected_workspace_can_assign_issue: bool, + selected_workspace_can_compare: bool, tool_hotkeys: &[(String, String)], status: &str, ) -> Line<'static> { @@ -878,6 +926,9 @@ fn help_line( if selected_workspace_can_assign_issue { push_hotkey(&mut spans, "i", " issue "); } + if selected_workspace_can_compare { + push_hotkey(&mut spans, "c", " compare "); + } push_hotkey(&mut spans, "d", " edit description "); push_hotkey(&mut spans, "x", " delete "); let archive_action = if snapshot.persistent.archived { diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 2034214..7dd88b6 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -2,6 +2,131 @@ use crate::*; use std::os::unix::fs::PermissionsExt; use std::path::Path; +fn vscode_command_candidates() -> Vec { + let mut candidates = vec![PathBuf::from( + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + )]; + if let Some(home) = std::env::var_os("HOME") { + candidates.push( + PathBuf::from(home) + .join("Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"), + ); + } + candidates +} + +pub(crate) fn vscode_command_path() -> Option { + if command_exists("code") { + return Some(PathBuf::from("code")); + } + + vscode_command_candidates() + .into_iter() + .find(|candidate| command_exists(candidate.to_string_lossy().as_ref())) +} + +pub(crate) fn vscode_is_available() -> bool { + vscode_command_path().is_some() +} + +pub(crate) fn vscode_open_command(paths: &[PathBuf]) -> io::Result<(String, Vec)> { + let program = vscode_command_path().ok_or_else(|| { + io::Error::other("VS Code is not installed or the 'code' CLI is unavailable") + })?; + + let mut args = vec!["--reuse-window".to_string()]; + args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned())); + + Ok((program.to_string_lossy().into_owned(), args)) +} + +pub(crate) async fn write_compare_preview( + repo_path: &Path, + workspace_key: &str, +) -> io::Result<(String, Vec)> { + let diff = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["diff", "--no-ext-diff", "--stat", "--patch", "HEAD", "--"]) + .output() + .await?; + if !diff.status.success() { + let stderr = String::from_utf8_lossy(&diff.stderr).trim().to_string(); + return Err(io::Error::other(format!( + "git diff failed{}", + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ))); + } + + let untracked = Command::new("git") + .arg("-C") + .arg(repo_path) + .args(["ls-files", "--others", "--exclude-standard"]) + .output() + .await?; + if !untracked.status.success() { + let stderr = String::from_utf8_lossy(&untracked.stderr) + .trim() + .to_string(); + return Err(io::Error::other(format!( + "git ls-files failed{}", + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ))); + } + + let diff_text = String::from_utf8_lossy(&diff.stdout).into_owned(); + let untracked_text = String::from_utf8_lossy(&untracked.stdout).into_owned(); + let mut preview = String::new(); + if !diff_text.trim().is_empty() { + preview.push_str(&diff_text); + if !preview.ends_with('\n') { + preview.push('\n'); + } + } + if !untracked_text.trim().is_empty() { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str("Untracked files:\n"); + for line in untracked_text + .lines() + .filter(|line| !line.trim().is_empty()) + { + preview.push_str(" "); + preview.push_str(line); + preview.push('\n'); + } + } + + let mut paths = vec![repo_path.to_path_buf()]; + if !preview.trim().is_empty() { + let sanitized_key: String = workspace_key + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '-' + } + }) + .collect(); + let preview_path = + std::env::temp_dir().join(format!("multicode-compare-{sanitized_key}.diff")); + tokio::fs::write(&preview_path, preview).await?; + paths.push(preview_path); + } + + vscode_open_command(&paths) +} + pub(crate) fn shell_escape_arg(arg: &str) -> String { if arg.is_empty() { "''".to_string() diff --git a/tui/src/render.rs b/tui/src/render.rs index 39e2ed8..cb07462 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -256,6 +256,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.selected_workspace_snapshot().is_some_and(|snapshot| { workspace_is_usable(snapshot) && snapshot.persistent.assigned_repository.is_some() }) && app.selected_link_index.is_none(), + app.selected_workspace_can_compare(), &app.contextual_tool_hotkeys(), &app.status, ); diff --git a/tui/src/tests.rs b/tui/src/tests.rs index f2f29a2..2abb702 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -283,7 +283,7 @@ mod tests { assert_eq!( target, AttachTarget::Codex { - uri: "ws://127.0.0.1:3456".to_string(), + uri: "ws://127.0.0.1:3456/".to_string(), thread_id: Some("thread-123".to_string()), } ); @@ -678,6 +678,71 @@ mod tests { assert!(visible[2].value.is_empty()); } + #[test] + fn compare_target_path_prefers_validated_review_repo() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + let workspace = TestDir::new(); + + let all_links = workspace_links(&started); + let mut validations = HashMap::new(); + validations.insert( + all_links[0].clone(), + WorkspaceLinkValidationResult::Valid("/tmp/repo-a".to_string()), + ); + + assert_eq!( + compare_target_path(&started, &validations, workspace.path()), + Some(PathBuf::from("/tmp/repo-a")) + ); + } + + #[test] + fn compare_target_path_falls_back_to_issue_worktree() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + started.persistent.automation_issue = Some( + "https://github.com/micronaut-projects/micronaut-serialization/issues/921".to_string(), + ); + let workspace = TestDir::new(); + let repo_path = workspace + .path() + .join("work/micronaut-serialization-921/.git"); + fs::create_dir_all(&repo_path).expect("issue worktree repo should be created"); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + Some(workspace.path().join("work/micronaut-serialization-921")) + ); + } + + #[test] + fn compare_target_path_falls_back_to_assigned_repository_root() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.assigned_repository = + Some("micronaut-projects/micronaut-serialization".to_string()); + let workspace = TestDir::new(); + let repo_path = workspace.path().join("micronaut-serialization/.git"); + fs::create_dir_all(&repo_path).expect("assigned repo root should be created"); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + Some(workspace.path().join("micronaut-serialization")) + ); + } + + #[test] + fn compare_target_path_is_none_without_review_repo_or_workspace_repo() { + let started = snapshot(true, Some("http://example")); + let workspace = TestDir::new(); + + assert_eq!( + compare_target_path(&started, &HashMap::new(), workspace.path()), + None + ); + } + #[test] fn selectable_workspace_links_include_custom_issue_and_pr_without_github_status() { let mut started = snapshot(true, Some("http://example")); @@ -1007,6 +1072,7 @@ mod tests { Some(WorkspaceLinkKind::Issue), true, false, + false, no_tool_hotkeys(), "", ); @@ -1042,6 +1108,7 @@ mod tests { Some(WorkspaceLinkKind::Issue), true, false, + false, no_tool_hotkeys(), "", ); @@ -1070,6 +1137,7 @@ mod tests { Some(WorkspaceLinkKind::Issue), true, false, + false, no_tool_hotkeys(), "", ); @@ -1100,6 +1168,7 @@ mod tests { None, true, false, + false, no_tool_hotkeys(), "", ); @@ -1122,6 +1191,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1147,6 +1217,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1176,6 +1247,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1199,6 +1271,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1227,6 +1300,7 @@ mod tests { None, false, true, + false, no_tool_hotkeys(), "", ); @@ -1254,6 +1328,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1281,6 +1356,7 @@ mod tests { None, false, true, + false, no_tool_hotkeys(), "", ); @@ -1293,6 +1369,56 @@ mod tests { assert!(text.contains("x delete")); } + #[test] + fn help_line_shows_compare_hotkey_only_when_enabled() { + let started = snapshot(true, Some("http://example")); + let enabled_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + 0, + None, + false, + false, + None, + false, + false, + true, + no_tool_hotkeys(), + "", + ); + let enabled_text = enabled_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(enabled_text.contains("c compare")); + + let disabled_line = help_line( + UiMode::Normal, + 1, + 1, + Some(&started), + 0, + None, + false, + false, + None, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let disabled_text = disabled_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(!disabled_text.contains("c compare")); + } + #[test] fn help_line_shows_starting_message_in_starting_modal() { let line = help_line( @@ -1307,6 +1433,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1333,6 +1460,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1409,6 +1537,7 @@ mod tests { None, false, false, + false, &tool_hotkeys, "", ); @@ -1570,6 +1699,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1594,6 +1724,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1901,6 +2032,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1926,6 +2058,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); @@ -1950,6 +2083,7 @@ mod tests { None, false, false, + false, no_tool_hotkeys(), "", ); From f39c528d2a517da7dee604937571bda41acdda1c Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 13:21:05 +0200 Subject: [PATCH 12/75] Format autonomous workspace service Apply the remaining formatting-only change in the autonomous workspace service so the worktree is clean. Co-authored-by: Codex --- lib/src/services/autonomous_workspace_service.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 1b1b031..86304ac 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -176,7 +176,9 @@ async fn watch_workspace( let current_issue_url = snapshot.persistent.automation_issue.clone(); if let Some(current_issue_url) = current_issue_url { - let root_status = snapshot.root_session_status.unwrap_or(RootSessionStatus::Idle); + let root_status = snapshot + .root_session_status + .unwrap_or(RootSessionStatus::Idle); let should_resume_assigned_issue = matches!(root_status, RootSessionStatus::Idle) && (scan_requested || (previous_root_status.is_none() From be7a99a4940f6316b3e95cffc46ad6c94c1d8394 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sat, 11 Apr 2026 16:48:15 +0200 Subject: [PATCH 13/75] Pin Apple Codex image version Pin the Apple-container Codex image to Codex CLI 0.120 instead of taking the latest npm release at build time. Allow build-local.sh to override the pinned version through CODEX_VERSION so testing an intentional upgrade does not require editing the Containerfile. Document the pinned build and the override flow in the README. Co-Authored-By: Codex --- README.md | 11 +++++++++++ apple-container/Containerfile | 3 ++- apple-container/build-local.sh | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7b27845..d466b48 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,17 @@ To build both local Apple-container images from this repository: ./apple-container/build-local.sh ``` +The Codex image build pins the installed Codex CLI to the version declared in +[`apple-container/Containerfile`](/Users/graemerocher/dev/micronaut/multicode/apple-container/Containerfile) +via `CODEX_VERSION` so container behavior stays reproducible across rebuilds. Update that build arg +when you intentionally want to move the image to a newer Codex release. + +You can also override the pinned version at build time without editing the file: + +```bash +CODEX_VERSION=0.120 ./apple-container/build-local.sh +``` + That script produces: - `multicode-java25:latest` and `multicode-opencode-java25:latest` for the OpenCode workflow diff --git a/apple-container/Containerfile b/apple-container/Containerfile index f522f28..2bf2656 100644 --- a/apple-container/Containerfile +++ b/apple-container/Containerfile @@ -5,6 +5,7 @@ FROM ghcr.io/graalvm/native-image-community:25 ARG HOST_UID=1000 ARG HOST_GID=1000 ARG GH_VERSION=2.83.2 +ARG CODEX_VERSION=0.120 ARG INSTALL_OPENCODE=1 ARG INSTALL_CODEX=0 @@ -41,7 +42,7 @@ RUN set -eux; \ npm install -g opencode-ai; \ fi; \ if [ "${INSTALL_CODEX}" = "1" ]; then \ - npm install -g @openai/codex; \ + npm install -g "@openai/codex@${CODEX_VERSION}"; \ fi; \ if ! getent group "${HOST_GID}" >/dev/null; then \ groupadd --gid "${HOST_GID}" multicode; \ diff --git a/apple-container/build-local.sh b/apple-container/build-local.sh index dde93eb..a299f7e 100755 --- a/apple-container/build-local.sh +++ b/apple-container/build-local.sh @@ -4,6 +4,7 @@ set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) HOST_UID=$(id -u) HOST_GID=$(id -g) +CODEX_VERSION=${CODEX_VERSION:-0.120} container build \ -t multicode-java25:latest \ @@ -20,6 +21,7 @@ exec container build \ -f "$SCRIPT_DIR/Containerfile" \ --build-arg "HOST_UID=$HOST_UID" \ --build-arg "HOST_GID=$HOST_GID" \ + --build-arg "CODEX_VERSION=$CODEX_VERSION" \ --build-arg "INSTALL_OPENCODE=0" \ --build-arg "INSTALL_CODEX=1" \ "$SCRIPT_DIR" From be8bae7d4b89ed172b4dd4fa88e4352a0de7c952 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Sun, 12 Apr 2026 13:56:03 +0200 Subject: [PATCH 14/75] Support for multiple tasks per VM for multicode Add multi-task autonomous scheduling so a workspace acts as a VM/repository summary while issue work moves into child task rows backed by dedicated worktrees and per-task runtime state. Improve Codex task orchestration by recovering task sessions from Codex state, reconciling runtime state per task, tracking machine-readable issue/PR metadata on task threads, and fixing Waiting on VM versus Busy transitions for non-active tasks. Update the TUI to support task rows as first-class entries with task-specific attach, compare, delete, and issue/PR link handling, while keeping workspace-level refresh and autonomous scan behavior aligned with available task capacity. Co-authored-by: OpenAI Codex --- lib/src/lib.rs | 89 +- .../services/automation_state_file_service.rs | 351 ++- .../services/autonomous_workspace_service.rs | 2479 ++++++++++++++++- lib/src/services/codex_app_server.rs | 254 +- .../services/codex_root_session_service.rs | 207 +- lib/src/services/combined.rs | 787 +++++- lib/src/services/config.rs | 12 +- .../services/multicode_metadata_service.rs | 2 +- lib/src/services/persistent_storage.rs | 3 + lib/src/services/resource_usage_service.rs | 34 + lib/src/services/runtime.rs | 9 +- tui/src/app.rs | 429 ++- tui/src/main.rs | 348 ++- tui/src/ops.rs | 73 +- tui/src/render.rs | 397 ++- tui/src/tests.rs | 322 ++- workspace-skills/autonomous-state/SKILL.md | 14 +- 17 files changed, 5183 insertions(+), 627 deletions(-) diff --git a/lib/src/lib.rs b/lib/src/lib.rs index bb32abb..8f5d6c8 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -61,6 +61,48 @@ impl Default for CustomLinksPersistentSnapshot { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum WorkspaceTaskSource { + #[default] + Manual, + Scan, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTaskPersistentSnapshot { + pub id: String, + pub issue_url: String, + #[serde(default)] + pub source: WorkspaceTaskSource, + #[serde(default)] + pub created_at: Option, +} + +impl WorkspaceTaskPersistentSnapshot { + pub fn new(id: String, issue_url: String, source: WorkspaceTaskSource) -> Self { + Self { + id, + issue_url, + source, + created_at: Some(SystemTime::now()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WorkspaceTaskRuntimeSnapshot { + pub session_id: Option, + pub session_status: Option, + pub agent_state: Option, + pub status: Option, + pub waiting_on_vm: bool, + pub repository: Vec, + pub issue: Vec, + pub pr: Vec, + pub last_error: Option, +} + /// Workspace metadata that is saved in persistent storage, i.e. survives a host reboot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersistentWorkspaceSnapshot { @@ -79,6 +121,8 @@ pub struct PersistentWorkspaceSnapshot { pub agent_provided: AgentProvidedPersistentSnapshot, #[serde(default)] pub custom_links: CustomLinksPersistentSnapshot, + #[serde(default)] + pub tasks: Vec, } impl Default for PersistentWorkspaceSnapshot { @@ -93,6 +137,7 @@ impl Default for PersistentWorkspaceSnapshot { archive_format: None, agent_provided: AgentProvidedPersistentSnapshot::default(), custom_links: CustomLinksPersistentSnapshot::default(), + tasks: Vec::new(), } } } @@ -134,9 +179,10 @@ pub struct TransientWorkspaceSnapshot { pub runtime: RuntimeHandleSnapshot, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AutomationAgentState { Working, + WaitingOnVm, Question, Review, Idle, @@ -171,6 +217,8 @@ pub struct WorkspaceSnapshot { pub automation_agent_state: Option, pub automation_status: Option, pub automation_scan_request_nonce: u64, + pub active_task_id: Option, + pub task_states: BTreeMap, pub usage_total_tokens: Option, pub usage_total_cost: Option, pub usage_cpu_percent: Option, @@ -192,6 +240,8 @@ impl Default for WorkspaceSnapshot { automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, + active_task_id: None, + task_states: BTreeMap::new(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -200,3 +250,40 @@ impl Default for WorkspaceSnapshot { } } } + +impl WorkspaceSnapshot { + pub fn task_persistent_snapshot(&self, task_id: &str) -> Option<&WorkspaceTaskPersistentSnapshot> { + self.persistent.tasks.iter().find(|task| task.id == task_id) + } + + pub fn task_issue_url_for_id(&self, task_id: &str) -> Option<&str> { + self.task_persistent_snapshot(task_id) + .map(|task| task.issue_url.as_str()) + } + + pub fn resolved_active_task_id(&self) -> Option { + if self + .active_task_id + .as_deref() + .is_some_and(|task_id| self.task_persistent_snapshot(task_id).is_some()) + { + return self.active_task_id.clone(); + } + + self.persistent.automation_issue.as_deref().and_then(|issue_url| { + self.persistent + .tasks + .iter() + .find(|task| task.issue_url == issue_url) + .map(|task| task.id.clone()) + }) + } + + pub fn resolved_active_issue_url(&self) -> Option { + self.resolved_active_task_id() + .as_deref() + .and_then(|task_id| self.task_issue_url_for_id(task_id)) + .map(ToOwned::to_owned) + .or_else(|| self.persistent.automation_issue.clone()) + } +} diff --git a/lib/src/services/automation_state_file_service.rs b/lib/src/services/automation_state_file_service.rs index 2758d91..285bc2b 100644 --- a/lib/src/services/automation_state_file_service.rs +++ b/lib/src/services/automation_state_file_service.rs @@ -62,7 +62,7 @@ async fn watch_workspace( let should_track = snapshot.transient.is_some() && !snapshot.persistent.archived && !snapshot.persistent.automation_paused - && snapshot.persistent.automation_issue.is_some(); + && active_task_id_for_snapshot(&snapshot).is_some(); if should_track { apply_state_file_snapshot(&workspace, read_state_file(&state_file).await); @@ -83,21 +83,64 @@ async fn watch_workspace( fn apply_state_file_snapshot(workspace: &Workspace, next: Option) { workspace.update(|snapshot| { + let resolved_active_task_id = active_task_id_for_snapshot(snapshot); + let Some(target_task_id) = + state_update_target_task_id(snapshot, resolved_active_task_id.as_deref(), next.as_ref()) + else { + return if next.is_some() { + false + } else { + clear_automation_state_snapshot(snapshot) + }; + }; + if next.is_none() + && expected_session_id_for_active_task(snapshot, &target_task_id).is_some() + { + return false; + } let next_session_id = next.as_ref().and_then(|state| state.thread_id.clone()); let next_agent_state = next.as_ref().map(|state| state.state); let next_session_status = next.as_ref().map(|state| state.state.root_status()); + let should_update_bridge_state = + resolved_active_task_id.as_deref() == Some(target_task_id.as_str()) + || resolved_active_task_id.is_none(); let mut changed = false; - if snapshot.automation_session_id != next_session_id { - snapshot.automation_session_id = next_session_id; + if should_update_bridge_state { + if snapshot.automation_session_id != next_session_id { + snapshot.automation_session_id = next_session_id.clone(); + changed = true; + } + if snapshot.automation_agent_state != next_agent_state { + snapshot.automation_agent_state = next_agent_state; + changed = true; + } + if snapshot.automation_session_status != next_session_status { + snapshot.automation_session_status = next_session_status; + changed = true; + } + if snapshot.active_task_id.is_none() + && snapshot.active_task_id.as_deref() != Some(target_task_id.as_str()) + { + snapshot.active_task_id = Some(target_task_id.clone()); + changed = true; + } + } + let task_state = snapshot.task_states.entry(target_task_id).or_default(); + if task_state.session_id != next_session_id { + task_state.session_id = next_session_id.clone(); + changed = true; + } + if task_state.agent_state != next_agent_state { + task_state.agent_state = next_agent_state; changed = true; } - if snapshot.automation_agent_state != next_agent_state { - snapshot.automation_agent_state = next_agent_state; + if task_state.session_status != next_session_status { + task_state.session_status = next_session_status; changed = true; } - if snapshot.automation_session_status != next_session_status { - snapshot.automation_session_status = next_session_status; + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; changed = true; } changed @@ -105,19 +148,81 @@ fn apply_state_file_snapshot(workspace: &Workspace, next: Option bool { + let mut changed = false; + if let Some(active_task_id) = active_task_id_for_snapshot(snapshot) + && let Some(task_state) = snapshot.task_states.get_mut(&active_task_id) + { + if task_state.session_id.take().is_some() { changed = true; } - if snapshot.automation_agent_state.take().is_some() { + if task_state.agent_state.take().is_some() { changed = true; } - if snapshot.automation_session_status.take().is_some() { + if task_state.session_status.take().is_some() { changed = true; } - changed - }); + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + } + if snapshot.automation_session_id.take().is_some() { + changed = true; + } + if snapshot.automation_agent_state.take().is_some() { + changed = true; + } + if snapshot.automation_session_status.take().is_some() { + changed = true; + } + changed +} + +fn active_task_id_for_snapshot(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_task_id() +} + +fn state_update_target_task_id( + snapshot: &WorkspaceSnapshot, + resolved_active_task_id: Option<&str>, + next: Option<&ParsedAutomationState>, +) -> Option { + if let Some(thread_id) = next.and_then(|state| state.thread_id.as_deref()) { + if let Some(task_id) = task_id_for_session_id(snapshot, thread_id) { + return Some(task_id); + } + if let Some(active_task_id) = resolved_active_task_id + { + let expected_session_id = expected_session_id_for_active_task(snapshot, active_task_id); + if expected_session_id.is_none() || expected_session_id == Some(thread_id) { + return Some(active_task_id.to_string()); + } + } + return None; + } + + resolved_active_task_id.map(ToOwned::to_owned) +} + +fn expected_session_id_for_active_task<'a>( + snapshot: &'a WorkspaceSnapshot, + active_task_id: &str, +) -> Option<&'a str> { + snapshot + .task_states + .get(active_task_id) + .and_then(|task_state| task_state.session_id.as_deref()) + .or(snapshot.automation_session_id.as_deref()) +} + +fn task_id_for_session_id(snapshot: &WorkspaceSnapshot, session_id: &str) -> Option { + snapshot.task_states.iter().find_map(|(task_id, task_state)| { + (task_state.session_id.as_deref() == Some(session_id)).then(|| task_id.clone()) + }) } async fn read_state_file(path: &Path) -> Option { @@ -164,6 +269,7 @@ impl AutomationAgentState { fn root_status(self) -> RootSessionStatus { match self { AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::WaitingOnVm => RootSessionStatus::Idle, AutomationAgentState::Question => RootSessionStatus::Question, AutomationAgentState::Review | AutomationAgentState::Idle @@ -175,6 +281,7 @@ impl AutomationAgentState { #[cfg(test)] mod tests { use super::*; + use crate::{WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource}; #[test] fn parse_state_file_maps_known_states() { @@ -190,4 +297,220 @@ mod tests { assert_eq!(parsed.state, AutomationAgentState::Stale); } + + #[test] + fn active_task_id_falls_back_to_automation_issue_mapping() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + + assert_eq!( + active_task_id_for_snapshot(&snapshot).as_deref(), + Some("task-42") + ); + } + + #[test] + fn apply_state_file_snapshot_populates_task_state_for_fallback_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + true + }); + + apply_state_file_snapshot( + &workspace, + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-42")); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should be created"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn apply_state_file_snapshot_ignores_explicit_mismatched_session_id() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-42".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + true + }); + + apply_state_file_snapshot( + &workspace, + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-old".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn apply_state_file_snapshot_preserves_existing_session_when_state_file_missing() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-42".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot(&workspace, None); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn apply_state_file_snapshot_updates_matching_task_without_reassigning_active_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-30".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-30".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-30".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot( + &workspace, + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-30")); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-30")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.session_id.as_deref(), Some("thread-42")); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Idle)); + let task_30 = snapshot + .task_states + .get("task-30") + .expect("task 30 should remain"); + assert_eq!(task_30.session_id.as_deref(), Some("thread-30")); + assert_eq!(task_30.agent_state, Some(AutomationAgentState::Working)); + } } diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 86304ac..cf967c3 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -1,20 +1,28 @@ -use std::{collections::HashSet, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + time::Duration, +}; +use diesel::{Connection, QueryableByName, RunQueryDsl, sql_query, sqlite::SqliteConnection}; use serde::Deserialize; use tokio::{ process::Command, sync::watch, - time::{Instant, sleep_until}, + task::spawn_blocking, + time::{Instant, sleep, sleep_until}, }; use super::{ CombinedService, GithubStatus, - runtime::{AUTOMATION_STATE_ENV, automation_state_file_source}, + codex_app_server::CodexAppServerClient, + runtime::{AUTOMATION_STATE_ENV, automation_state_file_source, synthetic_codex_home_source}, workspace_watch::monitor_workspace_snapshots, }; use crate::{ AutomationAgentState, RootSessionStatus, WorkspaceManagerError, WorkspaceSnapshot, - manager::Workspace, + WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, manager::Workspace, opencode, + services::config::AgentProvider, }; const ISSUE_PRIORITY_LABELS: [&str; 4] = [ @@ -46,8 +54,19 @@ pub async fn autonomous_workspace_service( move |key, workspace, workspace_rx| { let service = service.clone(); async move { + tracing::info!(workspace_key = %key, "starting autonomous workspace watcher"); tokio::spawn(async move { - watch_workspace(service, key, workspace, workspace_rx).await; + let workspace_key = key.clone(); + let handle = tokio::spawn(async move { + watch_workspace(service, key, workspace, workspace_rx).await; + }); + if let Err(error) = handle.await { + tracing::error!( + workspace_key, + error = ?error, + "autonomous workspace watcher terminated unexpectedly" + ); + } }); Ok(()) } @@ -62,26 +81,49 @@ async fn watch_workspace( workspace: Workspace, mut workspace_rx: watch::Receiver, ) { + tracing::info!(workspace_key, "autonomous workspace watcher active"); let issue_scan_delay = Duration::from_secs(service.config.autonomous.issue_scan_delay_seconds); let issue_scan_delay = issue_scan_delay.max(Duration::from_secs(1)); let mut next_scan_at: Option = None; let mut watched_issue_url: Option = None; let mut issue_status_rx: Option>> = None; + let mut task_issue_status_rxs: HashMap>> = + HashMap::new(); let mut previous_root_status: Option = None; let mut previous_scan_request_nonce: u64 = 0; let mut blocked_start_scan_request_nonce: Option = None; + let mut previous_active_issue_url: Option = None; loop { - let snapshot = workspace_rx.borrow().clone(); + let mut snapshot = workspace_rx.borrow().clone(); let assigned_repository = snapshot.persistent.assigned_repository.clone(); let scan_requested = snapshot.automation_scan_request_nonce != previous_scan_request_nonce; previous_scan_request_nonce = snapshot.automation_scan_request_nonce; + if assigned_repository.is_some() || !snapshot.persistent.tasks.is_empty() { + tracing::warn!( + workspace_key, + assigned_repository = assigned_repository.as_deref(), + transient_uri = snapshot.transient.as_ref().map(|transient| transient.uri.as_str()), + active_task_id = snapshot.active_task_id.as_deref(), + resolved_active_task_id = snapshot.resolved_active_task_id().as_deref(), + automation_issue = snapshot.persistent.automation_issue.as_deref(), + task_count = snapshot.persistent.tasks.len(), + root_session_id = snapshot.root_session_id.as_deref(), + root_status = ?snapshot.root_session_status, + scan_requested, + paused = snapshot.persistent.automation_paused, + "autonomous workspace iteration" + ); + } + if snapshot.persistent.archived || assigned_repository.is_none() { watched_issue_url = None; issue_status_rx = None; + task_issue_status_rxs.clear(); next_scan_at = None; blocked_start_scan_request_nonce = None; + previous_active_issue_url = None; previous_root_status = snapshot.root_session_status; clear_automation_runtime_state(&workspace); set_automation_status(&workspace, None); @@ -93,11 +135,85 @@ async fn watch_workspace( let assigned_repository = assigned_repository.expect("checked above"); let repository_label = compact_repository_label(&assigned_repository); + sync_task_runtime_state(&workspace, &snapshot); + snapshot = workspace.subscribe().borrow().clone(); + recover_codex_task_sessions( + &service, + &workspace, + &snapshot, + &workspace_key, + &assigned_repository, + ) + .await; + snapshot = workspace.subscribe().borrow().clone(); + reconcile_codex_task_runtime_states( + &service, + &workspace, + &snapshot, + &workspace_key, + &assigned_repository, + ) + .await; + snapshot = workspace.subscribe().borrow().clone(); + sync_task_issue_status_receivers(&service, &snapshot, &mut task_issue_status_rxs); + let current_issue_url = active_issue_url_for_snapshot(&snapshot); + if previous_active_issue_url != current_issue_url { + request_refresh_for_task_issues( + &service, + task_issue_status_rxs.keys().map(String::as_str), + current_issue_url.as_deref(), + ); + previous_active_issue_url = current_issue_url.clone(); + } + let closed_background_issue_urls = closed_background_task_issue_urls( + &snapshot, + current_issue_url.as_deref(), + &task_issue_status_rxs, + ); + if !closed_background_issue_urls.is_empty() { + for closed_issue_url in &closed_background_issue_urls { + if let Err(err) = service + .remove_workspace_task_checkout( + &workspace_key, + &assigned_repository, + closed_issue_url, + ) + .await + { + tracing::warn!( + workspace_key, + issue_url = %closed_issue_url, + error = ?err, + "failed to remove closed background task worktree" + ); + } + clear_automation_issue_claim(&workspace, closed_issue_url); + task_issue_status_rxs.remove(closed_issue_url); + } + next_scan_at = Some(Instant::now()); + set_automation_status(&workspace, Some(format!("Next issue {repository_label}"))); + previous_root_status = snapshot.root_session_status; + continue; + } + if !snapshot.persistent.tasks.is_empty() { + tracing::warn!( + workspace_key, + task_count = snapshot.persistent.tasks.len(), + active_task_id = snapshot.active_task_id.as_deref(), + automation_issue = snapshot.persistent.automation_issue.as_deref(), + root_session_id = snapshot.root_session_id.as_deref(), + root_status = ?snapshot.root_session_status, + scan_requested, + "autonomous workspace loop snapshot" + ); + } if snapshot.persistent.automation_paused { watched_issue_url = None; issue_status_rx = None; + task_issue_status_rxs.clear(); next_scan_at = None; blocked_start_scan_request_nonce = None; + previous_active_issue_url = None; previous_root_status = snapshot.root_session_status; clear_automation_runtime_state(&workspace); set_automation_status(&workspace, Some(format!("Stopped {repository_label}"))); @@ -110,7 +226,7 @@ async fn watch_workspace( blocked_start_scan_request_nonce = None; } if scan_requested - && snapshot.persistent.automation_issue.is_none() + && active_task_id_for_snapshot(&snapshot).is_none() && matches!( snapshot .root_session_status @@ -122,6 +238,45 @@ async fn watch_workspace( set_automation_status(&workspace, Some(format!("Scan now {repository_label}"))); } + if active_task_id_for_snapshot(&snapshot).is_none() + { + if let Some(next_issue_url) = next_schedulable_task_issue_url(&snapshot, None) { + tracing::info!( + workspace_key, + issue_url = %next_issue_url, + task_count = snapshot.persistent.tasks.len(), + root_session_id = snapshot.root_session_id.as_deref(), + root_status = ?snapshot.root_session_status, + "leasing autonomous task onto workspace VM" + ); + lease_task_issue(&workspace, &snapshot, &next_issue_url); + clear_automation_state_file(&service, &workspace_key).await; + set_automation_status( + &workspace, + Some(format!( + "Scheduling {}", + issue_reference(&next_issue_url).unwrap_or(next_issue_url.clone()) + )), + ); + previous_root_status = snapshot.root_session_status; + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } + if !snapshot.persistent.tasks.is_empty() { + tracing::warn!( + workspace_key, + task_count = snapshot.persistent.tasks.len(), + root_session_id = snapshot.root_session_id.as_deref(), + root_status = ?snapshot.root_session_status, + active_task_id = snapshot.active_task_id.as_deref(), + automation_issue = snapshot.persistent.automation_issue.as_deref(), + "autonomous workspace has queued tasks but no schedulable active lease" + ); + } + } + if snapshot.transient.is_none() { if start_retry_is_blocked( blocked_start_scan_request_nonce, @@ -174,22 +329,100 @@ async fn watch_workspace( continue; } - let current_issue_url = snapshot.persistent.automation_issue.clone(); if let Some(current_issue_url) = current_issue_url { + tracing::warn!( + workspace_key, + issue_url = %current_issue_url, + root_session_id = snapshot.root_session_id.as_deref(), + root_status = ?snapshot.root_session_status, + task_session_id = snapshot + .resolved_active_task_id() + .as_deref() + .and_then(|task_id| snapshot.task_states.get(task_id)) + .and_then(|state| state.session_id.as_deref()), + "autonomous workspace has active issue candidate" + ); + if scan_requested { + let available_slots = service + .config + .autonomous + .max_parallel_issues + .saturating_sub(snapshot.persistent.tasks.len()); + if available_slots > 0 { + match enqueue_next_issues( + &service, + &workspace, + &workspace_key, + &snapshot, + &assigned_repository, + available_slots, + ) + .await + { + Ok(queued) if queued > 0 => { + set_automation_status( + &workspace, + Some(format!( + "Queued {queued} issue(s) for {repository_label}" + )), + ); + previous_root_status = snapshot.root_session_status; + continue; + } + Ok(_) => {} + Err(err) => { + set_automation_status( + &workspace, + Some(format!("Scan failed {repository_label}: {err}")), + ); + previous_root_status = snapshot.root_session_status; + if !wait_for_workspace_change_until( + &mut workspace_rx, + &mut issue_status_rx, + Some(Instant::now() + ISSUE_SCAN_RETRY_DELAY), + ) + .await + { + break; + } + continue; + } + } + } + } + if active_task_can_yield_vm(&snapshot) + && let Some(next_issue_url) = + next_schedulable_task_issue_url(&snapshot, Some(current_issue_url.as_str())) + { + lease_task_issue(&workspace, &snapshot, &next_issue_url); + clear_automation_state_file(&service, &workspace_key).await; + watched_issue_url = None; + issue_status_rx = None; + set_automation_status( + &workspace, + Some(format!( + "Scheduling {}", + issue_reference(&next_issue_url).unwrap_or(next_issue_url.clone()) + )), + ); + previous_root_status = snapshot.root_session_status; + if workspace_rx.changed().await.is_err() { + break; + } + continue; + } let root_status = snapshot .root_session_status .unwrap_or(RootSessionStatus::Idle); - let should_resume_assigned_issue = matches!(root_status, RootSessionStatus::Idle) - && (scan_requested - || (previous_root_status.is_none() - && snapshot.automation_agent_state.is_none() - && snapshot.automation_session_status.is_none())); - - if !should_resume_assigned_issue - && snapshot.automation_session_id.is_none() - && snapshot.root_session_id.is_some() - && !matches!(root_status, RootSessionStatus::Idle) - { + let should_resume_assigned_issue = + should_start_assigned_issue_work(&snapshot, root_status); + + if should_bridge_root_runtime_state( + service.agent_provider(), + &snapshot, + should_resume_assigned_issue, + root_status, + ) { set_automation_runtime_state( &workspace, snapshot.root_session_id.clone(), @@ -231,6 +464,8 @@ async fn watch_workspace( clear_automation_issue_claim(&workspace, ¤t_issue_url); watched_issue_url = None; issue_status_rx = None; + task_issue_status_rxs.remove(¤t_issue_url); + previous_active_issue_url = None; next_scan_at = Some(Instant::now() + issue_scan_delay); set_automation_status( &workspace, @@ -275,27 +510,26 @@ async fn watch_workspace( } if issue_is_closed(issue_status_rx.as_ref()) { - workspace.update(|next| { - let mut changed = false; - if next.persistent.automation_issue.as_deref() - == Some(current_issue_url.as_str()) - { - next.persistent.automation_issue = None; - changed = true; - } - if next.automation_session_id.take().is_some() { - changed = true; - } - if next.automation_agent_state.take().is_some() { - changed = true; - } - if next.automation_session_status.take().is_some() { - changed = true; - } - changed - }); + if let Err(err) = service + .remove_workspace_task_checkout( + &workspace_key, + &assigned_repository, + ¤t_issue_url, + ) + .await + { + tracing::warn!( + workspace_key, + issue_url = %current_issue_url, + error = ?err, + "failed to remove closed task worktree" + ); + } + clear_automation_issue_claim(&workspace, ¤t_issue_url); watched_issue_url = None; issue_status_rx = None; + task_issue_status_rxs.remove(¤t_issue_url); + previous_active_issue_url = None; next_scan_at = Some(Instant::now()); set_automation_status(&workspace, Some(format!("Next issue {repository_label}"))); previous_root_status = snapshot.root_session_status; @@ -358,25 +592,48 @@ async fn watch_workspace( } set_automation_status(&workspace, Some(format!("Scan issues {repository_label}"))); - match claim_next_issue( + let available_slots = service + .config + .autonomous + .max_parallel_issues + .saturating_sub(snapshot.persistent.tasks.len()); + match enqueue_next_issues( &service, &workspace, &workspace_key, &snapshot, &assigned_repository, + available_slots.max(1), ) .await { - Ok(Some(issue)) => { - watched_issue_url = Some(issue.url.clone()); - issue_status_rx = service.github_status_service().watch_status(&issue.url); + Ok(queued) if queued > 0 => { next_scan_at = None; + let post_enqueue_snapshot = workspace.subscribe().borrow().clone(); + let next_issue_url = + next_schedulable_task_issue_url(&post_enqueue_snapshot, None); + tracing::warn!( + workspace_key, + queued, + task_count = post_enqueue_snapshot.persistent.tasks.len(), + active_task_id = post_enqueue_snapshot.active_task_id.as_deref(), + automation_issue = post_enqueue_snapshot.persistent.automation_issue.as_deref(), + next_issue_url = next_issue_url.as_deref(), + "autonomous workspace post-enqueue state" + ); + if let Some(next_issue_url) = next_issue_url { + lease_task_issue( + &workspace, + &post_enqueue_snapshot, + &next_issue_url, + ); + } set_automation_status( &workspace, - Some(format!("Working {}", issue.display_reference())), + Some(format!("Queued {queued} issue(s) for {repository_label}")), ); } - Ok(None) => { + Ok(_) => { next_scan_at = Some(Instant::now() + issue_scan_delay); set_automation_status( &workspace, @@ -408,91 +665,57 @@ fn start_retry_is_blocked(blocked_nonce: Option, current_nonce: u64) -> boo blocked_nonce == Some(current_nonce) } -async fn claim_next_issue( +async fn start_assigned_issue_work( service: &CombinedService, workspace: &Workspace, workspace_key: &str, snapshot: &WorkspaceSnapshot, assigned_repository: &str, + issue_url: &str, ) -> Result, String> { - let excluded_issue_urls = active_issue_urls(service, workspace_key); let token = resolved_gh_token(service).await?; - let Some(issue) = find_next_issue(assigned_repository, &excluded_issue_urls, &token).await? - else { + let Some(issue) = fetch_issue(assigned_repository, issue_url, &token).await? else { return Ok(None); }; - set_automation_status( + ensure_workspace_task_claim( workspace, - Some(format!("Claiming {}", issue.display_reference())), + assigned_repository, + &issue, + WorkspaceTaskSource::Manual, ); - persist_automation_issue_claim(workspace, assigned_repository, &issue); - clear_automation_state_file(service, workspace_key).await; - - if let Err(err) = prompt_root_session(service, snapshot, assigned_repository, &issue).await { - clear_automation_issue_claim(workspace, &issue.url); - tracing::warn!( - workspace_key, - issue_url = %issue.url, - error = %err, - "failed to start autonomous issue work after reserving issue" - ); - return Err(err); - } + let task_session_id = ensure_task_session( + service, + workspace, + snapshot, + workspace_key, + assigned_repository, + &issue, + ) + .await?; set_automation_runtime_state( workspace, - snapshot.root_session_id.clone(), + Some(task_session_id.clone()), Some(RootSessionStatus::Busy), ); - if let Err(err) = - add_work_started_comment(assigned_repository, &issue, workspace_key, &token).await - { - tracing::warn!( - workspace_key, - issue_url = %issue.url, - error = %err, - "autonomous issue prompt started but adding work-started comment failed" - ); - return Err(err); - } - - if let Err(err) = add_issue_label(assigned_repository, &issue, IN_PROGRESS_LABEL, &token).await - { - tracing::warn!( - workspace_key, - issue_url = %issue.url, - label = IN_PROGRESS_LABEL, - error = %err, - "autonomous issue prompt started but adding in-progress label failed" - ); - return Err(err); - } - - Ok(Some(issue)) -} - -async fn start_assigned_issue_work( - service: &CombinedService, - workspace: &Workspace, - workspace_key: &str, - snapshot: &WorkspaceSnapshot, - assigned_repository: &str, - issue_url: &str, -) -> Result, String> { - let token = resolved_gh_token(service).await?; - let Some(issue) = fetch_issue(assigned_repository, issue_url, &token).await? else { - return Ok(None); - }; - set_automation_status( workspace, Some(format!("Claiming {}", issue.display_reference())), ); clear_automation_state_file(service, workspace_key).await; - if let Err(err) = prompt_root_session(service, snapshot, assigned_repository, &issue).await { + if let Err(err) = prompt_task_session( + service, + snapshot, + assigned_repository, + &issue, + &task_session_id, + task_cwd_path(service, workspace_key, assigned_repository, &issue.url), + ) + .await + { tracing::warn!( workspace_key, issue_url = %issue.url, @@ -502,12 +725,6 @@ async fn start_assigned_issue_work( return Err(err); } - set_automation_runtime_state( - workspace, - snapshot.root_session_id.clone(), - Some(RootSessionStatus::Busy), - ); - if let Err(err) = add_work_started_comment(assigned_repository, &issue, workspace_key, &token).await { @@ -535,30 +752,123 @@ async fn start_assigned_issue_work( Ok(Some(issue)) } -fn persist_automation_issue_claim( +async fn enqueue_next_issues( + service: &CombinedService, + workspace: &Workspace, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, + assigned_repository: &str, + limit: usize, +) -> Result { + if limit == 0 { + return Ok(0); + } + + let token = resolved_gh_token(service).await?; + let mut queued = 0usize; + for _ in 0..limit { + let current_snapshot = workspace.subscribe().borrow().clone(); + let excluded_issue_urls = + reserved_issue_urls(service, workspace_key, Some(¤t_snapshot)); + let Some(issue) = + find_next_issue(assigned_repository, &excluded_issue_urls, &token).await? + else { + break; + }; + queue_issue_task(workspace, &issue, WorkspaceTaskSource::Scan); + queued += 1; + } + + let _ = snapshot; + Ok(queued) +} + +fn ensure_workspace_task_claim( workspace: &Workspace, assigned_repository: &str, issue: &SelectedIssue, + source: WorkspaceTaskSource, ) { - workspace.update(|next| { - let changed_issue = next.persistent.automation_issue.as_deref() != Some(issue.url.as_str()); - let changed_repo = - next.persistent.assigned_repository.as_deref() != Some(assigned_repository); - if changed_issue || changed_repo { - next.persistent.assigned_repository = Some(assigned_repository.to_string()); - next.persistent.automation_issue = Some(issue.url.clone()); - true - } else { - false + workspace.update(|snapshot| { + let mut changed = false; + if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository) { + snapshot.persistent.assigned_repository = Some(assigned_repository.to_string()); + changed = true; + } + let task_id = task_id_for_issue(snapshot, &issue.url).unwrap_or_else(|| { + let task = WorkspaceTaskPersistentSnapshot::new( + format!("task-{}", issue.number), + issue.url.clone(), + source, + ); + let task_id = task.id.clone(); + snapshot.persistent.tasks.push(task); + task_id + }); + if snapshot.active_task_id.as_deref() != Some(task_id.as_str()) { + snapshot.active_task_id = Some(task_id); + changed = true; + } + changed + }); +} + +fn queue_issue_task(workspace: &Workspace, issue: &SelectedIssue, source: WorkspaceTaskSource) { + workspace.update(|snapshot| { + if snapshot + .persistent + .tasks + .iter() + .any(|task| task.issue_url == issue.url) + { + return false; } + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + format!("task-{}", issue.number), + issue.url.clone(), + source, + )); + true }); } fn clear_automation_issue_claim(workspace: &Workspace, issue_url: &str) { workspace.update(|next| { let mut changed = false; - if next.persistent.automation_issue.as_deref() == Some(issue_url) { - next.persistent.automation_issue = None; + let cleared_task_id = task_id_for_issue(next, issue_url); + let before = next.persistent.tasks.len(); + next.persistent + .tasks + .retain(|task| task.issue_url != issue_url); + if next.persistent.tasks.len() != before { + changed = true; + } + if let Some(task_id) = cleared_task_id.as_deref() + && next.task_states.remove(task_id).is_some() + { + changed = true; + } + let next_active_task_id = if next.active_task_id.as_deref() == cleared_task_id.as_deref() { + next.persistent.tasks.first().map(|task| task.id.clone()) + } else { + next.active_task_id + .as_deref() + .filter(|task_id| task_persistent_snapshot(next, task_id).is_some()) + .map(ToOwned::to_owned) + }; + if next.active_task_id != next_active_task_id { + next.active_task_id = next_active_task_id.clone(); + changed = true; + } + let next_active_issue_url = next_active_task_id + .as_deref() + .and_then(|task_id| task_issue_url_for_id(next, task_id)) + .map(ToOwned::to_owned); + if next.persistent.automation_issue != next_active_issue_url { + next.persistent.automation_issue = next_active_issue_url; changed = true; } if next.automation_session_id.take().is_some() { @@ -577,6 +887,19 @@ fn clear_automation_issue_claim(workspace: &Workspace, issue_url: &str) { fn clear_automation_runtime_state(workspace: &Workspace) { workspace.update(|snapshot| { let mut changed = false; + if let Some(active_task_id) = snapshot.active_task_id.clone() + && let Some(task_state) = snapshot.task_states.get_mut(&active_task_id) + { + let mut next_task_state = task_state.clone(); + next_task_state.session_id = None; + next_task_state.session_status = None; + next_task_state.agent_state = None; + next_task_state.waiting_on_vm = false; + if &next_task_state != task_state { + *task_state = next_task_state; + changed = true; + } + } if snapshot.automation_session_id.take().is_some() { changed = true; } @@ -590,6 +913,133 @@ fn clear_automation_runtime_state(workspace: &Workspace) { }); } +fn lease_task_issue(workspace: &Workspace, snapshot: &WorkspaceSnapshot, issue_url: &str) { + let next_task_id = task_id_for_issue(snapshot, issue_url); + workspace.update(|next| { + let mut changed = false; + if next.persistent.automation_issue.as_deref() != Some(issue_url) { + next.persistent.automation_issue = Some(issue_url.to_string()); + changed = true; + } + if next.active_task_id != next_task_id { + next.active_task_id = next_task_id.clone(); + next.automation_session_id = None; + next.automation_agent_state = None; + next.automation_session_status = None; + changed = true; + } + changed + }); +} + +fn sync_task_runtime_state(workspace: &Workspace, snapshot: &WorkspaceSnapshot) { + workspace.update(|next| { + let mut changed = false; + let task_ids = snapshot + .persistent + .tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(); + let stale_task_ids = next + .task_states + .keys() + .filter(|task_id| !task_ids.contains(task_id.as_str())) + .cloned() + .collect::>(); + for task_id in stale_task_ids { + if next.task_states.remove(&task_id).is_some() { + changed = true; + } + } + + let resolved_active_task_id = next + .active_task_id + .as_deref() + .filter(|task_id| active_task_lease_must_be_preserved(next, task_id)) + .map(ToOwned::to_owned) + .or_else(|| active_task_id_for_snapshot(snapshot)); + if next.active_task_id != resolved_active_task_id { + next.active_task_id = resolved_active_task_id.clone(); + changed = true; + } + let resolved_issue_url = resolved_active_task_id + .as_deref() + .and_then(|task_id| task_issue_url_for_id(snapshot, task_id)) + .map(ToOwned::to_owned); + if next.persistent.automation_issue != resolved_issue_url { + next.persistent.automation_issue = resolved_issue_url; + changed = true; + } + if next.active_task_id.is_none() { + if next.automation_session_id.take().is_some() { + changed = true; + } + if next.automation_agent_state.take().is_some() { + changed = true; + } + if next.automation_session_status.take().is_some() { + changed = true; + } + } + for task in &snapshot.persistent.tasks { + let is_active = next.active_task_id.as_deref() == Some(task.id.as_str()); + let task_state = next.task_states.entry(task.id.clone()).or_default(); + let should_wait = !is_active + && !matches!( + task_state.agent_state, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ); + if task_state.waiting_on_vm != should_wait { + task_state.waiting_on_vm = should_wait; + if should_wait { + task_state.agent_state = Some(AutomationAgentState::WaitingOnVm); + } else if !should_wait + && task_state.agent_state == Some(AutomationAgentState::WaitingOnVm) + { + task_state.agent_state = None; + } + changed = true; + } + } + changed + }); +} + +fn active_task_lease_must_be_preserved(snapshot: &WorkspaceSnapshot, task_id: &str) -> bool { + task_persistent_snapshot(snapshot, task_id).is_some() + && snapshot + .task_states + .get(task_id) + .is_some_and(|task_state| { + task_state.session_id.is_some() && !task_can_yield_vm(task_state.agent_state) + }) +} + +fn active_task_id_for_snapshot(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_task_id() +} + +fn active_issue_url_for_snapshot(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_issue_url() +} + +fn task_issue_url_for_id<'a>(snapshot: &'a WorkspaceSnapshot, task_id: &str) -> Option<&'a str> { + snapshot.task_issue_url_for_id(task_id) +} + +fn task_persistent_snapshot<'a>( + snapshot: &'a WorkspaceSnapshot, + task_id: &str, +) -> Option<&'a WorkspaceTaskPersistentSnapshot> { + snapshot.task_persistent_snapshot(task_id) +} + fn set_automation_runtime_state( workspace: &Workspace, session_id: Option, @@ -601,11 +1051,7 @@ fn set_automation_runtime_state( snapshot.automation_session_id = session_id.clone(); changed = true; } - let next_agent_state = session_status.map(|status| match status { - RootSessionStatus::Busy => AutomationAgentState::Working, - RootSessionStatus::Question => AutomationAgentState::Question, - RootSessionStatus::Idle => AutomationAgentState::Idle, - }); + let next_agent_state = session_status.map(root_status_to_agent_state); if snapshot.automation_agent_state != next_agent_state { snapshot.automation_agent_state = next_agent_state; changed = true; @@ -614,13 +1060,49 @@ fn set_automation_runtime_state( snapshot.automation_session_status = session_status; changed = true; } + if let Some(active_task_id) = snapshot.active_task_id.clone() { + let task_state = snapshot.task_states.entry(active_task_id).or_default(); + let can_update_task_state = task_state.session_id.is_none() + || task_state.session_id == session_id; + if can_update_task_state { + if task_state.session_id != session_id { + task_state.session_id = session_id.clone(); + changed = true; + } + if task_state.session_status != session_status { + task_state.session_status = session_status; + changed = true; + } + if task_state.agent_state != next_agent_state { + task_state.agent_state = next_agent_state; + changed = true; + } + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + } changed }); } -fn active_issue_urls(service: &CombinedService, current_workspace_key: &str) -> HashSet { +fn reserved_issue_urls( + service: &CombinedService, + current_workspace_key: &str, + current_snapshot: Option<&WorkspaceSnapshot>, +) -> HashSet { let workspace_keys = service.manager.subscribe().borrow().clone(); let mut urls = HashSet::new(); + if let Some(snapshot) = current_snapshot { + urls.extend( + snapshot + .persistent + .tasks + .iter() + .map(|task| task.issue_url.clone()), + ); + } for key in workspace_keys { if key == current_workspace_key { continue; @@ -629,17 +1111,115 @@ fn active_issue_urls(service: &CombinedService, current_workspace_key: &str) -> continue; }; let snapshot = workspace.subscribe().borrow().clone(); - if let Some(url) = snapshot.persistent.automation_issue { - urls.insert(url); - } + urls.extend( + snapshot + .persistent + .tasks + .into_iter() + .map(|task| task.issue_url), + ); urls.extend(snapshot.persistent.custom_links.issue); urls.extend(snapshot.persistent.agent_provided.issue); } urls } -fn set_automation_status(workspace: &Workspace, next_status: Option) { - workspace.update(|snapshot| { +fn next_schedulable_task_issue_url( + snapshot: &WorkspaceSnapshot, + exclude_issue_url: Option<&str>, +) -> Option { + snapshot + .persistent + .tasks + .iter() + .find(|task| { + Some(task.issue_url.as_str()) != exclude_issue_url + && !matches!( + snapshot + .task_states + .get(&task.id) + .and_then(|state| state.agent_state), + Some( + AutomationAgentState::Working + | AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + ) + ) + }) + .map(|task| task.issue_url.clone()) +} + +fn task_can_yield_vm(agent_state: Option) -> bool { + matches!( + agent_state, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ) +} + +fn active_task_can_yield_vm(snapshot: &WorkspaceSnapshot) -> bool { + let Some(active_task_id) = snapshot.active_task_id.as_deref() else { + return false; + }; + let Some(task_state) = snapshot.task_states.get(active_task_id) else { + return false; + }; + if task_state.session_id.is_none() { + return false; + } + task_can_yield_vm(task_state.agent_state) +} + +fn task_id_for_issue(snapshot: &WorkspaceSnapshot, issue_url: &str) -> Option { + snapshot + .persistent + .tasks + .iter() + .find(|task| task.issue_url == issue_url) + .map(|task| task.id.clone()) +} + +fn should_start_assigned_issue_work( + snapshot: &WorkspaceSnapshot, + root_status: RootSessionStatus, +) -> bool { + if !matches!(root_status, RootSessionStatus::Idle) { + return false; + } + + let Some(active_task_id) = active_task_id_for_snapshot(snapshot) else { + return false; + }; + + snapshot + .task_states + .get(&active_task_id) + .and_then(|state| state.session_id.as_deref()) + .is_none() +} + +fn should_bridge_root_runtime_state( + agent_provider: AgentProvider, + snapshot: &WorkspaceSnapshot, + should_resume_assigned_issue: bool, + root_status: RootSessionStatus, +) -> bool { + if agent_provider == AgentProvider::Codex { + return false; + } + !should_resume_assigned_issue + && snapshot.automation_session_id.is_none() + && snapshot.root_session_id.is_some() + && !matches!(root_status, RootSessionStatus::Idle) +} + +fn set_automation_status(workspace: &Workspace, next_status: Option) { + workspace.update(|snapshot| { if snapshot.automation_status != next_status { snapshot.automation_status = next_status.clone(); true @@ -650,13 +1230,391 @@ fn set_automation_status(workspace: &Workspace, next_status: Option) { } fn effective_automation_agent_state(snapshot: &WorkspaceSnapshot) -> Option { - snapshot.automation_agent_state.or_else(|| { - snapshot.root_session_status.map(|status| match status { - RootSessionStatus::Busy => AutomationAgentState::Working, - RootSessionStatus::Question => AutomationAgentState::Question, - RootSessionStatus::Idle => AutomationAgentState::Idle, + snapshot + .active_task_id + .as_deref() + .and_then(|task_id| snapshot.task_states.get(task_id)) + .and_then(|state| state.agent_state) + .or(snapshot.automation_agent_state) + .or_else(|| snapshot.root_session_status.map(root_status_to_agent_state)) +} + +fn root_status_to_agent_state(status: RootSessionStatus) -> AutomationAgentState { + match status { + RootSessionStatus::Busy => AutomationAgentState::Working, + RootSessionStatus::Question => AutomationAgentState::Question, + RootSessionStatus::Idle => AutomationAgentState::Idle, + } +} + +#[derive(Debug, QueryableByName)] +struct CodexStateThreadRow { + #[diesel(sql_type = diesel::sql_types::Text)] + id: String, + #[diesel(sql_type = diesel::sql_types::Text)] + cwd: String, + #[diesel(sql_type = diesel::sql_types::BigInt)] + updated_at: i64, + #[diesel(sql_type = diesel::sql_types::Integer)] + has_user_event: i32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct CodexTaskMetadata { + repositories: Vec, + issues: Vec, + prs: Vec, +} + +async fn recover_codex_task_sessions( + service: &CombinedService, + workspace: &Workspace, + snapshot: &WorkspaceSnapshot, + workspace_key: &str, + assigned_repository: &str, +) { + if service.agent_provider() != AgentProvider::Codex { + return; + } + + let missing_task_cwds = snapshot + .persistent + .tasks + .iter() + .filter(|task| { + snapshot + .task_states + .get(&task.id) + .and_then(|state| state.session_id.as_deref()) + .is_none() + }) + .map(|task| { + ( + task.id.clone(), + task_cwd_path(service, workspace_key, assigned_repository, &task.issue_url) + .to_string_lossy() + .into_owned(), + ) }) + .collect::>(); + if missing_task_cwds.is_empty() { + return; + } + + let codex_home = synthetic_codex_home_source(service.workspace_directory_path(), workspace_key); + let recovered = spawn_blocking(move || { + recover_codex_thread_ids_from_state_db(&codex_home, &missing_task_cwds) }) + .await + .ok() + .and_then(Result::ok); + let Some(recovered) = recovered else { + return; + }; + if recovered.is_empty() { + return; + } + + workspace.update(|next| { + let mut changed = false; + for (task_id, session_id) in &recovered { + let task_state = next.task_states.entry(task_id.clone()).or_default(); + if task_state.session_id.as_deref() != Some(session_id.as_str()) { + task_state.session_id = Some(session_id.clone()); + changed = true; + } + } + changed + }); +} + +async fn reconcile_codex_task_runtime_states( + service: &CombinedService, + workspace: &Workspace, + snapshot: &WorkspaceSnapshot, + workspace_key: &str, + assigned_repository: &str, +) { + if service.agent_provider() != AgentProvider::Codex { + return; + } + + let Some(uri) = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + else { + return; + }; + + let _ = (workspace_key, assigned_repository); + let client = CodexAppServerClient::new(uri); + let active_task_id = snapshot.active_task_id.as_deref(); + for task in &snapshot.persistent.tasks { + let Some(task_state) = snapshot.task_states.get(&task.id) else { + continue; + }; + let Some(task_session_id) = task_state.session_id.clone() else { + continue; + }; + + let response = match client.thread_read_with_turns(&task_session_id, true).await { + Ok(response) => response, + Err(error) => { + if error.contains("thread not loaded") { + let (session_status, agent_state) = unloaded_codex_task_runtime(task_state); + set_task_runtime_state_from_codex( + workspace, + &task.id, + &task_session_id, + session_status, + agent_state, + Some(CodexTaskMetadata { + issues: vec![task.issue_url.clone()], + ..Default::default() + }), + ); + } + continue; + } + }; + let Some(status) = response.thread.status.as_ref() else { + continue; + }; + + let next_session_status = codex_thread_status_to_root_session_status(status); + let next_agent_state = codex_task_thread_status_to_agent_state(status); + let next_agent_state = if active_task_id != Some(task.id.as_str()) + && next_agent_state == AutomationAgentState::Working + { + AutomationAgentState::WaitingOnVm + } else { + next_agent_state + }; + let metadata = codex_task_metadata_from_turns(&response.thread.turns, &task.issue_url); + set_task_runtime_state_from_codex( + workspace, + &task.id, + &task_session_id, + next_session_status, + next_agent_state, + Some(metadata), + ); + } +} + +fn latest_codex_state_db_path(codex_home: &Path) -> Option { + let mut candidates = std::fs::read_dir(codex_home) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + if !file_type.is_file() { + return None; + } + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + (file_name.starts_with("state_") && file_name.ends_with(".sqlite")).then(|| { + let modified = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()); + (modified, entry.path()) + }) + }) + .collect::>(); + candidates.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + candidates.pop().map(|(_, path)| path) +} + +fn recover_codex_thread_ids_from_state_db( + codex_home: &Path, + task_cwds: &[(String, String)], +) -> Result, String> { + let Some(state_db_path) = latest_codex_state_db_path(codex_home) else { + return Ok(HashMap::new()); + }; + let database_url = state_db_path.to_string_lossy().into_owned(); + let mut connection = + SqliteConnection::establish(&database_url).map_err(|error| error.to_string())?; + let rows = sql_query( + "SELECT id, cwd, updated_at, has_user_event \ + FROM threads \ + WHERE archived = 0 \ + ORDER BY updated_at DESC, created_at DESC", + ) + .load::(&mut connection) + .map_err(|error| error.to_string())?; + + let mut threads_by_cwd = HashMap::::new(); + for row in rows { + let prefer_row = match threads_by_cwd.get(&row.cwd) { + None => true, + Some(existing) => { + (row.has_user_event != 0 && existing.has_user_event == 0) + || (row.has_user_event == existing.has_user_event + && row.updated_at > existing.updated_at) + } + }; + if prefer_row { + threads_by_cwd.insert(row.cwd.clone(), row); + } + } + + Ok(task_cwds + .iter() + .filter_map(|(task_id, cwd)| { + threads_by_cwd + .get(cwd) + .map(|row| (task_id.clone(), row.id.clone())) + }) + .collect()) +} + +fn unloaded_codex_task_runtime( + task_state: &crate::WorkspaceTaskRuntimeSnapshot, +) -> (RootSessionStatus, AutomationAgentState) { + match task_state.agent_state { + Some(AutomationAgentState::Question) => { + (RootSessionStatus::Question, AutomationAgentState::Question) + } + Some(AutomationAgentState::Stale) => (RootSessionStatus::Idle, AutomationAgentState::Stale), + _ => (RootSessionStatus::Idle, AutomationAgentState::Review), + } +} + +fn codex_task_metadata_from_turns( + turns: &[super::codex_app_server::CodexThreadTurn], + issue_url: &str, +) -> CodexTaskMetadata { + let mut repositories = std::collections::BTreeSet::new(); + let mut issues = std::collections::BTreeSet::from([issue_url.to_string()]); + let mut prs = std::collections::BTreeSet::new(); + + for turn in turns { + for item in &turn.items { + if item.get("type").and_then(serde_json::Value::as_str) != Some("agentMessage") { + continue; + } + let Some(text) = item.get("text").and_then(serde_json::Value::as_str) else { + continue; + }; + repositories.extend(extract_multicode_tag_values(text, "repo")); + issues.extend(extract_multicode_tag_values(text, "issue")); + prs.extend(extract_multicode_tag_values(text, "pr")); + } + } + + CodexTaskMetadata { + repositories: repositories.into_iter().collect(), + issues: issues.into_iter().collect(), + prs: prs.into_iter().collect(), + } +} + +fn extract_multicode_tag_values(text: &str, tag: &str) -> Vec { + let opening = format!(""); + let closing = format!(""); + let mut values = Vec::new(); + let mut search_start = 0; + + while let Some(open_index) = text[search_start..].find(&opening) { + let content_start = search_start + open_index + opening.len(); + let Some(close_index) = text[content_start..].find(&closing) else { + break; + }; + let content_end = content_start + close_index; + let value = text[content_start..content_end].trim(); + if !value.is_empty() { + values.push(value.to_string()); + } + search_start = content_end + closing.len(); + } + + values +} + +fn codex_thread_status_to_root_session_status( + status: &super::codex_app_server::CodexThreadStatus, +) -> RootSessionStatus { + if status.waits_for_human_input() { + RootSessionStatus::Question + } else if status.is_idle() { + RootSessionStatus::Idle + } else { + RootSessionStatus::Busy + } +} + +fn codex_task_thread_status_to_agent_state( + status: &super::codex_app_server::CodexThreadStatus, +) -> AutomationAgentState { + match status { + super::codex_app_server::CodexThreadStatus::SystemError => AutomationAgentState::Stale, + _ if status.waits_for_human_input() => AutomationAgentState::Question, + _ if status.is_idle() => AutomationAgentState::Review, + _ => AutomationAgentState::Working, + } +} + +fn set_task_runtime_state_from_codex( + workspace: &Workspace, + task_id: &str, + session_id: &str, + session_status: RootSessionStatus, + agent_state: AutomationAgentState, + metadata: Option, +) { + workspace.update(|snapshot| { + let mut changed = false; + let is_active = snapshot.active_task_id.as_deref() == Some(task_id); + if is_active { + if snapshot.automation_session_id.as_deref() != Some(session_id) { + snapshot.automation_session_id = Some(session_id.to_string()); + changed = true; + } + if snapshot.automation_agent_state != Some(agent_state) { + snapshot.automation_agent_state = Some(agent_state); + changed = true; + } + if snapshot.automation_session_status != Some(session_status) { + snapshot.automation_session_status = Some(session_status); + changed = true; + } + } + let task_state = snapshot.task_states.entry(task_id.to_string()).or_default(); + if task_state.session_id.as_deref() != Some(session_id) { + task_state.session_id = Some(session_id.to_string()); + changed = true; + } + if task_state.agent_state != Some(agent_state) { + task_state.agent_state = Some(agent_state); + changed = true; + } + if task_state.session_status != Some(session_status) { + task_state.session_status = Some(session_status); + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + if let Some(metadata) = metadata { + if task_state.repository != metadata.repositories { + task_state.repository = metadata.repositories; + changed = true; + } + if task_state.issue != metadata.issues { + task_state.issue = metadata.issues; + changed = true; + } + if task_state.pr != metadata.prs { + task_state.pr = metadata.prs; + changed = true; + } + } + changed + }); } fn issue_progress_status( @@ -668,6 +1626,7 @@ fn issue_progress_status( let _ = assigned_repository; match agent_state.unwrap_or(AutomationAgentState::Idle) { AutomationAgentState::Working => format!("Working {issue_ref}"), + AutomationAgentState::WaitingOnVm => format!("Waiting on VM {issue_ref}"), AutomationAgentState::Question => format!("Question {issue_ref}"), AutomationAgentState::Review => format!("Review {issue_ref}"), AutomationAgentState::Idle => format!("Wait close {issue_ref}"), @@ -690,6 +1649,59 @@ fn issue_is_closed(issue_status_rx: Option<&watch::Receiver ) } +fn sync_task_issue_status_receivers( + service: &CombinedService, + snapshot: &WorkspaceSnapshot, + task_issue_status_rxs: &mut HashMap>>, +) { + let tracked_issue_urls = snapshot + .persistent + .tasks + .iter() + .map(|task| task.issue_url.as_str()) + .collect::>(); + task_issue_status_rxs.retain(|issue_url, _| tracked_issue_urls.contains(issue_url.as_str())); + for task in &snapshot.persistent.tasks { + task_issue_status_rxs + .entry(task.issue_url.clone()) + .or_insert_with(|| { + service + .github_status_service() + .watch_status(&task.issue_url) + .expect("queued workspace tasks must have valid GitHub issue URLs") + }); + } +} + +fn request_refresh_for_task_issues<'a>( + service: &CombinedService, + issue_urls: impl Iterator, + exclude_issue_url: Option<&str>, +) { + for issue_url in issue_urls { + if Some(issue_url) == exclude_issue_url { + continue; + } + let _ = service.github_status_service().request_refresh(issue_url); + } +} + +fn closed_background_task_issue_urls( + snapshot: &WorkspaceSnapshot, + active_issue_url: Option<&str>, + task_issue_status_rxs: &HashMap>>, +) -> Vec { + snapshot + .persistent + .tasks + .iter() + .filter(|task| Some(task.issue_url.as_str()) != active_issue_url) + .filter_map(|task| { + issue_is_closed(task_issue_status_rxs.get(&task.issue_url)).then(|| task.issue_url.clone()) + }) + .collect() +} + async fn wait_for_workspace_change_until( workspace_rx: &mut watch::Receiver, issue_status_rx: &mut Option>>, @@ -719,14 +1731,260 @@ async fn wait_for_workspace_change_until( } } -async fn prompt_root_session( +async fn ensure_task_session( + service: &CombinedService, + workspace: &Workspace, + snapshot: &WorkspaceSnapshot, + workspace_key: &str, + assigned_repository: &str, + issue: &SelectedIssue, +) -> Result { + let task_cwd = service + .ensure_workspace_task_checkout(workspace_key, assigned_repository, &issue.url) + .await + .map_err(|err| err.summary())?; + let task_id = task_id_for_issue(snapshot, &issue.url) + .ok_or_else(|| format!("task missing for issue {}", issue.url))?; + if let Some(existing) = snapshot + .task_states + .get(&task_id) + .and_then(|state| state.session_id.clone()) + { + let mut reuse_existing = true; + if service.agent_provider() == AgentProvider::Codex { + let Some(uri) = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + else { + return Err("workspace has no active runtime uri".to_string()); + }; + match CodexAppServerClient::new(uri).thread_read(&existing).await { + Ok(response) => { + reuse_existing = response.thread.id.as_deref() == Some(existing.as_str()) + && response.thread.status.as_ref().is_some_and(|status| { + !matches!(status, super::codex_app_server::CodexThreadStatus::NotLoaded) + && !status.requires_replacement() + }); + } + Err(error) => { + reuse_existing = !(error.contains("thread not loaded") + || error.contains("thread not found")); + if !reuse_existing { + tracing::info!( + workspace_key, + issue_url = %issue.url, + task_id = %task_id, + session_id = %existing, + error = %error, + "discarding stale autonomous codex task session" + ); + } + } + } + } + if reuse_existing { + tracing::info!( + workspace_key, + issue_url = %issue.url, + task_id = %task_id, + session_id = %existing, + cwd = %task_cwd.display(), + "reusing existing autonomous task session" + ); + return Ok(existing); + } + } + + let session_id = match service.agent_provider() { + AgentProvider::Opencode => { + let client = snapshot + .opencode_client + .as_ref() + .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; + let root_session_id = snapshot + .root_session_id + .clone() + .ok_or_else(|| "workspace has no root session id".to_string())?; + let root_session_id = + opencode::client::types::SessionForkSessionId::try_from(root_session_id.as_str()) + .map_err(|err| format!("invalid root session id '{root_session_id}': {err}"))?; + client + .client + .session_fork( + &root_session_id, + None, + None, + &opencode::client::types::SessionForkBody::default(), + ) + .await + .map_err(|err| format!("failed to fork task session: {err}"))? + .into_inner() + .id + .to_string() + } + AgentProvider::Codex => { + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| "workspace has no active runtime uri".to_string())?; + let client = CodexAppServerClient::new(uri); + let session_id = client + .thread_start(task_cwd.to_string_lossy().as_ref(), &service.config.agent.codex) + .await? + .thread + .id; + wait_for_codex_task_thread_ready(&client, &session_id).await?; + session_id + } + }; + + tracing::info!( + workspace_key, + issue_url = %issue.url, + task_id = %task_id, + session_id = %session_id, + cwd = %task_cwd.display(), + "created autonomous task session" + ); + + workspace.update(|next| { + let task_state = next.task_states.entry(task_id.clone()).or_default(); + if task_state.session_id.as_deref() == Some(session_id.as_str()) { + false + } else { + task_state.session_id = Some(session_id.clone()); + true + } + }); + + Ok(session_id) +} + +async fn prompt_task_session( service: &CombinedService, snapshot: &WorkspaceSnapshot, assigned_repository: &str, issue: &SelectedIssue, + task_session_id: &str, + cwd: std::path::PathBuf, +) -> Result<(), String> { + let prompt = build_issue_prompt(assigned_repository, issue, task_session_id, &cwd); + match service.agent_provider() { + AgentProvider::Opencode => { + let opencode_client = snapshot + .opencode_client + .as_ref() + .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; + let session_id = task_session_id + .parse::() + .map_err(|err| format!("invalid task session id '{task_session_id}': {err}"))?; + let prompt_body = opencode::client::types::SessionPromptAsyncBody { + agent: None, + format: None, + message_id: None, + model: None, + no_reply: None, + parts: vec![ + opencode::client::types::TextPartInput { + id: None, + ignored: None, + metadata: Default::default(), + synthetic: None, + text: prompt, + time: None, + type_: opencode::client::types::TextPartInputType::Text, + } + .into(), + ], + system: None, + tools: Default::default(), + variant: None, + }; + opencode_client + .client + .session_prompt_async(&session_id, None, None, &prompt_body) + .await + .map(|_| ()) + .map_err(|err| format!("failed to send prompt: {err}")) + } + AgentProvider::Codex => { + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| "workspace has no active runtime uri".to_string())?; + let _ = cwd; + let client = CodexAppServerClient::new(uri); + let mut last_error: Option = None; + for attempt in 0..5 { + match client + .turn_start(task_session_id, &prompt, &service.config.agent.codex) + .await + { + Ok(_) => return Ok(()), + Err(error) + if (error.contains("thread not found") + || error.contains("thread not loaded")) + && attempt < 4 => + { + last_error = Some(error); + wait_for_codex_task_thread_ready(&client, task_session_id).await?; + sleep(Duration::from_millis(200)).await; + } + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or_else(|| { + format!("failed to start codex turn for task session {task_session_id}") + })) + } + } +} + +async fn wait_for_codex_task_thread_ready( + client: &CodexAppServerClient, + task_session_id: &str, ) -> Result<(), String> { - let prompt = build_issue_prompt(assigned_repository, issue); - service.prompt_root_session(snapshot, &prompt).await + let mut last_error: Option = None; + for attempt in 0..25 { + match client.thread_read(task_session_id).await { + Ok(response) => { + let status_ready = response.thread.status.as_ref().is_some_and(|status| { + !matches!(status, super::codex_app_server::CodexThreadStatus::NotLoaded) + }); + if status_ready { + return Ok(()); + } + last_error = Some(format!( + "thread '{task_session_id}' read succeeded but is not materialized yet" + )); + } + Err(error) + if error.contains("thread not found") || error.contains("thread not loaded") => + { + last_error = Some(error); + } + Err(error) => return Err(error), + } + if attempt < 24 { + sleep(Duration::from_millis(200)).await; + } + } + + Err(last_error.unwrap_or_else(|| { + format!("timed out waiting for codex task thread '{task_session_id}' to materialize") + })) +} + +fn task_cwd_path( + service: &CombinedService, + workspace_key: &str, + assigned_repository: &str, + issue_url: &str, +) -> std::path::PathBuf { + service.workspace_task_checkout_path(workspace_key, assigned_repository, issue_url) } async fn clear_automation_state_file(service: &CombinedService, workspace_key: &str) { @@ -740,15 +1998,22 @@ async fn clear_automation_state_file(service: &CombinedService, workspace_key: & } } -fn build_issue_prompt(assigned_repository: &str, issue: &SelectedIssue) -> String { +fn build_issue_prompt( + assigned_repository: &str, + issue: &SelectedIssue, + task_session_id: &str, + cwd: &std::path::Path, +) -> String { format!( "You are operating in an autonomous multicode workspace for repository {assigned_repository}.\n\ Start work on GitHub issue {issue_url}.\n\ Issue title: {issue_title}\n\ +Primary checkout for this task: {cwd}\n\ Before you proceed, load and follow these workspace skills as appropriate: `independent-fix`, `machine-readable-clone`, `machine-readable-issue`, `machine-readable-pr`, `git-commit-coauthorship`, `micronaut-projects-guide`, and `autonomous-state`.\n\ The environment variable `{automation_state_env}` points to a multicode-owned state file. Maintain it throughout the run using the `autonomous-state` skill so multicode can track whether you are working, waiting for a question, or ready for review.\n\ +For this task session/thread, write autonomous state updates in the format `:{task_session_id}` so multicode can attribute the state to this specific session.\n\ Your job is to:\n\ -1. Ensure the repository is available in this workspace.\n\ +1. Use the existing checkout at `{cwd}` for this issue. Keep this task isolated to that checkout instead of sharing another task's repository state.\n\ 2. Understand and reproduce the issue, creating a minimal reproducer or failing test when possible.\n\ 3. Implement the fix.\n\ 4. Run focused verification and summarize the evidence.\n\ @@ -759,7 +2024,9 @@ Your job is to:\n\ Prefer an upstream pull request if you have write access. Keep going until the workspace is ready for review or you need human feedback.", automation_state_env = AUTOMATION_STATE_ENV, issue_url = issue.url, - issue_title = issue.title + issue_title = issue.title, + task_session_id = task_session_id, + cwd = cwd.display() ) } @@ -1128,6 +2395,7 @@ struct SelectedIssueLabel { #[cfg(test)] mod tests { use super::*; + use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; use crate::WorkspaceSnapshot; #[test] @@ -1355,7 +2623,7 @@ mod tests { } #[test] - fn persist_automation_issue_claim_updates_workspace_state() { + fn ensure_workspace_task_claim_updates_workspace_state() { let workspace = Workspace::new(WorkspaceSnapshot::default()); let issue = SelectedIssue { number: 810, @@ -1367,16 +2635,144 @@ mod tests { labels: vec![], }; - persist_automation_issue_claim(&workspace, "example/repo", &issue); + ensure_workspace_task_claim( + &workspace, + "example/repo", + &issue, + WorkspaceTaskSource::Scan, + ); let snapshot = workspace.subscribe().borrow().clone(); assert_eq!( snapshot.persistent.assigned_repository.as_deref(), Some("example/repo") ); + assert!(snapshot.persistent.automation_issue.is_none()); + assert_eq!(snapshot.persistent.tasks.len(), 1); + assert_eq!(snapshot.persistent.tasks[0].issue_url, issue.url); assert_eq!( - snapshot.persistent.automation_issue.as_deref(), - Some(issue.url.as_str()) + snapshot.persistent.tasks[0].source, + WorkspaceTaskSource::Scan + ); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-810")); + } + + #[test] + fn sync_task_runtime_state_prunes_stale_entries_and_derives_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-810".to_string(), + "https://github.com/example/repo/issues/810".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/810".to_string()); + snapshot.task_states.insert( + "task-stale".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + waiting_on_vm: true, + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("stale-session".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-810")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/810") + ); + assert!(!next.task_states.contains_key("task-stale")); + let task_state = next + .task_states + .get("task-810") + .expect("task state should exist"); + assert_eq!(task_state.agent_state, None); + assert!(!task_state.waiting_on_vm); + } + + #[test] + fn sync_task_runtime_state_clears_bridge_state_without_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-missing".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/999".to_string()); + snapshot.automation_session_id = Some("stale-session".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(next.active_task_id.is_none()); + assert!(next.persistent.automation_issue.is_none()); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + } + + #[test] + fn sync_task_runtime_state_preserves_live_non_yieldable_active_task_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-48".to_string(), + "https://github.com/example/repo/issues/48".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-48".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/48".to_string()); + snapshot.task_states.insert( + "task-48".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-48".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + let mut stale_snapshot = workspace.subscribe().borrow().clone(); + stale_snapshot.active_task_id = Some("task-42".to_string()); + stale_snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/42".to_string()); + + sync_task_runtime_state(&workspace, &stale_snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-48")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/48") ); } @@ -1393,7 +2789,12 @@ mod tests { labels: vec![], }; - persist_automation_issue_claim(&workspace, "example/repo", &issue); + ensure_workspace_task_claim( + &workspace, + "example/repo", + &issue, + WorkspaceTaskSource::Scan, + ); clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/999"); let unchanged = workspace.subscribe().borrow().clone(); assert_eq!( @@ -1404,12 +2805,62 @@ mod tests { clear_automation_issue_claim(&workspace, &issue.url); let cleared = workspace.subscribe().borrow().clone(); assert!(cleared.persistent.automation_issue.is_none()); + assert!(cleared.persistent.tasks.is_empty()); assert_eq!( cleared.persistent.assigned_repository.as_deref(), Some("example/repo") ); } + #[test] + fn clear_automation_issue_claim_promotes_next_task_to_active_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-810".to_string(), + "https://github.com/example/repo/issues/810".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-811".to_string(), + "https://github.com/example/repo/issues/811".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-810".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/810".to_string()); + snapshot.task_states.insert( + "task-810".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-810".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/810"); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-811")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/811") + ); + assert!(!next.task_states.contains_key("task-810")); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + } + #[test] fn issue_progress_status_uses_explicit_automation_states() { assert_eq!( @@ -1420,6 +2871,14 @@ mod tests { ), "Working example/repo#42" ); + assert_eq!( + issue_progress_status( + "example/repo", + "https://github.com/example/repo/issues/42", + Some(AutomationAgentState::WaitingOnVm), + ), + "Waiting on VM example/repo#42" + ); assert_eq!( issue_progress_status( "example/repo", @@ -1438,6 +2897,712 @@ mod tests { ); } + #[test] + fn sync_task_runtime_state_marks_only_blocked_non_active_tasks_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-3".to_string(), + "https://github.com/example/repo/issues/3".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-4".to_string(), + "https://github.com/example/repo/issues/4".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-3".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-4".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(!next + .task_states + .get("task-1") + .expect("active task should exist") + .waiting_on_vm); + assert!(!next + .task_states + .get("task-2") + .expect("question task should exist") + .waiting_on_vm); + assert!(!next + .task_states + .get("task-3") + .expect("idle task should exist") + .waiting_on_vm); + let waiting_task = next + .task_states + .get("task-4") + .expect("blocked task should exist"); + assert!(waiting_task.waiting_on_vm); + assert_eq!( + waiting_task.agent_state, + Some(AutomationAgentState::WaitingOnVm) + ); + } + + #[test] + fn sync_task_runtime_state_relabels_blocked_working_task_as_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-2".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + let task_state = next + .task_states + .get("task-2") + .expect("blocked task should exist"); + assert!(task_state.waiting_on_vm); + assert_eq!( + task_state.agent_state, + Some(AutomationAgentState::WaitingOnVm) + ); + } + + #[test] + fn closed_background_task_issue_urls_only_returns_closed_non_active_tasks() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-26".to_string(), + "https://github.com/example/repo/issues/26".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-16".to_string(), + "https://github.com/example/repo/issues/16".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-14".to_string(), + "https://github.com/example/repo/issues/14".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-16".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/16".to_string()); + + let (closed_sender, closed_rx) = watch::channel(Some(GithubStatus::Issue( + super::super::github_status_service::GithubIssueStatus { + state: super::super::github_status_service::GithubIssueState::Closed, + fetched_at: std::time::SystemTime::now(), + }, + ))); + let (_open_sender, open_rx) = watch::channel(Some(GithubStatus::Issue( + super::super::github_status_service::GithubIssueStatus { + state: super::super::github_status_service::GithubIssueState::Open, + fetched_at: std::time::SystemTime::now(), + }, + ))); + let mut task_issue_status_rxs = HashMap::new(); + task_issue_status_rxs.insert( + "https://github.com/example/repo/issues/26".to_string(), + closed_rx, + ); + task_issue_status_rxs.insert( + "https://github.com/example/repo/issues/16".to_string(), + open_rx, + ); + drop(closed_sender); + + assert_eq!( + closed_background_task_issue_urls( + &snapshot, + Some("https://github.com/example/repo/issues/16"), + &task_issue_status_rxs + ), + vec!["https://github.com/example/repo/issues/26".to_string()] + ); + } + + #[test] + fn next_schedulable_task_issue_url_skips_tasks_with_live_or_terminal_agent_states() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-3".to_string(), + "https://github.com/example/repo/issues/3".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-4".to_string(), + "https://github.com/example/repo/issues/4".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-3".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert_eq!( + next_schedulable_task_issue_url(&snapshot, None).as_deref(), + Some("https://github.com/example/repo/issues/4") + ); + assert_eq!( + next_schedulable_task_issue_url( + &snapshot, + Some("https://github.com/example/repo/issues/4") + ), + None + ); + } + + #[test] + fn task_can_yield_vm_only_for_non_working_states() { + assert!(!task_can_yield_vm(Some(AutomationAgentState::Working))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Question))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Review))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Idle))); + assert!(task_can_yield_vm(Some(AutomationAgentState::Stale))); + assert!(!task_can_yield_vm(None)); + } + + #[test] + fn active_task_can_yield_vm_requires_existing_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: None, + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + + assert!(!active_task_can_yield_vm(&snapshot)); + + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: Some(AutomationAgentState::Idle), + ..Default::default() + }, + ); + + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn active_task_can_yield_vm_requires_explicit_task_state() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Idle); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: None, + ..Default::default() + }, + ); + + assert!(!active_task_can_yield_vm(&snapshot)); + + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-5".to_string()), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn set_automation_runtime_state_preserves_existing_task_session() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-5".to_string(), + "https://github.com/example/repo/issues/5".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-5".to_string()); + snapshot.task_states.insert( + "task-5".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-5".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + set_automation_runtime_state( + &workspace, + Some("root-session".to_string()), + Some(RootSessionStatus::Idle), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("root-session") + ); + let task_state = snapshot + .task_states + .get("task-5") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("task-session-5")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn set_automation_runtime_state_updates_matching_task_session_to_working() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-7".to_string(), + "https://github.com/example/repo/issues/7".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-7".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/7".to_string()); + snapshot.task_states.insert( + "task-7".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-7".to_string()), + ..Default::default() + }, + ); + true + }); + + set_automation_runtime_state( + &workspace, + Some("task-session-7".to_string()), + Some(RootSessionStatus::Busy), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("task-session-7") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-7") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("task-session-7")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn codex_task_thread_status_maps_idle_to_review() { + assert_eq!( + codex_task_thread_status_to_agent_state(&CodexThreadStatus::Idle), + AutomationAgentState::Review + ); + assert_eq!( + codex_thread_status_to_root_session_status(&CodexThreadStatus::Idle), + RootSessionStatus::Idle + ); + } + + #[test] + fn codex_task_thread_status_maps_waiting_flags_to_question() { + let status = CodexThreadStatus::Active { + active_flags: vec![CodexThreadActiveFlag::WaitingOnApproval], + }; + + assert_eq!( + codex_task_thread_status_to_agent_state(&status), + AutomationAgentState::Question + ); + assert_eq!( + codex_thread_status_to_root_session_status(&status), + RootSessionStatus::Question + ); + } + + #[test] + fn set_task_runtime_state_from_codex_marks_reviewing_task_yieldable() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-7".to_string(), + "https://github.com/example/repo/issues/7".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-7".to_string()); + snapshot.task_states.insert( + "task-7".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-7".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-7", + "task-session-7", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + issues: vec!["https://github.com/example/repo/issues/7".to_string()], + prs: vec!["https://github.com/example/repo/pull/11".to_string()], + ..Default::default() + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!( + snapshot.automation_session_id.as_deref(), + Some("task-session-7") + ); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Review) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Idle) + ); + let task_state = snapshot + .task_states + .get("task-7") + .expect("task state should remain"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + assert_eq!( + task_state.issue, + vec!["https://github.com/example/repo/issues/7".to_string()] + ); + assert_eq!( + task_state.pr, + vec!["https://github.com/example/repo/pull/11".to_string()] + ); + assert!(active_task_can_yield_vm(&snapshot)); + } + + #[test] + fn unloaded_codex_task_runtime_preserves_question_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Question), + ..Default::default() + }; + + assert_eq!( + unloaded_codex_task_runtime(&task_state), + (RootSessionStatus::Question, AutomationAgentState::Question) + ); + } + + #[test] + fn codex_task_metadata_from_turns_extracts_issue_and_pr_tags() { + let turns = vec![crate::services::codex_app_server::CodexThreadTurn { + items: vec![ + serde_json::json!({ + "type": "agentMessage", + "text": "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1\nhttps://github.com/graemerocher/multicode-test/issues/1\nhttps://github.com/graemerocher/multicode-test/pull/8" + }), + ], + }]; + + let metadata = codex_task_metadata_from_turns( + &turns, + "https://github.com/graemerocher/multicode-test/issues/1", + ); + + assert_eq!( + metadata.repositories, + vec!["/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1".to_string()] + ); + assert_eq!( + metadata.issues, + vec!["https://github.com/graemerocher/multicode-test/issues/1".to_string()] + ); + assert_eq!( + metadata.prs, + vec!["https://github.com/graemerocher/multicode-test/pull/8".to_string()] + ); + } + + #[test] + fn non_active_codex_working_task_is_rendered_waiting_on_vm() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.active_task_id = Some("task-12".to_string()); + + let next_agent_state = codex_task_thread_status_to_agent_state(&status); + let next_agent_state = if snapshot.active_task_id.as_deref() != Some("task-7") + && next_agent_state == AutomationAgentState::Working + { + AutomationAgentState::WaitingOnVm + } else { + next_agent_state + }; + + assert_eq!(next_agent_state, AutomationAgentState::WaitingOnVm); + } + + #[test] + fn should_start_assigned_issue_work_when_active_task_has_no_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Idle); + snapshot.automation_session_status = Some(RootSessionStatus::Idle); + + assert!(should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + } + + #[test] + fn should_not_start_assigned_issue_work_when_active_task_already_has_session() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-6".to_string(), + "https://github.com/example/repo/issues/6".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-6".to_string()); + snapshot.task_states.insert( + "task-6".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-6".to_string()), + ..Default::default() + }, + ); + + assert!(!should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Idle + )); + assert!(!should_start_assigned_issue_work( + &snapshot, + RootSessionStatus::Busy + )); + } + + #[test] + fn should_not_bridge_root_runtime_state_for_codex() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.root_session_id = Some("root-thread".to_string()); + + assert!(!should_bridge_root_runtime_state( + AgentProvider::Codex, + &snapshot, + false, + RootSessionStatus::Busy, + )); + } + + #[test] + fn should_bridge_root_runtime_state_for_non_codex_busy_root() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot.root_session_id = Some("root-thread".to_string()); + + assert!(should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + false, + RootSessionStatus::Busy, + )); + assert!(!should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + true, + RootSessionStatus::Busy, + )); + assert!(!should_bridge_root_runtime_state( + AgentProvider::Opencode, + &snapshot, + false, + RootSessionStatus::Idle, + )); + } + #[test] fn build_issue_prompt_requires_skills_and_publish_approval() { let issue = SelectedIssue { @@ -1450,12 +3615,22 @@ mod tests { labels: vec![], }; - let prompt = build_issue_prompt("example/repo", &issue); + let prompt = build_issue_prompt( + "example/repo", + &issue, + "thread-task-980", + std::path::Path::new("/tmp/work/example-repo-980"), + ); assert!(prompt.contains("`independent-fix`")); assert!(prompt.contains("`machine-readable-pr`")); assert!(prompt.contains("`autonomous-state`")); assert!(prompt.contains(AUTOMATION_STATE_ENV)); + assert!(prompt.contains("Primary checkout for this task: /tmp/work/example-repo-980")); + assert!(prompt.contains("Use the existing checkout at `/tmp/work/example-repo-980`")); + assert!(prompt.contains( + "write autonomous state updates in the format `:thread-task-980`" + )); assert!(prompt.contains( "Run repository commands, builds, Gradle tasks, and focused tests as needed without asking for permission." )); diff --git a/lib/src/services/codex_app_server.rs b/lib/src/services/codex_app_server.rs index 27012d0..a591f98 100644 --- a/lib/src/services/codex_app_server.rs +++ b/lib/src/services/codex_app_server.rs @@ -1,4 +1,11 @@ -use std::time::Duration; +use std::{ + collections::HashMap, + sync::{ + Arc, OnceLock, + atomic::{AtomicI64, Ordering}, + }, + time::Duration, +}; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; @@ -8,13 +15,27 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use super::config::{CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}; const INITIALIZE_REQUEST_ID: i64 = 1; -const REQUEST_ID: i64 = 2; +const INITIAL_REQUEST_ID: i64 = 2; + +type CodexSocket = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, +>; + +static NEXT_REQUEST_ID: AtomicI64 = AtomicI64::new(INITIAL_REQUEST_ID); +static SHARED_CONNECTIONS: OnceLock>>> = + OnceLock::new(); #[derive(Debug, Clone)] pub struct CodexAppServerClient { uri: String, } +#[derive(Debug)] +struct SharedCodexConnection { + uri: String, + socket: tokio::sync::Mutex>, +} + impl CodexAppServerClient { pub fn new(uri: impl Into) -> Self { Self { uri: uri.into() } @@ -53,11 +74,19 @@ impl CodexAppServerClient { } pub async fn thread_read(&self, thread_id: &str) -> Result { + self.thread_read_with_turns(thread_id, false).await + } + + pub async fn thread_read_with_turns( + &self, + thread_id: &str, + include_turns: bool, + ) -> Result { self.request( "thread/read", json!({ "threadId": thread_id, - "includeTurns": true, + "includeTurns": include_turns, }), ) .await @@ -108,108 +137,165 @@ impl CodexAppServerClient { where T: for<'de> Deserialize<'de>, { - let mut socket = self.connect_initialized().await?; - socket - .send(Message::Text( - json!({ - "jsonrpc": "2.0", - "id": REQUEST_ID, - "method": method, - "params": params, - }) - .to_string() - .into(), - )) + let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + self.shared_connection() + .request(request_id, method, params) .await - .map_err(|err| err.to_string())?; + } - while let Some(message) = socket.next().await { - let message = message.map_err(|err| err.to_string())?; - let Some(text) = message_to_text(message) else { + async fn connect_initialized( + &self, + ) -> Result { + connect_initialized_socket(&self.uri).await + } + + fn shared_connection(&self) -> Arc { + let registry = SHARED_CONNECTIONS.get_or_init(Default::default); + let mut registry = registry.lock().expect("codex shared connection registry poisoned"); + registry + .entry(self.uri.clone()) + .or_insert_with(|| { + Arc::new(SharedCodexConnection { + uri: self.uri.clone(), + socket: tokio::sync::Mutex::new(None), + }) + }) + .clone() + } +} + +impl SharedCodexConnection { + async fn request(&self, request_id: i64, method: &str, params: Value) -> Result + where + T: for<'de> Deserialize<'de>, + { + let request = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }) + .to_string(); + + let mut socket = self.socket.lock().await; + for attempt in 0..2 { + if socket.is_none() { + *socket = Some(connect_initialized_socket(&self.uri).await?); + } + + let Some(active_socket) = socket.as_mut() else { continue; }; - let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; - if value.get("id").and_then(Value::as_i64) != Some(REQUEST_ID) { - continue; - } - if let Some(result) = value.get("result") { - return serde_json::from_value(result.clone()).map_err(|err| err.to_string()); + + if let Err(err) = active_socket.send(Message::Text(request.clone().into())).await { + *socket = None; + if attempt == 0 { + continue; + } + return Err(err.to_string()); } - if let Some(error) = value.get("error") { - return Err(error.to_string()); + + loop { + match active_socket.next().await { + Some(Ok(message)) => { + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = + serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(request_id) { + continue; + } + if let Some(result) = value.get("result") { + return serde_json::from_value(result.clone()) + .map_err(|err| err.to_string()); + } + if let Some(error) = value.get("error") { + return Err(error.to_string()); + } + } + Some(Err(err)) => { + *socket = None; + if attempt == 0 { + break; + } + return Err(err.to_string()); + } + None => { + *socket = None; + if attempt == 0 { + break; + } + return Err(format!( + "codex app-server connection to '{}' closed", + self.uri + )); + } + } } } Err(format!( - "codex app-server connection to '{}' closed", - self.uri + "failed to complete codex app-server request '{}' for '{}'", + method, self.uri )) } +} - async fn connect_initialized( - &self, - ) -> Result< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - String, - > { - let (mut socket, _) = connect_async(self.uri.as_str()) - .await - .map_err(|err| err.to_string())?; +async fn connect_initialized_socket(uri: &str) -> Result { + let (mut socket, _) = connect_async(uri).await.map_err(|err| err.to_string())?; + socket + .send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": INITIALIZE_REQUEST_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "multicode", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { + "experimentalApi": true, + }, + } + }) + .to_string() + .into(), + )) + .await + .map_err(|err| err.to_string())?; + + while let Some(message) = socket.next().await { + let message = message.map_err(|err| err.to_string())?; + let Some(text) = message_to_text(message) else { + continue; + }; + let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; + if value.get("id").and_then(Value::as_i64) != Some(INITIALIZE_REQUEST_ID) { + continue; + } + if value.get("error").is_some() { + return Err(value["error"].to_string()); + } socket .send(Message::Text( json!({ "jsonrpc": "2.0", - "id": INITIALIZE_REQUEST_ID, - "method": "initialize", - "params": { - "clientInfo": { - "name": "multicode", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": { - "experimentalApi": true, - }, - } + "method": "initialized", }) .to_string() .into(), )) .await .map_err(|err| err.to_string())?; - - while let Some(message) = socket.next().await { - let message = message.map_err(|err| err.to_string())?; - let Some(text) = message_to_text(message) else { - continue; - }; - let value: Value = serde_json::from_str(&text).map_err(|err| err.to_string())?; - if value.get("id").and_then(Value::as_i64) != Some(INITIALIZE_REQUEST_ID) { - continue; - } - if value.get("error").is_some() { - return Err(value["error"].to_string()); - } - socket - .send(Message::Text( - json!({ - "jsonrpc": "2.0", - "method": "initialized", - }) - .to_string() - .into(), - )) - .await - .map_err(|err| err.to_string())?; - return Ok(socket); - } - - Err(format!( - "codex app-server initialize did not complete for '{}'", - self.uri - )) + return Ok(socket); } + + Err(format!( + "codex app-server initialize did not complete for '{}'", + uri + )) } fn build_thread_start_params(cwd: &str, config: &CodexAgentConfig) -> Value { @@ -415,6 +501,10 @@ pub struct CodexTurn { #[derive(Debug, Clone, Deserialize)] pub struct CodexThreadRead { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, #[serde(default)] pub turns: Vec, } diff --git a/lib/src/services/codex_root_session_service.rs b/lib/src/services/codex_root_session_service.rs index 773d683..dfbc837 100644 --- a/lib/src/services/codex_root_session_service.rs +++ b/lib/src/services/codex_root_session_service.rs @@ -139,6 +139,7 @@ async fn sync_root_session( key: RootSessionTaskKey, config: CodexAgentConfig, ) { + tracing::info!(uri = %key.uri, cwd = %key.cwd, "starting codex root session sync"); let client = CodexAppServerClient::new(key.uri.clone()); let event_tx = broadcast::channel(256).0; let forwarder = tokio::spawn(forward_codex_notifications_forever( @@ -256,18 +257,113 @@ async fn refresh_root_session( ) { let response = match client.thread_list(&key.cwd).await { Ok(response) => response, - Err(_) => return, + Err(error) => { + tracing::warn!( + uri = %key.uri, + cwd = %key.cwd, + error = %error, + "failed to list codex root threads" + ); + return; + } }; + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + thread_count = response.data.len(), + "listed codex root threads" + ); let current_root_session_id = workspace.subscribe().borrow().root_session_id.clone(); - let thread = - match select_thread_for_tracking(current_root_session_id.as_deref(), &response.data) { - Some(thread) => thread, - None => match client.thread_start(&key.cwd, config).await { + let thread = match select_thread_for_tracking(current_root_session_id.as_deref(), &response.data) + { + Some(thread) => thread, + None => { + if let Some(current_root_session_id) = current_root_session_id.as_deref() { + match client.thread_read(current_root_session_id).await { + Ok(response) => { + if let Some(status) = response.thread.status.as_ref() { + if matches!( + status, + super::codex_app_server::CodexThreadStatus::NotLoaded + ) || status.requires_replacement() + { + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + status = ?status, + "clearing stale codex root thread after thread/read" + ); + clear_tracked_root_session(workspace, key, Some(current_root_session_id)); + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + status = ?status, + "retaining existing codex root thread based on thread/read" + ); + update_from_read( + workspace, + key, + current_root_session_id, + status, + active_turn_thread_id, + ); + return; + } + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + "retaining existing codex root thread without status from thread/read" + ); + return; + } + } + Err(error) => { + if error.contains("thread not loaded") { + tracing::info!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + error = %error, + "clearing stale codex root thread after thread/read failure" + ); + clear_tracked_root_session( + workspace, + key, + Some(current_root_session_id), + ); + } else { + tracing::debug!( + uri = %key.uri, + cwd = %key.cwd, + current_root_session_id, + error = %error, + "retaining existing codex root thread while it is not yet materialized in thread/list" + ); + return; + } + } + } + } + match client.thread_start(&key.cwd, config).await { Ok(response) => response.thread, - Err(_) => return, - }, - }; + Err(error) => { + tracing::warn!( + uri = %key.uri, + cwd = %key.cwd, + error = %error, + "failed to start codex root thread" + ); + return; + } + } + } + }; update_from_thread(workspace, key, &thread, active_turn_thread_id); } @@ -359,6 +455,65 @@ fn update_from_thread( }); } +fn update_from_read( + workspace: &Workspace, + key: &RootSessionTaskKey, + thread_id: &str, + status: &super::codex_app_server::CodexThreadStatus, + active_turn_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if snapshot.root_session_id.as_deref() != Some(thread_id) { + return false; + } + + let next_status = effective_status(thread_id, status, active_turn_thread_id); + if snapshot.root_session_status == Some(next_status) { + return false; + } + + snapshot.root_session_status = Some(next_status); + true + }); +} + +fn clear_tracked_root_session( + workspace: &Workspace, + key: &RootSessionTaskKey, + expected_thread_id: Option<&str>, +) { + workspace.update(|snapshot| { + if snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + != Some(key.uri.as_str()) + { + return false; + } + if expected_thread_id.is_some() + && snapshot.root_session_id.as_deref() != expected_thread_id + { + return false; + } + let changed = snapshot.root_session_id.is_some() + || snapshot.root_session_title.is_some() + || snapshot.root_session_status.is_some(); + snapshot.root_session_id = None; + snapshot.root_session_title = None; + snapshot.root_session_status = None; + changed + }); +} + fn effective_status( thread_id: &str, status: &super::codex_app_server::CodexThreadStatus, @@ -473,4 +628,40 @@ mod tests { assert_eq!(snapshot.root_session_title.as_deref(), Some("Current")); assert_eq!(snapshot.root_session_status, Some(RootSessionStatus::Busy)); } + + #[test] + fn select_thread_for_tracking_returns_none_when_current_thread_is_unmaterialized() { + assert!(select_thread_for_tracking(Some("thread-current"), &[]).is_none()); + } + + #[test] + fn clear_tracked_root_session_clears_matching_thread() { + let workspace = WorkspaceSnapshot::default(); + let workspace = crate::manager::Workspace::new(workspace); + let key = RootSessionTaskKey { + uri: "ws://127.0.0.1:31337".to_string(), + cwd: "/tmp/workspace".to_string(), + }; + workspace.update(|snapshot| { + snapshot.transient = Some(TransientWorkspaceSnapshot { + uri: key.uri.clone(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "runtime-1".to_string(), + metadata: Default::default(), + }, + }); + snapshot.root_session_id = Some("thread-current".to_string()); + snapshot.root_session_title = Some("Current".to_string()); + snapshot.root_session_status = Some(RootSessionStatus::Busy); + true + }); + + clear_tracked_root_session(&workspace, &key, Some("thread-current")); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.root_session_id, None); + assert_eq!(snapshot.root_session_title, None); + assert_eq!(snapshot.root_session_status, None); + } } diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index b02593c..3ae08db 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -8,6 +8,7 @@ use std::{ }; use tokio::process::Command; +use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnCommand { @@ -17,8 +18,8 @@ pub struct SpawnCommand { } use crate::{ - WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, database::Database, logging, - opencode, + WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, + WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, database::Database, logging, opencode, }; use super::{ @@ -283,17 +284,34 @@ impl CombinedService { .transpose()?; workspace.update(|snapshot| { - snapshot.persistent.automation_issue = normalized.clone(); + let Some(issue_url) = normalized.clone() else { + return false; + }; + if snapshot + .persistent + .tasks + .iter() + .any(|task| task.issue_url == issue_url) + { + snapshot.automation_status = Some(format!( + "Issue already queued for workspace '{key}': {}", + format_issue_reference(&issue_url) + )); + return true; + } + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + format!("task-{}", Uuid::new_v4().simple()), + issue_url.clone(), + WorkspaceTaskSource::Manual, + )); snapshot.persistent.automation_paused = false; - snapshot.automation_status = Some(match normalized.as_ref() { - Some(issue_url) => format!( - "Issue assigned; start queued for {}", - format_issue_reference(issue_url) - ), - None => { - format!("Issue cleared; scan queued for {assigned_repository}") - } - }); + snapshot.automation_status = Some(format!( + "Issue queued; start queued for {}", + format_issue_reference(&issue_url) + )); snapshot.automation_scan_request_nonce = snapshot.automation_scan_request_nonce.saturating_add(1); true @@ -307,7 +325,7 @@ impl CombinedService { let workspace = self.manager.get_workspace(&key)?; workspace.update(|snapshot| { if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { - if snapshot.persistent.automation_issue.is_none() { + if snapshot.active_task_id.is_none() { snapshot.automation_status = Some(format!("Scan requested for {repository}")); } } @@ -331,6 +349,9 @@ impl CombinedService { } snapshot.persistent.assigned_repository = repository.clone(); snapshot.persistent.automation_issue = None; + snapshot.persistent.tasks.clear(); + snapshot.active_task_id = None; + snapshot.task_states.clear(); snapshot.persistent.automation_paused = false; snapshot.automation_status = repository .as_ref() @@ -344,6 +365,112 @@ impl CombinedService { Ok(()) } + pub fn workspace_path_for_key(&self, key: &str) -> PathBuf { + self.workspace_directory_path.join(key) + } + + pub fn workspace_repo_root_path(&self, key: &str, repository: &str) -> PathBuf { + self.workspace_path_for_key(key) + .join(repository_repo_name(repository)) + } + + pub fn workspace_task_checkout_path( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> PathBuf { + self.workspace_path_for_key(key) + .join("work") + .join(format!( + "{}-{}", + repository_repo_name(repository), + issue_url_number(issue_url).unwrap_or("task") + )) + } + + pub async fn ensure_workspace_task_checkout( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> Result { + let key = validate_workspace_key(key)?; + let repository = normalize_repository_spec(repository)?; + let repo_root = self.workspace_repo_root_path(&key, &repository); + let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); + + tokio::fs::create_dir_all(self.workspace_path_for_key(&key)).await?; + self.ensure_repository_checkout(&repository, &repo_root).await?; + self.ensure_task_worktree(&repo_root, &task_root).await?; + unset_repo_local_git_config(&repo_root, "user.name").await?; + unset_repo_local_git_config(&repo_root, "user.email").await?; + unset_repo_local_git_config(&task_root, "user.name").await?; + unset_repo_local_git_config(&task_root, "user.email").await?; + + Ok(task_root) + } + + pub async fn remove_workspace_task_checkout( + &self, + key: &str, + repository: &str, + issue_url: &str, + ) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let repository = normalize_repository_spec(repository)?; + let repo_root = self.workspace_repo_root_path(&key, &repository); + let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); + + if !path_has_git_entry(&task_root).await? && tokio::fs::metadata(&task_root).await.is_err() { + return Ok(()); + } + + if path_has_git_entry(&repo_root).await? { + let mut command = Command::new(git_program()); + command + .arg("-C") + .arg(&repo_root) + .args(["worktree", "remove", "--force"]) + .arg(&task_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + + let output = command.output().await?; + if !output.status.success() + && path_has_git_entry(&task_root).await? + { + return Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to remove task worktree '{}': {}", + task_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + + let mut prune = Command::new(git_program()); + prune + .arg("-C") + .arg(&repo_root) + .args(["worktree", "prune"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + prune.env(name, value); + } + let _ = prune.status().await; + } + + if tokio::fs::metadata(&task_root).await.is_ok() { + remove_path_if_exists(&task_root).await?; + } + Ok(()) + } + pub async fn stop_workspace(&self, key: &str) -> Result<(), CombinedServiceError> { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; @@ -378,6 +505,13 @@ impl CombinedService { let workspace = self.manager.get_workspace(&key)?; let snapshot = workspace.subscribe().borrow().clone(); + if !snapshot.persistent.tasks.is_empty() { + return Err(CombinedServiceError::WorkspaceHasTasks { + key: key.clone(), + task_count: snapshot.persistent.tasks.len(), + }); + } + if let Some(transient) = snapshot.transient.as_ref() { self.runtime.stop_server(&transient.runtime).await?; } @@ -387,6 +521,92 @@ impl CombinedService { Ok(()) } + pub async fn delete_workspace_task( + &self, + key: &str, + task_id: &str, + ) -> Result<(), CombinedServiceError> { + let key = validate_workspace_key(key)?; + let workspace = self.manager.get_workspace(&key)?; + let snapshot = workspace.subscribe().borrow().clone(); + + let task = snapshot + .persistent + .tasks + .iter() + .find(|task| task.id == task_id) + .cloned() + .ok_or_else(|| CombinedServiceError::WorkspaceTaskMissing { + key: key.clone(), + task_id: task_id.to_string(), + })?; + let assigned_repository = snapshot.persistent.assigned_repository.clone(); + + if self.agent_provider == AgentProvider::Opencode + && let Some(task_state) = snapshot.task_states.get(task_id) + && let (Some(opencode_client), Some(session_id)) = ( + snapshot.opencode_client.as_ref(), + task_state.session_id.as_deref(), + ) + && let Ok(session_id) = + opencode::client::types::SessionDeleteSessionId::try_from(session_id) + { + let _ = opencode_client + .client + .session_delete(&session_id, None, None) + .await; + } + + if let Some(assigned_repository) = assigned_repository.as_deref() { + self.remove_workspace_task_checkout(&key, assigned_repository, &task.issue_url) + .await?; + } + + workspace.update(|next| { + let mut changed = false; + let before = next.persistent.tasks.len(); + next.persistent.tasks.retain(|entry| entry.id != task_id); + if next.persistent.tasks.len() != before { + changed = true; + } + if next.task_states.remove(task_id).is_some() { + changed = true; + } + if next.active_task_id.as_deref() == Some(task_id) { + next.active_task_id = next.persistent.tasks.first().map(|task| task.id.clone()); + next.automation_session_id = None; + next.automation_agent_state = None; + next.automation_session_status = None; + changed = true; + } else if next + .active_task_id + .as_deref() + .is_some_and(|active_task_id| { + !next.persistent.tasks.iter().any(|entry| entry.id == active_task_id) + }) + { + next.active_task_id = next.persistent.tasks.first().map(|entry| entry.id.clone()); + changed = true; + } + let next_active_issue = next + .active_task_id + .as_deref() + .and_then(|active_task_id| { + next.persistent + .tasks + .iter() + .find(|entry| entry.id == active_task_id) + .map(|entry| entry.issue_url.clone()) + }); + if next.persistent.automation_issue != next_active_issue { + next.persistent.automation_issue = next_active_issue; + changed = true; + } + changed + }); + Ok(()) + } + /// Build a command to run a user-defined exec-type tool. pub async fn build_exec_tool_command( &self, @@ -749,6 +969,98 @@ impl CombinedService { github_git_credentials_env_vars(self.github_git_credentials_env.as_ref()) } + async fn ensure_repository_checkout( + &self, + repository: &str, + repo_root: &Path, + ) -> Result<(), CombinedServiceError> { + if path_has_git_entry(repo_root).await? { + return Ok(()); + } + + if tokio::fs::metadata(repo_root).await.is_ok() { + remove_path_if_exists(repo_root).await?; + } + if let Some(parent) = repo_root.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut command = Command::new(git_program()); + command + .args(["clone", &repository_clone_url(repository)]) + .arg(repo_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + let output = command.output().await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to clone {repository} into '{}': {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + } + + async fn ensure_task_worktree( + &self, + repo_root: &Path, + task_root: &Path, + ) -> Result<(), CombinedServiceError> { + if path_has_git_entry(task_root).await? { + return Ok(()); + } + + if tokio::fs::metadata(task_root).await.is_ok() { + remove_path_if_exists(task_root).await?; + } + if let Some(parent) = task_root.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut prune = Command::new(git_program()); + prune + .arg("-C") + .arg(repo_root) + .args(["worktree", "prune"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + prune.env(name, value); + } + let _ = prune.status().await; + + let mut command = Command::new(git_program()); + command + .arg("-C") + .arg(repo_root) + .args(["worktree", "add", "--detach"]) + .arg(task_root) + .arg("HEAD") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + for (name, value) in self.sandbox_env_pairs(Vec::new()).await? { + command.env(name, value); + } + let output = command.output().await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::RepositoryPreparation(format!( + "failed to create task worktree '{}': {}", + task_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))) + } + } + async fn compress_directory_to_archive( &self, archive_path: &Path, @@ -988,6 +1300,58 @@ fn github_git_credentials_env_vars( ] } +async fn path_has_git_entry(path: &Path) -> Result { + match tokio::fs::symlink_metadata(path.join(".git")).await { + Ok(_) => Ok(true), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.into()), + } +} + +fn repository_repo_name(repository: &str) -> &str { + repository.rsplit('/').next().unwrap_or(repository) +} + +fn issue_url_number(issue_url: &str) -> Option<&str> { + let issue_number = issue_url.rsplit('/').next()?.trim(); + (!issue_number.is_empty()).then_some(issue_number) +} + +fn repository_clone_url(repository: &str) -> String { + format!("https://github.com/{repository}.git") +} + +fn git_program() -> String { + for candidate in [ + "git", + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ] { + let path = Path::new(candidate); + let available = if path.components().count() > 1 { + std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + } else { + std::env::var_os("PATH").is_some_and(|path_var| { + std::env::split_paths(&path_var) + .map(|directory| directory.join(candidate)) + .any(|resolved| { + std::fs::metadata(&resolved) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + }) + }) + }; + if available { + return candidate.to_string(); + } + } + + "git".to_string() +} + async fn remove_path_if_exists(path: &Path) -> Result<(), CombinedServiceError> { match tokio::fs::symlink_metadata(path).await { Ok(metadata) if metadata.is_dir() => { @@ -1051,7 +1415,7 @@ async fn unset_repo_local_git_config( repo_root: &Path, key: &str, ) -> Result<(), CombinedServiceError> { - let output = Command::new("git") + let output = Command::new(git_program()) .arg("-C") .arg(repo_root) .args(["config", "--local", "--unset-all", key]) @@ -1146,8 +1510,17 @@ pub enum CombinedServiceError { InvalidToolExecution(String), InvalidRepositorySpec(String), InvalidIssueSpec(String), + RepositoryPreparation(String), UnsupportedRuntimeBackend(String), WorkspaceRepositoryRequired(String), + WorkspaceTaskMissing { + key: String, + task_id: String, + }, + WorkspaceHasTasks { + key: String, + task_count: usize, + }, WorkspaceArchived(String), WorkspaceNotArchived(String), ArchiveWorkspaceRunning(String), @@ -1484,6 +1857,14 @@ mod tests { } Self { key, old_value } } + + fn set_value(key: &'static str, value: impl Into) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::set_var(key, value.into()); + } + Self { key, old_value } + } } impl Drop for EnvVarGuard { @@ -1740,6 +2121,26 @@ token = { command = "gh auth token" } fs::set_permissions(path, perms).expect("executable permissions should be set"); } + fn run_git(repo_root: &Path, args: &[&str]) { + let status = std::process::Command::new(git_program()) + .arg("-C") + .arg(repo_root) + .args(args) + .status() + .expect("git command should run"); + assert!(status.success(), "git command should succeed: git -C {repo_root:?} {}", args.join(" ")); + } + + fn init_test_git_repository(repo_root: &Path) { + fs::create_dir_all(repo_root).expect("repo root should exist"); + run_git(repo_root, &["init", "--initial-branch=main"]); + run_git(repo_root, &["config", "user.email", "test@example.com"]); + run_git(repo_root, &["config", "user.name", "Test User"]); + fs::write(repo_root.join("README.md"), "hello\n").expect("repo file should be written"); + run_git(repo_root, &["add", "README.md"]); + run_git(repo_root, &["commit", "-m", "initial"]); + } + #[test] fn github_git_credentials_helper_script_returns_expected_helper() { assert_eq!( @@ -2226,7 +2627,10 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("fake opencode should be written"); make_executable(&fake_opencode); - let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _path_guard = EnvVarGuard::set_value( + "PATH", + "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin", + ); let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); @@ -2234,8 +2638,9 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] fs::write( &config_path, format!( - "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", - workspace_directory.display() + "workspace-directory = \"{}\"\nopencode = [\"{}\"]\n\n[isolation]\n", + workspace_directory.display(), + fake_opencode.display() ), ) .expect("config should be written"); @@ -2276,6 +2681,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] Some("example/repo") ); assert!(snapshot.persistent.automation_issue.is_none()); + assert!(snapshot.persistent.tasks.is_empty()); assert_eq!(snapshot.automation_scan_request_nonce, 1); let cleared = service @@ -2291,12 +2697,13 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .borrow() .clone(); assert!(cleared_snapshot.persistent.assigned_repository.is_none()); + assert!(cleared_snapshot.persistent.tasks.is_empty()); assert_eq!(cleared_snapshot.automation_scan_request_nonce, 1); }); } #[test] - fn assign_workspace_issue_normalizes_and_requests_autonomous_start() { + fn assign_workspace_issue_creates_manual_task_and_requests_autonomous_start() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -2358,16 +2765,22 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .subscribe() .borrow() .clone(); + assert!(snapshot.persistent.automation_issue.is_none()); + assert_eq!(snapshot.persistent.tasks.len(), 1); assert_eq!( - snapshot.persistent.automation_issue.as_deref(), - Some("https://github.com/example/repo/issues/42") + snapshot.persistent.tasks[0].issue_url, + "https://github.com/example/repo/issues/42" + ); + assert_eq!( + snapshot.persistent.tasks[0].source, + WorkspaceTaskSource::Manual ); assert_eq!(snapshot.automation_scan_request_nonce, 2); let cleared = service .assign_workspace_issue("alpha", None) .await - .expect("clearing issue assignment should succeed"); + .expect("empty issue assignment should be ignored"); assert!(cleared.is_none()); let cleared_snapshot = service @@ -2378,7 +2791,75 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .borrow() .clone(); assert!(cleared_snapshot.persistent.automation_issue.is_none()); - assert_eq!(cleared_snapshot.automation_scan_request_nonce, 3); + assert_eq!(cleared_snapshot.persistent.tasks.len(), 1); + assert_eq!(cleared_snapshot.automation_scan_request_nonce, 2); + }); + } + + #[test] + fn assign_workspace_issue_does_not_duplicate_existing_task() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("initial issue assignment should succeed"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("duplicate issue assignment should succeed"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + assert_eq!(snapshot.persistent.tasks.len(), 1); }); } @@ -2602,6 +3083,256 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] }); } + #[test] + fn delete_workspace_rejects_workspace_with_tasks() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let err = service + .delete_workspace("alpha") + .await + .expect_err("workspace deletion should be rejected while tasks exist"); + assert!(matches!( + err, + CombinedServiceError::WorkspaceHasTasks { + key, + task_count: 1 + } if key == "alpha" + )); + }); + } + + #[test] + fn delete_workspace_task_removes_task_and_clears_active_session_fields() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + let task_id = workspace + .subscribe() + .borrow() + .persistent + .tasks + .first() + .expect("task should exist") + .id + .clone(); + workspace.update(|snapshot| { + snapshot.active_task_id = Some(task_id.clone()); + snapshot.automation_session_id = Some("ses-task".to_string()); + snapshot.automation_agent_state = Some(crate::AutomationAgentState::Working); + snapshot.automation_session_status = Some(crate::RootSessionStatus::Busy); + snapshot.task_states.insert( + task_id.clone(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-task".to_string()), + agent_state: Some(crate::AutomationAgentState::Working), + session_status: Some(crate::RootSessionStatus::Busy), + ..Default::default() + }, + ); + true + }); + + service + .delete_workspace_task("alpha", &task_id) + .await + .expect("task deletion should succeed"); + + let snapshot = workspace.subscribe().borrow().clone(); + assert!(snapshot.persistent.tasks.is_empty()); + assert!(snapshot.active_task_id.is_none()); + assert!(snapshot.automation_session_id.is_none()); + assert!(snapshot.automation_agent_state.is_none()); + assert!(snapshot.automation_session_status.is_none()); + assert!(!snapshot.task_states.contains_key(&task_id)); + }); + } + + #[test] + fn ensure_and_remove_workspace_task_checkout_manage_git_worktree() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + + let repo_root = service.workspace_repo_root_path("alpha", "example/repo"); + init_test_git_repository(&repo_root); + + let issue_url = "https://github.com/example/repo/issues/42"; + let task_root = service + .ensure_workspace_task_checkout("alpha", "example/repo", issue_url) + .await + .expect("task checkout should be prepared"); + + assert_eq!( + task_root, + service.workspace_task_checkout_path("alpha", "example/repo", issue_url) + ); + assert!( + tokio::fs::symlink_metadata(task_root.join(".git")) + .await + .expect("worktree should have git entry") + .file_type() + .is_file(), + "git worktree should expose a .git file" + ); + assert!( + tokio::fs::metadata(task_root.join("README.md")).await.is_ok(), + "worktree should contain repository files" + ); + + service + .remove_workspace_task_checkout("alpha", "example/repo", issue_url) + .await + .expect("task checkout should be removed"); + + assert!( + tokio::fs::metadata(&task_root).await.is_err(), + "task worktree should be removed" + ); + assert!( + tokio::fs::symlink_metadata(repo_root.join(".git")).await.is_ok(), + "base checkout should remain" + ); + }); + } + #[test] fn strip_workspace_git_identity_overrides_removes_repo_local_user_identity() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -2615,7 +3346,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] let repo = workspace.join("repo"); fs::create_dir_all(&repo).expect("repo dir should exist"); - let init = Command::new("git") + let init = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["init"]) @@ -2625,7 +3356,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("git init should run"); assert!(init.status.success(), "git init should succeed"); - let set_name = Command::new("git") + let set_name = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["config", "--local", "user.name", "Local Name"]) @@ -2638,7 +3369,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] "git config user.name should succeed" ); - let set_email = Command::new("git") + let set_email = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["config", "--local", "user.email", "local@example.com"]) @@ -2655,7 +3386,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .await .expect("workspace git identity cleanup should succeed"); - let get_name = Command::new("git") + let get_name = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["config", "--local", "--get", "user.name"]) @@ -2665,7 +3396,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("git config get user.name should run"); assert_eq!(get_name.status.code(), Some(1)); - let get_email = Command::new("git") + let get_email = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["config", "--local", "--get", "user.email"]) @@ -2675,7 +3406,7 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("git config get user.email should run"); assert_eq!(get_email.status.code(), Some(1)); - let remote = Command::new("git") + let remote = Command::new(git_program()) .arg("-C") .arg(&repo) .args(["config", "--local", "core.repositoryformatversion"]) diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index ba2f46a..c9998f7 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -42,6 +42,8 @@ pub struct AutonomousConfig { alias = "issue-scan-delay-seconds" )] pub issue_scan_delay_seconds: u64, + #[serde(default = "default_max_parallel_issues", alias = "max-parallel-issues")] + pub max_parallel_issues: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] @@ -121,6 +123,7 @@ impl Default for AutonomousConfig { fn default() -> Self { Self { issue_scan_delay_seconds: default_issue_scan_delay_seconds(), + max_parallel_issues: default_max_parallel_issues(), } } } @@ -231,6 +234,10 @@ fn default_issue_scan_delay_seconds() -> u64 { 15 * 60 } +fn default_max_parallel_issues() -> usize { + 5 +} + fn default_handler_review() -> String { "/usr/bin/smerge".to_string() } @@ -429,7 +436,10 @@ fn is_executable_file(path: &Path) -> bool { pub(super) fn path_looks_like_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains('.')) + .is_some_and(|name| { + let trimmed = name.trim_start_matches('.'); + !trimmed.is_empty() && trimmed.contains('.') + }) } pub(super) fn validate_workspace_key(key: &str) -> Result { diff --git a/lib/src/services/multicode_metadata_service.rs b/lib/src/services/multicode_metadata_service.rs index 4cc6b34..5505c12 100644 --- a/lib/src/services/multicode_metadata_service.rs +++ b/lib/src/services/multicode_metadata_service.rs @@ -471,7 +471,7 @@ async fn refresh_snapshot_codex_multicode_metadata( thread_id: &str, expected_uri: &str, ) { - let Ok(response) = client.thread_read(thread_id).await else { + let Ok(response) = client.thread_read_with_turns(thread_id, true).await else { return; }; let metadata = collect_metadata_from_codex_turns(response.thread.turns.iter()); diff --git a/lib/src/services/persistent_storage.rs b/lib/src/services/persistent_storage.rs index 16eabee..a1f19ae 100644 --- a/lib/src/services/persistent_storage.rs +++ b/lib/src/services/persistent_storage.rs @@ -288,6 +288,7 @@ mod tests { archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + tasks: Vec::new(), }; let snapshot_path = storage_dir.join("alpha.json"); tokio::fs::write( @@ -377,6 +378,7 @@ mod tests { archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + tasks: Vec::new(), }; tokio::fs::write( storage_dir.join("beta.json"), @@ -496,6 +498,7 @@ mod tests { archive_format: None, agent_provided: Default::default(), custom_links: Default::default(), + tasks: Vec::new(), }; let snapshot_path = storage_dir.join("gamma.json"); tokio::fs::write( diff --git a/lib/src/services/resource_usage_service.rs b/lib/src/services/resource_usage_service.rs index 4590c0a..e27a48e 100644 --- a/lib/src/services/resource_usage_service.rs +++ b/lib/src/services/resource_usage_service.rs @@ -227,6 +227,23 @@ fn clear_stale_runtime_for_unit(workspace: &Workspace, unit: &str) { snapshot.automation_agent_state = None; changed = true; } + if let Some(active_task_id) = snapshot.active_task_id.clone() + && let Some(task_state) = snapshot.task_states.get_mut(&active_task_id) + { + if task_state.session_id.take().is_some() { + changed = true; + } + if task_state.session_status.take().is_some() { + changed = true; + } + if task_state.agent_state.take().is_some() { + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + } if snapshot.usage_cpu_percent.is_some() { snapshot.usage_cpu_percent = None; changed = true; @@ -445,6 +462,16 @@ mod tests { snapshot.root_session_id = Some("thread-1".to_string()); snapshot.root_session_title = Some("Codex".to_string()); snapshot.root_session_status = Some(crate::RootSessionStatus::Busy); + snapshot.active_task_id = Some("task-42".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-42".to_string()), + session_status: Some(crate::RootSessionStatus::Busy), + agent_state: Some(crate::AutomationAgentState::Working), + ..Default::default() + }, + ); snapshot.usage_cpu_percent = Some(25); snapshot.usage_ram_bytes = Some(1024); true @@ -457,6 +484,13 @@ mod tests { assert!(snapshot.root_session_id.is_none()); assert!(snapshot.root_session_title.is_none()); assert!(snapshot.root_session_status.is_none()); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert!(task_state.session_id.is_none()); + assert!(task_state.session_status.is_none()); + assert!(task_state.agent_state.is_none()); assert!(snapshot.usage_cpu_percent.is_none()); assert!(snapshot.usage_ram_bytes.is_none()); } diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index 8947d7a..325c98d 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -26,6 +26,10 @@ pub(crate) const AUTOMATION_STATE_DIR: &str = "/multicode-agent/automation"; pub(crate) const AUTOMATION_STATE_ENV: &str = "MULTICODE_AUTONOMOUS_STATE_PATH"; pub(crate) const AUTOMATION_STATE_FILE_NAME: &str = "state"; +fn is_synthetic_container_target(path: &Path) -> bool { + path.starts_with("/multicode-agent") +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RuntimeActivity { Active, @@ -121,7 +125,7 @@ fn server_uri(context: &RuntimeContext, password: &str, port: u16) -> String { } } -fn synthetic_codex_home_source(workspace_directory_path: &Path, key: &str) -> PathBuf { +pub(crate) fn synthetic_codex_home_source(workspace_directory_path: &Path, key: &str) -> PathBuf { workspace_directory_path .join(".multicode") .join("codex") @@ -728,6 +732,9 @@ impl LinuxSystemdBwrapRuntime { .as_ref() .is_some_and(|source| source != &resolved_mount.effective_source)); resolved_mount.prepare_source_node(owns_source_node).await?; + if !is_synthetic_container_target(&resolved_mount.mount.target) { + resolved_mount.prepare_target_node(owns_node).await?; + } resolved_mounts.push(resolved_mount); } diff --git a/tui/src/app.rs b/tui/src/app.rs index abc826e..3173c6c 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -27,16 +27,27 @@ pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { Some(format!("{NERD_FONT_GITHUB_GLYPH} {owner}/{repo}#{number}")) } -pub(crate) fn should_request_autonomous_issue_scan(snapshot: &WorkspaceSnapshot) -> bool { +pub(crate) fn has_available_task_slot( + snapshot: &WorkspaceSnapshot, + max_parallel_issues: usize, +) -> bool { + snapshot.persistent.tasks.len() < max_parallel_issues +} + +pub(crate) fn should_request_autonomous_issue_scan( + snapshot: &WorkspaceSnapshot, + max_parallel_issues: usize, +) -> bool { snapshot.persistent.assigned_repository.is_some() - && snapshot.persistent.automation_issue.is_none() + && !snapshot.persistent.archived + && has_available_task_slot(snapshot, max_parallel_issues) } pub(crate) fn should_auto_resume_autonomous_codex_after_attach( snapshot: &WorkspaceSnapshot, ) -> bool { if snapshot.persistent.assigned_repository.is_none() - || snapshot.persistent.automation_issue.is_none() + || snapshot.resolved_active_task_id().is_none() { return false; } @@ -44,7 +55,8 @@ pub(crate) fn should_auto_resume_autonomous_codex_after_attach( match snapshot.automation_agent_state { Some(AutomationAgentState::Working) => true, Some( - AutomationAgentState::Question + AutomationAgentState::WaitingOnVm + | AutomationAgentState::Question | AutomationAgentState::Review | AutomationAgentState::Idle | AutomationAgentState::Stale, @@ -58,7 +70,86 @@ pub(crate) fn should_auto_resume_autonomous_codex_after_attach( } } +pub(crate) fn restored_selected_row( + entries: &[TableEntry], + previous_selected_entry: Option<&TableEntry>, + current_selected_row: usize, +) -> usize { + let max_row = entries.len().saturating_sub(1); + let Some(previous_selected_entry) = previous_selected_entry else { + return current_selected_row.min(max_row); + }; + + if matches!(previous_selected_entry, TableEntry::Create) { + return 0; + } + + if let Some(position) = entries.iter().position(|entry| entry == previous_selected_entry) { + return position; + } + + if let TableEntry::Task { workspace_key, .. } = previous_selected_entry + && let Some(position) = entries.iter().position(|entry| { + matches!( + entry, + TableEntry::Workspace { + workspace_key: candidate + } if candidate == workspace_key + ) + }) + { + return position; + } + + current_selected_row.min(max_row) +} + impl TuiState { + pub(crate) fn table_entries(&self) -> Vec { + let mut entries = Vec::with_capacity(self.ordered_keys.len() + 1); + entries.push(TableEntry::Create); + for key in &self.ordered_keys { + entries.push(TableEntry::Workspace { + workspace_key: key.clone(), + }); + if let Some(snapshot) = self.snapshots.get(key) { + for task in &snapshot.persistent.tasks { + entries.push(TableEntry::Task { + workspace_key: key.clone(), + task_id: task.id.clone(), + }); + } + } + } + entries + } + + fn selected_entry(&self) -> Option { + self.table_entries().get(self.selected_row).cloned() + } + + pub(crate) fn selected_task_id(&self) -> Option<&str> { + if self.selected_row == 0 { + return None; + } + let mut row = 1usize; + for key in &self.ordered_keys { + if row == self.selected_row { + return None; + } + row += 1; + if let Some(snapshot) = self.snapshots.get(key) { + for task in &snapshot.persistent.tasks { + if row == self.selected_row { + return Some(task.id.as_str()); + } + row += 1; + } + } + } + None + } + pub(crate) async fn new( config_path: PathBuf, relay_socket: Option, @@ -102,7 +193,7 @@ impl TuiState { custom_link_kind: None, custom_link_action: None, custom_link_original_value: None, - pending_delete_workspace_key: None, + pending_delete_target: None, starting_workspace_key: None, started_wait_since: None, previous_machine_cpu_totals: None, @@ -156,12 +247,7 @@ impl TuiState { } pub(crate) fn sync_from_manager(&mut self) { - let previous_selected_key = if self.selected_row > 0 { - self.ordered_keys.get(self.selected_row - 1).cloned() - } else { - None - }; - let selected_create_row = self.selected_row == 0; + let previous_selected_entry = self.selected_entry(); let workspace_keys = self.workspace_keys_rx.borrow().clone(); @@ -190,21 +276,12 @@ impl TuiState { self.refresh_workspace_link_validations(); self.refresh_github_link_statuses(); - if selected_create_row { - self.selected_row = 0; - } else if let Some(previous_selected_key) = previous_selected_key { - self.selected_row = self - .ordered_keys - .iter() - .position(|key| key == &previous_selected_key) - .map(|position| position + 1) - .unwrap_or(0); - } else { - let max_row = self.ordered_keys.len(); - if self.selected_row > max_row { - self.selected_row = max_row; - } - } + let table_entries = self.table_entries(); + self.selected_row = restored_selected_row( + &table_entries, + previous_selected_entry.as_ref(), + self.selected_row, + ); self.normalize_selected_link_index(); @@ -227,7 +304,7 @@ impl TuiState { } UiMode::ConfirmDelete => { self.mode = UiMode::Normal; - self.pending_delete_workspace_key = None; + self.pending_delete_target = None; } _ => {} } @@ -271,23 +348,40 @@ impl TuiState { if self.mode == UiMode::ConfirmDelete && self - .pending_delete_workspace_key - .as_deref() - .is_some_and(|key| !self.snapshots.contains_key(key)) + .pending_delete_target + .as_ref() + .is_some_and(|target| match target { + PendingDeleteTarget::Workspace { workspace_key } + | PendingDeleteTarget::Task { workspace_key, .. } => { + !self.snapshots.contains_key(workspace_key) + } + }) { self.mode = UiMode::Normal; - self.pending_delete_workspace_key = None; + self.pending_delete_target = None; } } pub(crate) fn selected_workspace_key(&self) -> Option<&str> { if self.selected_row == 0 { - None - } else { - self.ordered_keys - .get(self.selected_row - 1) - .map(String::as_str) + return None; } + let mut row = 1usize; + for key in &self.ordered_keys { + if row == self.selected_row { + return Some(key.as_str()); + } + row += 1; + if let Some(snapshot) = self.snapshots.get(key) { + for _task in &snapshot.persistent.tasks { + if row == self.selected_row { + return Some(key.as_str()); + } + row += 1; + } + } + } + None } pub(crate) fn selected_workspace_snapshot(&self) -> Option<&WorkspaceSnapshot> { @@ -310,6 +404,11 @@ impl TuiState { let key = self.selected_workspace_key()?; let snapshot = self.snapshots.get(key)?; let workspace_path = self.service.workspace_directory_path().join(key); + if let Some(task_id) = self.selected_task_id() + && let Some(task) = task_persistent_snapshot(snapshot, task_id) + { + return compare_target_path_for_task(snapshot, task, &workspace_path); + } compare_target_path( snapshot, &self.workspace_link_validation_results, @@ -328,13 +427,27 @@ impl TuiState { let Some(snapshot) = self.selected_workspace_snapshot() else { return Vec::new(); }; + let candidates = if let Some(task_id) = self.selected_task_id() { + task_persistent_snapshot(snapshot, task_id) + .map(|task| { + visible_task_links( + task, + task_runtime_snapshot(snapshot, task_id), + &self.workspace_link_validation_results, + ) + }) + .unwrap_or_default() + } else { + validated_workspace_links_by_kind( + snapshot, + &self.workspace_link_validation_results, + link.kind, + ) + }; - validated_workspace_links_by_kind( - snapshot, - &self.workspace_link_validation_results, - link.kind, - ) + candidates .into_iter() + .filter(|candidate| candidate.kind == link.kind) .filter_map(|candidate| { self.workspace_link_validation_results .get(&candidate) @@ -420,22 +533,39 @@ impl TuiState { } fn can_add_custom_link_for_selected_kind(&self) -> Option { + if self.selected_task_id().is_some() { + return None; + } self.selected_workspace_link() .filter(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) .map(|link| link.kind) } fn selected_workspace_selectable_links(&self) -> Vec { - self.selected_workspace_key() - .and_then(|key| self.snapshots.get(key)) - .map(|snapshot| { - selectable_workspace_links( - snapshot, - &self.workspace_link_validation_results, - &self.github_link_statuses, - ) - }) - .unwrap_or_default() + let Some(key) = self.selected_workspace_key() else { + return Vec::new(); + }; + let Some(snapshot) = self.snapshots.get(key) else { + return Vec::new(); + }; + + if let Some(task_id) = self.selected_task_id() { + return task_persistent_snapshot(snapshot, task_id) + .map(|task| { + visible_task_links( + task, + task_runtime_snapshot(snapshot, task_id), + &self.workspace_link_validation_results, + ) + }) + .unwrap_or_default(); + } + + selectable_workspace_links( + snapshot, + &self.workspace_link_validation_results, + &self.github_link_statuses, + ) } fn selected_workspace_link_argument(&self, link: &WorkspaceLink) -> Option<&str> { @@ -446,7 +576,7 @@ impl TuiState { } fn normalize_selected_link_index(&mut self) { - if self.selected_row == 0 { + if matches!(self.selected_entry(), Some(TableEntry::Create)) { self.selected_link_index = None; return; } @@ -464,11 +594,17 @@ impl TuiState { } fn refresh_workspace_link_validations(&mut self) { - let active_links = self - .snapshots - .values() - .flat_map(workspace_links) - .collect::>(); + let mut active_links = HashSet::new(); + for snapshot in self.snapshots.values() { + active_links.extend(workspace_links(snapshot)); + active_links.extend(workspace_issue_pr_links(snapshot)); + for task in &snapshot.persistent.tasks { + active_links.extend(task_links( + task, + task_runtime_snapshot(snapshot, &task.id), + )); + } + } self.workspace_link_validation_results .retain(|link, _| active_links.contains(link)); @@ -524,12 +660,25 @@ impl TuiState { } fn refresh_github_link_statuses(&mut self) { - let active_issue_or_pr_links = self - .snapshots - .values() - .flat_map(workspace_links) - .filter(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) - .collect::>(); + let mut active_issue_or_pr_links = HashSet::new(); + for snapshot in self.snapshots.values() { + active_issue_or_pr_links.extend( + workspace_issue_pr_links(snapshot) + .into_iter() + .filter(|link| { + matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + }), + ); + for task in &snapshot.persistent.tasks { + active_issue_or_pr_links.extend( + task_links(task, task_runtime_snapshot(snapshot, &task.id)) + .into_iter() + .filter(|link| { + matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) + }), + ); + } + } self.github_link_status_rxs .retain(|link, _| active_issue_or_pr_links.contains(link)); @@ -584,9 +733,15 @@ impl TuiState { } pub(crate) fn selected_workspace_has_refreshable_github_link(&self) -> bool { - self.selected_workspace_selectable_links() - .into_iter() - .any(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) + self.selected_workspace_snapshot().is_some_and(|snapshot| { + should_request_autonomous_issue_scan( + snapshot, + self.service.config.autonomous.max_parallel_issues, + ) || self + .selected_workspace_selectable_links() + .into_iter() + .any(|link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr)) + }) } pub(crate) fn selected_workspace_link(&self) -> Option { @@ -603,7 +758,12 @@ impl TuiState { let autonomous_scan_requested = self .snapshots .get(&workspace_key) - .filter(|snapshot| should_request_autonomous_issue_scan(snapshot)) + .filter(|snapshot| { + should_request_autonomous_issue_scan( + snapshot, + self.service.config.autonomous.max_parallel_issues, + ) + }) .map(|_| self.service.request_workspace_issue_scan(&workspace_key)) .transpose(); @@ -683,6 +843,9 @@ impl TuiState { } pub(crate) fn contextual_tool_hotkeys(&self) -> Vec<(String, String)> { + if self.selected_task_id().is_some() { + return Vec::new(); + } contextual_tool_hotkeys( &self.service.config.tool, self.selected_workspace_snapshot(), @@ -699,10 +862,16 @@ impl TuiState { } fn snapshot_attach_target(&self, key: &str) -> io::Result { - self.snapshots + let snapshot = self + .snapshots .get(key) - .ok_or_else(|| io::Error::other(format!("workspace snapshot missing for '{key}'"))) - .and_then(workspace_attach_target) + .ok_or_else(|| io::Error::other(format!("workspace snapshot missing for '{key}'")))?; + if let Some(task_id) = self.selected_task_id() + && let Some(task_state) = task_runtime_snapshot(snapshot, task_id) + { + return task_attach_target(snapshot, task_state); + } + workspace_attach_target(snapshot) } fn attach_env_for_workspace(&self, key: &str) -> Vec<(String, String)> { @@ -1201,23 +1370,11 @@ impl TuiState { key: KeyEvent, ) { let link_selected = self.selected_link_index.is_some(); - let control_held = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Char('q') => self.should_quit = true, KeyCode::Up => { if link_selected { self.move_selected_link_target_up(); - } else if control_held { - if let Some(row) = next_non_stopped_row( - self.selected_row, - &self.ordered_keys, - &self.snapshots, - -1, - ) { - self.selected_row = row; - self.selected_link_index = None; - self.selected_link_target_index = 0; - } } else if self.selected_row > 0 { self.selected_row -= 1; self.selected_link_index = None; @@ -1227,18 +1384,7 @@ impl TuiState { KeyCode::Down => { if link_selected { self.move_selected_link_target_down(); - } else if control_held { - if let Some(row) = next_non_stopped_row( - self.selected_row, - &self.ordered_keys, - &self.snapshots, - 1, - ) { - self.selected_row = row; - self.selected_link_index = None; - self.selected_link_target_index = 0; - } - } else if self.selected_row < self.ordered_keys.len() { + } else if self.selected_row + 1 < self.table_entries().len() { self.selected_row += 1; self.selected_link_index = None; self.selected_link_target_index = 0; @@ -1366,6 +1512,9 @@ impl TuiState { } return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key().map(str::to_string) { let archived = self .snapshots @@ -1464,6 +1613,9 @@ impl TuiState { } return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key() { let current = self .snapshots @@ -1478,6 +1630,9 @@ impl TuiState { if link_selected { return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key() { let Some(snapshot) = self.snapshots.get(key) else { return; @@ -1488,12 +1643,7 @@ impl TuiState { if snapshot.persistent.assigned_repository.is_none() { return; } - self.issue_input = snapshot - .persistent - .automation_issue - .clone() - .and_then(|issue| issue.rsplit('/').next().map(ToOwned::to_owned)) - .unwrap_or_default(); + self.issue_input.clear(); self.mode = UiMode::EditIssue; } } @@ -1501,6 +1651,9 @@ impl TuiState { if link_selected { return; } + if self.selected_task_id().is_some() { + return; + } if let Some(key) = self.selected_workspace_key().map(str::to_string) { let state = self.snapshots.get(&key).map(workspace_state); match state { @@ -1540,15 +1693,32 @@ impl TuiState { if link_selected { return; } + if self.selected_task_id().is_some() { + return; + } self.request_selected_workspace_github_status_refresh(); } KeyCode::Char('x') => { if link_selected { return; } - if let Some(key) = self.selected_workspace_key().map(str::to_string) { - self.pending_delete_workspace_key = Some(key); - self.mode = UiMode::ConfirmDelete; + match self.selected_entry() { + Some(TableEntry::Workspace { workspace_key }) => { + self.pending_delete_target = + Some(PendingDeleteTarget::Workspace { workspace_key }); + self.mode = UiMode::ConfirmDelete; + } + Some(TableEntry::Task { + workspace_key, + task_id, + }) => { + self.pending_delete_target = Some(PendingDeleteTarget::Task { + workspace_key, + task_id, + }); + self.mode = UiMode::ConfirmDelete; + } + _ => {} } } KeyCode::Char(ch) => { @@ -1694,13 +1864,12 @@ impl TuiState { .await { Ok(Some(normalized)) => { - self.status = format!("Assigned issue '{normalized}' to workspace '{key}'"); + self.status = format!("Queued issue '{normalized}' for workspace '{key}'"); self.mode = UiMode::Normal; self.issue_input.clear(); } Ok(None) => { - self.status = - format!("Cleared direct issue assignment for workspace '{key}'"); + self.status = format!("No issue queued for workspace '{key}'"); self.mode = UiMode::Normal; self.issue_input.clear(); } @@ -1717,26 +1886,50 @@ impl TuiState { match key.code { KeyCode::Esc => { self.mode = UiMode::Normal; - self.pending_delete_workspace_key = None; + self.pending_delete_target = None; } KeyCode::Enter => { - let Some(workspace_key) = self.pending_delete_workspace_key.clone() else { + let Some(target) = self.pending_delete_target.clone() else { self.mode = UiMode::Normal; return; }; - match self.service.delete_workspace(&workspace_key).await { - Ok(()) => { - self.status = format!("Deleted workspace '{workspace_key}'"); - } - Err(err) => { - self.status = format!( - "Failed to delete workspace '{workspace_key}': {}", - err.summary() - ); + match target { + PendingDeleteTarget::Workspace { workspace_key } => { + match self.service.delete_workspace(&workspace_key).await { + Ok(()) => { + self.status = format!("Deleted workspace '{workspace_key}'"); + } + Err(err) => { + self.status = format!( + "Failed to delete workspace '{workspace_key}': {}", + err.summary() + ); + } + } } + PendingDeleteTarget::Task { + workspace_key, + task_id, + } => match self + .service + .delete_workspace_task(&workspace_key, &task_id) + .await + { + Ok(()) => { + self.status = format!( + "Deleted task '{task_id}' from workspace '{workspace_key}'" + ); + } + Err(err) => { + self.status = format!( + "Failed to delete task '{task_id}' from workspace '{workspace_key}': {}", + err.summary() + ); + } + }, } self.mode = UiMode::Normal; - self.pending_delete_workspace_key = None; + self.pending_delete_target = None; } _ => {} } diff --git a/tui/src/main.rs b/tui/src/main.rs index c72045a..eb00509 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -9,12 +9,13 @@ use std::{ use clap::Parser; use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use multicode_lib::{ - AutomationAgentState, RootSessionStatus, WorkspaceSnapshot, logging, opencode, + AutomationAgentState, RootSessionStatus, WorkspaceSnapshot, WorkspaceTaskPersistentSnapshot, + WorkspaceTaskRuntimeSnapshot, logging, opencode, services::{ CombinedService, GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, GithubStatus, ToolConfig, ToolType, @@ -100,6 +101,17 @@ enum CustomLinkModalAction { Edit, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingDeleteTarget { + Workspace { + workspace_key: String, + }, + Task { + workspace_key: String, + task_id: String, + }, +} + struct TuiState { service: CombinedService, relay_socket: Option, @@ -125,7 +137,7 @@ struct TuiState { custom_link_kind: Option, custom_link_action: Option, custom_link_original_value: Option, - pending_delete_workspace_key: Option, + pending_delete_target: Option, starting_workspace_key: Option, started_wait_since: Option, previous_machine_cpu_totals: Option, @@ -148,6 +160,18 @@ struct RunningOperation { cancel: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum TableEntry { + Create, + Workspace { + workspace_key: String, + }, + Task { + workspace_key: String, + task_id: String, + }, +} + fn workspace_is_usable(snapshot: &WorkspaceSnapshot) -> bool { !snapshot.persistent.archived } @@ -171,6 +195,7 @@ enum WorkspaceLinkSource { Custom, Automation, AgentProvided, + Task, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -274,6 +299,120 @@ fn workspace_state(snapshot: &WorkspaceSnapshot) -> WorkspaceUiState { } } +fn task_persistent_snapshot<'a>( + snapshot: &'a WorkspaceSnapshot, + task_id: &str, +) -> Option<&'a WorkspaceTaskPersistentSnapshot> { + snapshot.task_persistent_snapshot(task_id) +} + +fn task_runtime_snapshot<'a>( + snapshot: &'a WorkspaceSnapshot, + task_id: &str, +) -> Option<&'a WorkspaceTaskRuntimeSnapshot> { + snapshot.task_states.get(task_id) +} + +fn task_issue_reference(task: &WorkspaceTaskPersistentSnapshot) -> String { + let Some(url) = Url::parse(&task.issue_url).ok() else { + return task.issue_url.clone(); + }; + let segments = url + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + if segments.len() >= 4 { + let repo = segments[1]; + let number = segments[3]; + return format!("{repo}#{number}"); + } + task.issue_url.clone() +} + +fn task_row_label(task: &WorkspaceTaskPersistentSnapshot) -> String { + format!("➑️ {}", task_issue_reference(task)) +} + +fn task_issue_link<'a>( + task: &'a WorkspaceTaskPersistentSnapshot, + task_state: Option<&'a WorkspaceTaskRuntimeSnapshot>, +) -> &'a str { + task_state + .and_then(|state| state.issue.first().map(String::as_str)) + .unwrap_or(task.issue_url.as_str()) +} + +fn task_pr_link(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> Option<&str> { + task_state.and_then(|state| state.pr.first().map(String::as_str)) +} + +fn github_link_badge(url: &str) -> String { + let Some(parsed) = Url::parse(url).ok() else { + return String::new(); + }; + let segments = parsed + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + let Some(number) = segments.last().filter(|segment| !segment.is_empty()) else { + return String::new(); + }; + format!("#{number}") +} + +fn task_server_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> &'static str { + match task_state.and_then(|state| state.agent_state) { + Some(AutomationAgentState::Working) => "Busy", + Some(AutomationAgentState::Question) => "Question", + Some(AutomationAgentState::Review | AutomationAgentState::Idle) => "Idle", + Some(AutomationAgentState::WaitingOnVm) => "Waiting on VM", + Some(AutomationAgentState::Stale) => "Stale", + None => { + if task_state.is_some_and(|state| state.waiting_on_vm) { + "Waiting on VM" + } else { + "" + } + } + } +} + +fn task_server_style(task_state: Option<&WorkspaceTaskRuntimeSnapshot>, archived: bool) -> Style { + if archived { + return Style::default(); + } + match task_server_label(task_state) { + "Idle" => Style::default().fg(IDLE_COLOR), + "Busy" => Style::default().fg(BUSY_COLOR), + "Question" => Style::default().fg(WAITING_FOR_INPUT_COLOR), + "Waiting on VM" => Style::default().fg(Color::Blue), + "Stale" => Style::default().fg(OOM_COLOR), + _ => Style::default(), + } +} + +fn task_description( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> String { + if let Some(status) = task_state.and_then(|state| state.status.as_deref()) + && !status.trim().is_empty() + { + return status.trim().to_string(); + } + match task_state.and_then(|state| state.agent_state) { + Some(AutomationAgentState::Working) => format!("Working {}", task_issue_reference(task)), + Some(AutomationAgentState::Question) => format!("Question {}", task_issue_reference(task)), + Some(AutomationAgentState::Review) => format!("Review {}", task_issue_reference(task)), + Some(AutomationAgentState::WaitingOnVm) => { + format!("Waiting on VM {}", task_issue_reference(task)) + } + Some(AutomationAgentState::Idle) => format!("Wait close {}", task_issue_reference(task)), + Some(AutomationAgentState::Stale) => format!("Stalled {}", task_issue_reference(task)), + None => task_issue_reference(task), + } +} + fn next_non_stopped_row( current_row: usize, ordered_keys: &[String], @@ -312,10 +451,11 @@ fn server_cell_label(snapshot: &WorkspaceSnapshot) -> &'static str { } fn effective_server_status(snapshot: &WorkspaceSnapshot) -> RootSessionStatus { - if snapshot.persistent.automation_issue.is_some() { + if active_task_issue_url(snapshot).is_some() { if let Some(agent_state) = snapshot.automation_agent_state { return match agent_state { AutomationAgentState::Working => RootSessionStatus::Busy, + AutomationAgentState::WaitingOnVm => RootSessionStatus::Idle, AutomationAgentState::Question => RootSessionStatus::Question, AutomationAgentState::Review | AutomationAgentState::Idle @@ -455,18 +595,6 @@ fn description_cell_text(snapshot: &WorkspaceSnapshot, user_description: &str) - fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { let mut links = Vec::new(); - links.extend( - snapshot - .persistent - .automation_issue - .iter() - .cloned() - .map(|value| WorkspaceLink { - kind: WorkspaceLinkKind::Issue, - value, - source: WorkspaceLinkSource::Automation, - }), - ); links.extend( snapshot .persistent @@ -493,19 +621,6 @@ fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { source: WorkspaceLinkSource::Custom, }), ); - links.extend( - snapshot - .persistent - .agent_provided - .issue - .iter() - .cloned() - .map(|value| WorkspaceLink { - kind: WorkspaceLinkKind::Issue, - value, - source: WorkspaceLinkSource::AgentProvided, - }), - ); links.extend( snapshot .persistent @@ -519,23 +634,74 @@ fn workspace_links(snapshot: &WorkspaceSnapshot) -> Vec { source: WorkspaceLinkSource::Custom, }), ); - links.extend( - snapshot - .persistent - .agent_provided - .pr - .iter() - .cloned() - .map(|value| WorkspaceLink { - kind: WorkspaceLinkKind::Pr, - value, - source: WorkspaceLinkSource::AgentProvided, - }), - ); links } +fn workspace_issue_pr_links(snapshot: &WorkspaceSnapshot) -> Vec { + let mut links = Vec::new(); + + if snapshot.persistent.tasks.is_empty() { + links.extend( + active_task_issue_url(snapshot) + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::Automation, + }), + ); + links.extend( + snapshot + .persistent + .agent_provided + .issue + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value, + source: WorkspaceLinkSource::AgentProvided, + }), + ); + links.extend( + snapshot + .persistent + .agent_provided + .pr + .iter() + .cloned() + .map(|value| WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value, + source: WorkspaceLinkSource::AgentProvided, + }), + ); + } + + links +} + +fn task_links( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> Vec { + let mut links = vec![WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: task_issue_link(task, task_state).to_string(), + source: WorkspaceLinkSource::Task, + }]; + if let Some(pr) = task_pr_link(task_state) { + links.push(WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: pr.to_string(), + source: WorkspaceLinkSource::Task, + }); + } + links +} + #[cfg(test)] fn description_line( snapshot: &WorkspaceSnapshot, @@ -663,6 +829,32 @@ fn compare_target_path( .or_else(|| compare_target_path_from_workspace(snapshot, workspace_path)) } +fn compare_target_path_for_task( + snapshot: &WorkspaceSnapshot, + task: &WorkspaceTaskPersistentSnapshot, + workspace_path: &Path, +) -> Option { + let repo_name = snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(|repository| repository.rsplit('/').next()) + .filter(|segment| !segment.is_empty())?; + let issue_number = task + .issue_url + .rsplit('/') + .next() + .filter(|segment| !segment.is_empty())?; + + let candidates = [ + workspace_path + .join("work") + .join(format!("{repo_name}-{issue_number}")), + workspace_path.join(repo_name), + ]; + candidates.into_iter().find(|candidate| is_git_checkout(candidate)) +} + fn compare_target_path_from_workspace( snapshot: &WorkspaceSnapshot, workspace_path: &Path, @@ -673,9 +865,8 @@ fn compare_target_path_from_workspace( .as_deref() .and_then(|repository| repository.rsplit('/').next()) .filter(|segment| !segment.is_empty()); - let issue_number = snapshot - .persistent - .automation_issue + let active_issue_url = active_task_issue_url(snapshot); + let issue_number = active_issue_url .as_deref() .and_then(|issue| issue.rsplit('/').next()) .filter(|segment| !segment.is_empty()); @@ -692,9 +883,15 @@ fn compare_target_path_from_workspace( candidates.push(workspace_path.join(repo_name)); } - candidates - .into_iter() - .find(|candidate| candidate.join(".git").is_dir()) + candidates.into_iter().find(|candidate| is_git_checkout(candidate)) +} + +fn is_git_checkout(path: &Path) -> bool { + std::fs::symlink_metadata(path.join(".git")).is_ok() +} + +fn active_task_issue_url(snapshot: &WorkspaceSnapshot) -> Option { + snapshot.resolved_active_issue_url() } fn visible_workspace_links( @@ -711,9 +908,18 @@ fn visible_workspace_links( } for kind in [WorkspaceLinkKind::Issue, WorkspaceLinkKind::Pr] { - if let Some(link) = first_validated_workspace_link_by_kind(snapshot, validations, kind) { + let next_link = workspace_issue_pr_links(snapshot) + .into_iter() + .find(|link| link.kind == kind) + .filter(|link| { + matches!( + validations.get(link), + Some(WorkspaceLinkValidationResult::Valid(_)) + ) + }); + if let Some(link) = next_link { visible.push(link); - } else { + } else if snapshot.persistent.tasks.is_empty() { visible.push(WorkspaceLink { kind, value: String::new(), @@ -725,6 +931,22 @@ fn visible_workspace_links( visible } +fn visible_task_links( + task: &WorkspaceTaskPersistentSnapshot, + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, + validations: &HashMap, +) -> Vec { + task_links(task, task_state) + .into_iter() + .filter(|link| { + matches!( + validations.get(link), + Some(WorkspaceLinkValidationResult::Valid(_)) + ) + }) + .collect() +} + fn selectable_workspace_links( snapshot: &WorkspaceSnapshot, validations: &HashMap, @@ -861,6 +1083,7 @@ fn help_line( selected_row: usize, workspace_count: usize, selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, selected_workspace_link_count: usize, selected_link_index: Option, selected_link_is_custom: bool, @@ -881,6 +1104,27 @@ fn help_line( push_hotkey(&mut spans, "Enter", " create "); } Some(snapshot) => { + if selected_task_row { + if workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Started + { + push_hotkey(&mut spans, "Enter", " attach "); + } else if workspace_is_usable(snapshot) + && workspace_state(snapshot) == WorkspaceUiState::Stopped + { + push_hotkey(&mut spans, "Enter", " start+attach "); + } + if selected_workspace_can_compare { + push_hotkey(&mut spans, "c", " compare "); + } + push_hotkey(&mut spans, "x", " delete "); + push_hotkey(&mut spans, "q", " quit"); + if !status.is_empty() { + spans.push(Span::raw(" | ")); + spans.push(Span::raw(status.to_string())); + } + return Line::from(spans); + } if selected_workspace_link_count > 0 { push_hotkey(&mut spans, "←/β†’", " select link "); } @@ -957,7 +1201,7 @@ fn help_line( push_hotkey(&mut spans, "Esc", " cancel"); } UiMode::EditIssue => { - spans.push(Span::raw("Assign issue: type number or GitHub issue URL, ")); + spans.push(Span::raw("Queue issue: type number or GitHub issue URL, ")); push_hotkey(&mut spans, "Enter", " save, "); push_hotkey(&mut spans, "Esc", " cancel"); } @@ -968,7 +1212,7 @@ fn help_line( push_hotkey(&mut spans, "Esc", " cancel"); } UiMode::ConfirmDelete => { - spans.push(Span::raw("Delete workspace: ")); + spans.push(Span::raw("Delete item: ")); push_hotkey(&mut spans, "Enter", " confirm, "); push_hotkey(&mut spans, "Esc", " cancel"); } diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 7dd88b6..5c21073 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -197,6 +197,63 @@ pub(crate) fn workspace_attach_target(snapshot: &WorkspaceSnapshot) -> io::Resul }) } +pub(crate) fn task_attach_target( + snapshot: &WorkspaceSnapshot, + task_state: &multicode_lib::WorkspaceTaskRuntimeSnapshot, +) -> io::Result { + if workspace_state(snapshot) != WorkspaceUiState::Started { + return Err(io::Error::other( + "workspace must be in Started state before attaching", + )); + } + + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.as_str()) + .ok_or_else(|| io::Error::other("workspace is missing transient attach URI"))?; + + let mut parsed = Url::parse(uri) + .map_err(|err| io::Error::other(format!("workspace attach URI is invalid: {err}")))?; + + let session_id = task_state + .session_id + .clone() + .ok_or_else(|| io::Error::other("task does not have a resumable session yet"))?; + + if matches!(parsed.scheme(), "ws" | "wss") { + return Ok(AttachTarget::Codex { + uri: parsed.to_string(), + thread_id: Some(session_id), + }); + } + + let username = parsed.username().to_string(); + if username.is_empty() { + return Err(io::Error::other( + "workspace attach URI is missing username credentials", + )); + } + let password = parsed + .password() + .map(str::to_string) + .ok_or_else(|| io::Error::other("workspace attach URI is missing password credentials"))?; + + parsed + .set_username("") + .map_err(|_| io::Error::other("failed to sanitize workspace attach URI username"))?; + parsed + .set_password(None) + .map_err(|_| io::Error::other("failed to sanitize workspace attach URI password"))?; + + Ok(AttachTarget::Opencode { + uri: parsed.to_string(), + username, + password, + session_id: Some(session_id), + }) +} + pub(crate) fn build_handler_command( template: &str, argument_mode: multicode_lib::HandlerArgumentMode, @@ -252,18 +309,12 @@ pub(crate) async fn validate_workspace_link_target( } let git_dir = repo_path.join(".git"); - let git_metadata = tokio::fs::metadata(&git_dir).await.map_err(|err| { + tokio::fs::symlink_metadata(&git_dir).await.map_err(|err| { io::Error::other(format!( - "review path '{}' must contain a '.git' folder: {err}", + "review path '{}' must contain a '.git' entry: {err}", repo_path.display() )) })?; - if !git_metadata.is_dir() { - return Err(io::Error::other(format!( - "review path '{}' must contain a '.git' folder", - repo_path.display() - ))); - } Ok(repo_path.to_string_lossy().into_owned()) } @@ -308,9 +359,9 @@ pub(crate) fn attach_cli_args(agent_command: &str, target: &AttachTarget) -> Vec "--remote".to_string(), uri.clone(), ]; - // Remote Codex resumes are more reliable when the app-server picks the - // latest thread instead of trusting a cached local snapshot id. - if thread_id.is_some() { + if let Some(thread_id) = thread_id.as_deref() { + args.push(thread_id.to_string()); + } else { args.push("--last".to_string()); } args diff --git a/tui/src/render.rs b/tui/src/render.rs index cb07462..602ee85 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -18,6 +18,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { app.machine_total_ram_bytes, app.machine_agent_directory_disk_usage, ); + let entries = app.table_entries(); let ( workspace_width, server_width, @@ -36,6 +37,22 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { &create_row_cpu_raw, &create_row_ram_raw, ); + let workspace_width = entries.iter().fold(workspace_width, |width, entry| { + let label = match entry { + TableEntry::Create => CREATE_ROW_LABEL.to_string(), + TableEntry::Workspace { workspace_key } => workspace_key.clone(), + TableEntry::Task { + workspace_key, + task_id, + } => app + .snapshots + .get(workspace_key) + .and_then(|snapshot| task_persistent_snapshot(snapshot, task_id)) + .map(task_row_label) + .unwrap_or_else(|| "➑️ task".to_string()), + }; + width.max(content_width(&label)) + }); let create_row_cpu = right_align_cell_text(&create_row_cpu_raw, cpu_width); let create_row_ram = right_align_cell_text(&create_row_ram_raw, ram_width); let workspace_memory_high_bytes = multicode_lib::services::parse_optional_size_bytes( @@ -45,7 +62,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { .ok() .flatten(); - let mut rows = Vec::with_capacity(app.ordered_keys.len() + 1); + let mut rows = Vec::with_capacity(entries.len()); rows.push( Row::new(vec![ Cell::from(CREATE_ROW_LABEL), @@ -63,133 +80,226 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { .style(Style::default().fg(CREATE_ROW_COLOR)), ); - for key in &app.ordered_keys { - if let Some(snapshot) = app.snapshots.get(key) { - let archived = snapshot.persistent.archived; - let user_description = if app.mode == UiMode::EditDescription - && app.selected_workspace_key() == Some(key.as_str()) - { - format!("{}▏", app.edit_input) - } else { - snapshot.persistent.description.clone() - }; - let selected_link_index: Option = if app.mode == UiMode::Normal - && app.selected_workspace_key() == Some(key.as_str()) - { - app.selected_link_index - } else { - None - }; - let links = selectable_workspace_links( - snapshot, - &app.workspace_link_validation_results, - &app.github_link_statuses, - ); - let selected_link_kind = selected_link_index - .and_then(|index| links.get(index)) - .map(|link| link.kind); - let review_link = links - .iter() - .find(|link| link.kind == WorkspaceLinkKind::Review); - let issue_link = links - .iter() - .find(|link| link.kind == WorkspaceLinkKind::Issue); - let pr_link = links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); - let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); - let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); - let description = Cell::from(description_line_for_snapshot( - snapshot, - user_description.as_str(), - archived, - )); - let cpu = cpu_cell_label(snapshot); - let cpu = right_align_cell_text(&cpu, cpu_width); - let ram = ram_cell_label(snapshot); - let ram = right_align_cell_text(&ram, ram_width); - let cost = cost_cell_label(snapshot); - let cost = right_align_cell_text(&cost, cost_width); - - let review_cell = review_link.map_or_else(Cell::default, |_| { - status_icon_cell( - StatusIconKind::FileDiff, - if archived { - Color::DarkGray - } else { - AGENT_LINK_COLOR - }, - selected_link_kind == Some(WorkspaceLinkKind::Review), - ) - }); - let issue_cell = if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { - let (kind, color) = issue_icon_kind_and_color(issue_status.state); - status_icon_cell( - kind, - if archived { Color::DarkGray } else { color }, - selected_link_kind == Some(WorkspaceLinkKind::Issue), - ) - } else { - Cell::default() - }; - let (pr_cell, build_cell, review_status_cell) = - if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { - let (kind, color) = pr_icon_kind_and_color(*pr_status); - ( + for entry in entries.iter().skip(1) { + match entry { + TableEntry::Workspace { workspace_key: key } => { + let Some(snapshot) = app.snapshots.get(key) else { + continue; + }; + let archived = snapshot.persistent.archived; + let user_description = if app.mode == UiMode::EditDescription + && app.selected_workspace_key() == Some(key.as_str()) + && app.selected_task_id().is_none() + { + format!("{}▏", app.edit_input) + } else { + snapshot.persistent.description.clone() + }; + let selected_link_index: Option = if app.mode == UiMode::Normal + && app.selected_workspace_key() == Some(key.as_str()) + && app.selected_task_id().is_none() + { + app.selected_link_index + } else { + None + }; + let links = selectable_workspace_links( + snapshot, + &app.workspace_link_validation_results, + &app.github_link_statuses, + ); + let selected_link_kind = selected_link_index + .and_then(|index| links.get(index)) + .map(|link| link.kind); + let review_link = links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Review); + let issue_link = links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Issue); + let pr_link = links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); + let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); + let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); + let description = Cell::from(description_line_for_snapshot( + snapshot, + user_description.as_str(), + archived, + )); + let cpu = cpu_cell_label(snapshot); + let cpu = right_align_cell_text(&cpu, cpu_width); + let ram = ram_cell_label(snapshot); + let ram = right_align_cell_text(&ram, ram_width); + let cost = cost_cell_label(snapshot); + let cost = right_align_cell_text(&cost, cost_width); + + let review_cell = review_link.map_or_else(Cell::default, |_| { + status_icon_cell( + StatusIconKind::FileDiff, + if archived { + Color::DarkGray + } else { + AGENT_LINK_COLOR + }, + selected_link_kind == Some(WorkspaceLinkKind::Review), + ) + }); + let issue_cell = + if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { + let (kind, color) = issue_icon_kind_and_color(issue_status.state); status_icon_cell( kind, if archived { Color::DarkGray } else { color }, - selected_link_kind == Some(WorkspaceLinkKind::Pr), - ), - pr_build_icon_color(*pr_status).map_or_else(Cell::default, |build_color| { + selected_link_kind == Some(WorkspaceLinkKind::Issue), + ) + } else { + Cell::default() + }; + let (pr_cell, build_cell, review_status_cell) = + if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + let (kind, color) = pr_icon_kind_and_color(*pr_status); + ( status_icon_cell( - StatusIconKind::Server, - if archived { - Color::DarkGray - } else { - build_color + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Pr), + ), + pr_build_icon_color(*pr_status).map_or_else( + Cell::default, + |build_color| { + status_icon_cell( + StatusIconKind::Server, + if archived { + Color::DarkGray + } else { + build_color + }, + false, + ) + }, + ), + pr_review_icon_color(*pr_status).map_or_else( + Cell::default, + |review_color| { + status_icon_cell( + StatusIconKind::Eye, + if archived { + Color::DarkGray + } else { + review_color + }, + false, + ) }, - false, - ) - }), - pr_review_icon_color(*pr_status).map_or_else( - Cell::default, - |review_color| { - status_icon_cell( - StatusIconKind::Eye, - if archived { - Color::DarkGray - } else { - review_color - }, - false, - ) - }, - ), + ), + ) + } else { + (Cell::default(), Cell::default(), Cell::default()) + }; + + rows.push( + Row::new(vec![ + Cell::from(key.clone()), + Cell::from(server_cell_label(snapshot)) + .style(server_cell_style(snapshot, archived)), + Cell::from(cpu), + Cell::from(ram).style(ram_cell_style( + snapshot, + workspace_memory_high_bytes, + archived, + )), + Cell::from(cost), + review_cell, + issue_cell, + pr_cell, + build_cell, + review_status_cell, + description, + ]) + .style(workspace_row_style(snapshot)), + ); + } + TableEntry::Task { + workspace_key, + task_id, + } => { + let Some(snapshot) = app.snapshots.get(workspace_key) else { + continue; + }; + let Some(task) = task_persistent_snapshot(snapshot, task_id) else { + continue; + }; + let task_state = task_runtime_snapshot(snapshot, task_id); + let archived = snapshot.persistent.archived; + let selected_link_index: Option = if app.mode == UiMode::Normal + && app.selected_workspace_key() == Some(workspace_key.as_str()) + && app.selected_task_id() == Some(task_id.as_str()) + { + app.selected_link_index + } else { + None + }; + let task_links = + visible_task_links(task, task_state, &app.workspace_link_validation_results); + let selected_link_kind = selected_link_index + .and_then(|index| task_links.get(index)) + .map(|link| link.kind); + let issue_link = task_links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Issue); + let pr_link = task_links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); + let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); + let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); + let issue_cell = if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status + { + let (kind, color) = issue_icon_kind_and_color(issue_status.state); + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Issue), ) } else { - (Cell::default(), Cell::default(), Cell::default()) + Cell::from(github_link_badge(task_issue_link(task, task_state))).style( + if selected_link_kind == Some(WorkspaceLinkKind::Issue) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }, + ) }; - - rows.push( - Row::new(vec![ - Cell::from(key.clone()), - Cell::from(server_cell_label(snapshot)) - .style(server_cell_style(snapshot, archived)), - Cell::from(cpu), - Cell::from(ram).style(ram_cell_style( - snapshot, - workspace_memory_high_bytes, - archived, - )), - Cell::from(cost), - review_cell, - issue_cell, - pr_cell, - build_cell, - review_status_cell, - description, - ]) - .style(workspace_row_style(snapshot)), - ); + let pr_cell = if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + let (kind, color) = pr_icon_kind_and_color(*pr_status); + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Pr), + ) + } else { + Cell::from(task_pr_link(task_state).map(github_link_badge).unwrap_or_default()) + .style(if selected_link_kind == Some(WorkspaceLinkKind::Pr) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }) + }; + rows.push( + Row::new(vec![ + Cell::from(task_row_label(task)), + Cell::from(task_server_label(task_state)) + .style(task_server_style(task_state, archived)), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + issue_cell, + pr_cell, + Cell::from(""), + Cell::from(""), + Cell::from(task_description(task, task_state)), + ]) + .style(workspace_row_style(snapshot)), + ); + } + TableEntry::Create => {} } } @@ -240,8 +350,9 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { let help = help_line( app.mode, app.selected_row, - app.ordered_keys.len(), + entries.len().saturating_sub(1), app.selected_workspace_snapshot(), + app.selected_task_id().is_some(), app.selected_workspace_link_count(), app.selected_link_index, app.selected_workspace_link() @@ -279,9 +390,37 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { &app.custom_link_input, ); } else if app.mode == UiMode::ConfirmDelete - && let Some(workspace_key) = app.pending_delete_workspace_key.as_deref() + && let Some(target) = app.pending_delete_target.as_ref() { - draw_confirm_delete_modal(frame, workspace_key); + match target { + PendingDeleteTarget::Workspace { workspace_key } => { + draw_confirm_delete_modal( + frame, + " Delete workspace ", + &format!( + "Delete workspace '{workspace_key}'? This stops the workspace and removes its files and containers." + ), + ); + } + PendingDeleteTarget::Task { + workspace_key, + task_id, + } => { + let task_label = app + .snapshots + .get(workspace_key) + .and_then(|snapshot| task_persistent_snapshot(snapshot, task_id)) + .map(task_row_label) + .unwrap_or_else(|| task_id.clone()); + draw_confirm_delete_modal( + frame, + " Delete task ", + &format!( + "Delete task '{task_label}' from workspace '{workspace_key}'? This removes the task worktree and multicode tracking." + ), + ); + } + } } else if app.mode == UiMode::StartingModal && let Some(workspace_key) = app.starting_workspace_key.as_deref() { @@ -563,7 +702,7 @@ pub(crate) fn draw_create_modal( ); } -fn draw_confirm_delete_modal(frame: &mut Frame, workspace_key: &str) { +fn draw_confirm_delete_modal(frame: &mut Frame, title: &str, message: &str) { let area = centered_rect_fixed( CONFIRM_DELETE_MODAL_WIDTH, CONFIRM_DELETE_MODAL_HEIGHT, @@ -572,7 +711,7 @@ fn draw_confirm_delete_modal(frame: &mut Frame, workspace_key: &str) { frame.render_widget(Clear, area); let block = Block::default() - .title(" Delete workspace ") + .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Red)); let inner = block.inner(area); @@ -589,11 +728,9 @@ fn draw_confirm_delete_modal(frame: &mut Frame, workspace_key: &str) { .split(inner); frame.render_widget( - Paragraph::new(format!( - "Delete workspace '{workspace_key}'? This stops the workspace and removes its files and containers." - )) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), + Paragraph::new(message) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), rows[1], ); frame.render_widget( diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 2abb702..6212d63 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -6,8 +6,8 @@ mod tests { use super::*; use crate::app::{ - compact_github_tooltip_target, should_auto_resume_autonomous_codex_after_attach, - starting_modal_failure_status, + compact_github_tooltip_target, restored_selected_row, + should_auto_resume_autonomous_codex_after_attach, starting_modal_failure_status, }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, @@ -15,7 +15,7 @@ mod tests { }; use crate::ops::{ SessionWaitState, attach_cli_args, build_handler_command, command_exists, - session_wait_state_for_entry, tmux_session_command, tmux_status_left, + session_wait_state_for_entry, task_attach_target, tmux_session_command, tmux_status_left, validate_workspace_link_target, workspace_attach_target, workspace_ordering, }; use crate::render::selected_link_tooltip_area; @@ -91,6 +91,8 @@ mod tests { automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, + active_task_id: None, + task_states: Default::default(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -119,6 +121,8 @@ mod tests { automation_agent_state: None, automation_status: None, automation_scan_request_nonce: 0, + active_task_id: None, + task_states: Default::default(), usage_total_tokens: None, usage_total_cost: None, usage_cpu_percent: None, @@ -131,30 +135,172 @@ mod tests { &[].as_slice() } + fn assign_active_task(snapshot: &mut WorkspaceSnapshot, issue_url: &str) { + let task_id = "task-42".to_string(); + snapshot + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + task_id.clone(), + issue_url.to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some(task_id); + } + + #[test] + fn restored_selected_row_preserves_selected_task_row() { + let entries = vec![ + TableEntry::Create, + TableEntry::Workspace { + workspace_key: "test123".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-16".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }, + ]; + + let selected = restored_selected_row( + &entries, + Some(&TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }), + 3, + ); + + assert_eq!(selected, 3); + } + + #[test] + fn restored_selected_row_falls_back_to_workspace_when_task_disappears() { + let entries = vec![ + TableEntry::Create, + TableEntry::Workspace { + workspace_key: "test123".to_string(), + }, + TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-16".to_string(), + }, + ]; + + let selected = restored_selected_row( + &entries, + Some(&TableEntry::Task { + workspace_key: "test123".to_string(), + task_id: "task-14".to_string(), + }), + 3, + ); + + assert_eq!(selected, 1); + } + + #[test] + fn task_issue_reference_uses_repo_and_issue_number_only() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-8".to_string(), + "https://github.com/graemerocher/multicode-test/issues/8".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); + + assert_eq!(crate::task_row_label(&task), "➑️ multicode-test#8"); + } + + #[test] + fn task_issue_link_defaults_to_persistent_issue_url() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); + + assert_eq!( + crate::task_issue_link(&task, None), + "https://github.com/graemerocher/multicode-test/issues/1" + ); + } + + #[test] + fn github_link_badge_uses_terminal_issue_or_pr_number() { + assert_eq!( + crate::github_link_badge("https://github.com/graemerocher/multicode-test/issues/7"), + "#7" + ); + assert_eq!( + crate::github_link_badge("https://github.com/graemerocher/multicode-test/pull/12"), + "#12" + ); + } + + #[test] + fn task_links_expose_issue_and_pr_for_task_rows() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + pr: vec!["https://github.com/graemerocher/multicode-test/pull/8".to_string()], + ..Default::default() + }; + + let links = crate::task_links(&task, Some(&task_state)); + assert_eq!(links.len(), 2); + assert_eq!(links[0].kind, WorkspaceLinkKind::Issue); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); + } + + #[test] + fn workspace_links_hide_issue_and_pr_when_tasks_exist() { + let mut started = snapshot(true, Some("http://example")); + started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; + started.persistent.agent_provided.issue = vec!["https://example.com/issue/3".to_string()]; + started.persistent.agent_provided.pr = vec!["https://example.com/pull/4".to_string()]; + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + + let links = workspace_links(&started); + assert_eq!(links.len(), 1); + assert_eq!(links[0].kind, WorkspaceLinkKind::Review); + } + #[test] fn should_request_autonomous_issue_scan_for_assigned_workspace_without_active_issue() { let mut stopped = WorkspaceSnapshot::default(); stopped.persistent.assigned_repository = Some("micronaut-projects/micronaut-serialization".to_string()); - assert!(crate::app::should_request_autonomous_issue_scan(&stopped)); + assert!(crate::app::should_request_autonomous_issue_scan(&stopped, 5)); + + stopped + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-989".to_string(), + "https://github.com/micronaut-projects/micronaut-serialization/issues/989" + .to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + assert!(crate::app::should_request_autonomous_issue_scan(&stopped, 5)); + assert!(!crate::app::should_request_autonomous_issue_scan(&stopped, 1)); - stopped.persistent.automation_issue = Some( - "https://github.com/micronaut-projects/micronaut-serialization/issues/989".to_string(), - ); - assert!(!crate::app::should_request_autonomous_issue_scan(&stopped)); + stopped.persistent.archived = true; + assert!(!crate::app::should_request_autonomous_issue_scan(&stopped, 5)); let unassigned = WorkspaceSnapshot::default(); - assert!(!crate::app::should_request_autonomous_issue_scan( - &unassigned - )); + assert!(!crate::app::should_request_autonomous_issue_scan(&unassigned, 5)); } #[test] fn auto_resume_after_attach_only_resumes_active_autonomous_work() { let mut snapshot = WorkspaceSnapshot::default(); snapshot.persistent.assigned_repository = Some("example/repo".to_string()); - snapshot.persistent.automation_issue = - Some("https://github.com/example/repo/issues/42".to_string()); + assign_active_task(&mut snapshot, "https://github.com/example/repo/issues/42"); snapshot.automation_agent_state = Some(AutomationAgentState::Working); assert!(should_auto_resume_autonomous_codex_after_attach(&snapshot)); @@ -172,7 +318,8 @@ mod tests { snapshot.root_session_status = Some(RootSessionStatus::Idle); assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); - snapshot.persistent.automation_issue = None; + snapshot.active_task_id = None; + snapshot.persistent.tasks.clear(); snapshot.root_session_status = Some(RootSessionStatus::Busy); assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); } @@ -289,6 +436,49 @@ mod tests { ); } + #[test] + fn task_attach_target_uses_task_session_for_opencode() { + let started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("ses-task-1".to_string()), + ..Default::default() + }; + + let target = task_attach_target(&started, &task_state) + .expect("task attach target should use task session id"); + + assert_eq!( + target, + AttachTarget::Opencode { + uri: "http://127.0.0.1:3000/".to_string(), + username: "opencode".to_string(), + password: "secret".to_string(), + session_id: Some("ses-task-1".to_string()), + } + ); + } + + #[test] + fn task_attach_target_uses_task_thread_for_codex() { + let mut started = snapshot(false, Some("ws://127.0.0.1:3456")); + started.root_session_id = Some("thread-root".to_string()); + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-task-1".to_string()), + ..Default::default() + }; + + let target = task_attach_target(&started, &task_state) + .expect("task attach target should use task thread id"); + + assert_eq!( + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: Some("thread-task-1".to_string()), + } + ); + } + #[test] fn attach_cli_args_use_codex_resume_for_codex_target() { let target = AttachTarget::Codex { @@ -296,6 +486,25 @@ mod tests { thread_id: Some("thread-123".to_string()), }; + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "resume".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "thread-123".to_string(), + ] + ); + } + + #[test] + fn attach_cli_args_use_last_for_codex_when_thread_is_unavailable() { + let target = AttachTarget::Codex { + uri: "ws://127.0.0.1:3456".to_string(), + thread_id: None, + }; + assert_eq!( attach_cli_args("codex", &target), vec![ @@ -464,7 +673,8 @@ mod tests { runtime.block_on(async { let root = TestDir::new(); let workspace_dir = root.path().join("agent-work"); - let repo_dir = workspace_dir.join("core12299").join("micronaut-core"); + fs::create_dir_all(&workspace_dir).expect("workspace dir should be created"); + let repo_dir = workspace_dir.join("micronaut-core"); fs::create_dir_all(&repo_dir).expect("repo dir should be created"); fs::create_dir(repo_dir.join(".git")).expect(".git folder should be created"); @@ -507,7 +717,7 @@ mod tests { } #[test] - fn validate_workspace_link_target_rejects_repo_without_git_folder() { + fn validate_workspace_link_target_rejects_repo_without_git_entry() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -526,8 +736,8 @@ mod tests { }; let err = validate_workspace_link_target(&link, &workspace_dir) .await - .expect_err("repo without .git folder should be rejected"); - assert!(err.to_string().contains("must contain a '.git' folder")); + .expect_err("repo without .git entry should be rejected"); + assert!(err.to_string().contains("must contain a '.git' entry")); }); } @@ -555,6 +765,7 @@ mod tests { #[test] fn workspace_links_collect_review_issue_and_pr_entries() { let mut started = snapshot(true, Some("http://example")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); started.persistent.agent_provided.repo = vec!["/tmp/repo-a".to_string()]; started.persistent.agent_provided.issue = vec!["https://example.com/issue/1".to_string()]; started.persistent.agent_provided.pr = vec!["https://example.com/pull/2".to_string()]; @@ -563,6 +774,11 @@ mod tests { assert_eq!( links, vec![ + WorkspaceLink { + kind: WorkspaceLinkKind::Issue, + value: "https://github.com/example/repo/issues/42".to_string(), + source: WorkspaceLinkSource::Automation, + }, WorkspaceLink { kind: WorkspaceLinkKind::Review, value: "/tmp/repo-a".to_string(), @@ -706,10 +922,10 @@ mod tests { "https://github.com/micronaut-projects/micronaut-serialization/issues/921".to_string(), ); let workspace = TestDir::new(); - let repo_path = workspace - .path() - .join("work/micronaut-serialization-921/.git"); + let repo_path = workspace.path().join("work/micronaut-serialization-921"); fs::create_dir_all(&repo_path).expect("issue worktree repo should be created"); + fs::write(repo_path.join(".git"), "gitdir: /tmp/mock-worktree\n") + .expect("issue worktree git file should be created"); assert_eq!( compare_target_path(&started, &HashMap::new(), workspace.path()), @@ -1065,6 +1281,7 @@ mod tests { 1, 1, Some(&started), + false, 1, Some(0), false, @@ -1101,6 +1318,7 @@ mod tests { 1, 1, Some(&started), + false, 1, Some(0), true, @@ -1130,6 +1348,7 @@ mod tests { 1, 1, Some(&started), + false, 1, Some(0), true, @@ -1161,6 +1380,7 @@ mod tests { 1, 1, Some(&started), + false, 1, Some(0), false, @@ -1184,6 +1404,7 @@ mod tests { 1, 1, Some(&started), + false, 1, Some(0), false, @@ -1210,6 +1431,7 @@ mod tests { 0, 0, None, + false, 0, None, false, @@ -1240,6 +1462,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1264,6 +1487,7 @@ mod tests { 1, 1, Some(&stopped), + false, 0, None, false, @@ -1293,6 +1517,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1321,6 +1546,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1349,6 +1575,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1369,6 +1596,41 @@ mod tests { assert!(text.contains("x delete")); } + #[test] + fn help_line_limits_actions_for_task_row_focus() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 0, + None, + false, + false, + None, + false, + false, + true, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("Enter attach")); + assert!(text.contains("c compare")); + assert!(text.contains("x delete")); + assert!(!text.contains("i issue")); + assert!(!text.contains("d edit description")); + assert!(!text.contains("a archive")); + assert!(!text.contains("r recheck GH status")); + } + #[test] fn help_line_shows_compare_hotkey_only_when_enabled() { let started = snapshot(true, Some("http://example")); @@ -1377,6 +1639,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1400,6 +1663,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1426,6 +1690,7 @@ mod tests { 1, 1, Some(&snapshot(false, None)), + false, 0, None, false, @@ -1453,6 +1718,7 @@ mod tests { 1, 1, Some(&snapshot(false, None)), + false, 0, None, false, @@ -1470,7 +1736,7 @@ mod tests { .map(|span| span.content.as_ref()) .collect::(); - assert!(text.contains("Delete workspace:")); + assert!(text.contains("Delete item:")); assert!(text.contains("Enter")); } @@ -1530,6 +1796,7 @@ mod tests { 1, 1, Some(&started), + false, 0, None, false, @@ -1692,6 +1959,7 @@ mod tests { 1, 1, Some(&active), + false, 0, None, false, @@ -1717,6 +1985,7 @@ mod tests { 1, 1, Some(&archived), + false, 0, None, false, @@ -1765,8 +2034,7 @@ mod tests { #[test] fn server_cell_label_uses_automation_question_state() { let mut started = snapshot(true, Some("http://example")); - started.persistent.automation_issue = - Some("https://github.com/example/repo/issues/42".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); started.automation_agent_state = Some(AutomationAgentState::Question); assert_eq!(server_cell_label(&started), "Question"); @@ -1775,8 +2043,7 @@ mod tests { #[test] fn server_cell_label_uses_automation_review_state_as_idle() { let mut started = snapshot(true, Some("http://example")); - started.persistent.automation_issue = - Some("https://github.com/example/repo/issues/42".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); started.automation_agent_state = Some(AutomationAgentState::Review); assert_eq!(server_cell_label(&started), "Idle"); @@ -2025,6 +2292,7 @@ mod tests { 1, 1, Some(&snapshot(true, Some("http://example"))), + false, 0, None, false, @@ -2051,6 +2319,7 @@ mod tests { 0, 2, None, + false, 0, None, false, @@ -2076,6 +2345,7 @@ mod tests { 2, 2, Some(&last_workspace), + false, 0, None, false, diff --git a/workspace-skills/autonomous-state/SKILL.md b/workspace-skills/autonomous-state/SKILL.md index cd4d754..53442cf 100644 --- a/workspace-skills/autonomous-state/SKILL.md +++ b/workspace-skills/autonomous-state/SKILL.md @@ -6,7 +6,16 @@ description: Maintain the multicode autonomous state file while working autonomo When operating in a multicode autonomous workspace, the environment variable `MULTICODE_AUTONOMOUS_STATE_PATH` points to a writable state file owned by multicode. You must keep this file updated. -Write exactly one line to that file: +Write exactly one line to that file. + +If multicode tells you the current task session or thread id, write the state as: + +- `working:` +- `question:` +- `review:` +- `idle:` + +If no session/thread id was provided, fall back to the plain state word: - `working` - `question` @@ -28,5 +37,6 @@ Required workflow: - When the change is ready for human review or publish approval, write `review` before you stop. - Only write `idle` if the issue is fully complete and no further action is pending. - After resuming from an interruption, attach, or restart, immediately write the current state again before continuing. +- When a session/thread id was provided for the task, include it after the colon every time you write the state so multicode can distinguish parallel sessions on the same VM. -Do not write anything except the single state word to this file. +Do not write anything except the single state word, or the state followed by `:`, to this file. From 9211768fcbcbcc082342e2bed91aa590ec6d7f3b Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 13 Apr 2026 10:14:08 +0200 Subject: [PATCH 15/75] Improve Codex task reconciliation and task status updates Refine multicode's Codex task lifecycle handling across autonomous task sessions. - recover newer Codex task sessions from persisted session history when stale interrupted threads are still referenced - keep per-task session ownership aligned with resumed task threads so attach and metadata resolution use the current session - update task descriptions after optimistic background resume so reviewable tasks show PR-created state instead of leaving a stale resuming banner - harden autonomous state tracking and related TUI behavior around resumed review/working transitions Co-Authored-By: multicode --- Cargo.lock | 1 + lib/src/lib.rs | 58 +- .../services/automation_state_file_service.rs | 509 +++- .../services/autonomous_workspace_service.rs | 2180 +++++++++++++++-- lib/src/services/codex_app_server.rs | 18 +- .../services/codex_root_session_service.rs | 15 +- lib/src/services/combined.rs | 420 +++- lib/src/services/runtime.rs | 200 +- tui/Cargo.toml | 1 + tui/src/app.rs | 582 ++++- tui/src/main.rs | 71 +- tui/src/render.rs | 50 +- tui/src/tests.rs | 212 +- workspace-skills/autonomous-state/SKILL.md | 14 +- 14 files changed, 3842 insertions(+), 489 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dece4f..20d6729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1812,6 +1812,7 @@ dependencies = [ "multicode-lib", "ratatui", "rustix", + "serde_json", "size", "tokio", "toml 1.0.6+spec-1.1.0", diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 8f5d6c8..f01a5b5 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -74,6 +74,8 @@ pub struct WorkspaceTaskPersistentSnapshot { pub id: String, pub issue_url: String, #[serde(default)] + pub backing_pr_url: Option, + #[serde(default)] pub source: WorkspaceTaskSource, #[serde(default)] pub created_at: Option, @@ -84,10 +86,16 @@ impl WorkspaceTaskPersistentSnapshot { Self { id, issue_url, + backing_pr_url: None, source, created_at: Some(SystemTime::now()), } } + + pub fn with_backing_pr_url(mut self, backing_pr_url: Option) -> Self { + self.backing_pr_url = backing_pr_url; + self + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -252,7 +260,10 @@ impl Default for WorkspaceSnapshot { } impl WorkspaceSnapshot { - pub fn task_persistent_snapshot(&self, task_id: &str) -> Option<&WorkspaceTaskPersistentSnapshot> { + pub fn task_persistent_snapshot( + &self, + task_id: &str, + ) -> Option<&WorkspaceTaskPersistentSnapshot> { self.persistent.tasks.iter().find(|task| task.id == task_id) } @@ -265,18 +276,22 @@ impl WorkspaceSnapshot { if self .active_task_id .as_deref() - .is_some_and(|task_id| self.task_persistent_snapshot(task_id).is_some()) + .is_some_and(|task_id| task_holds_vm_lease(self, task_id)) { return self.active_task_id.clone(); } - self.persistent.automation_issue.as_deref().and_then(|issue_url| { - self.persistent - .tasks - .iter() - .find(|task| task.issue_url == issue_url) - .map(|task| task.id.clone()) - }) + self.persistent + .automation_issue + .as_deref() + .and_then(|issue_url| { + self.persistent + .tasks + .iter() + .find(|task| task.issue_url == issue_url) + .map(|task| task.id.clone()) + }) + .filter(|task_id| task_holds_vm_lease(self, task_id)) } pub fn resolved_active_issue_url(&self) -> Option { @@ -287,3 +302,28 @@ impl WorkspaceSnapshot { .or_else(|| self.persistent.automation_issue.clone()) } } + +fn task_holds_vm_lease(snapshot: &WorkspaceSnapshot, task_id: &str) -> bool { + let Some(_task) = snapshot.task_persistent_snapshot(task_id) else { + return false; + }; + let Some(task_state) = snapshot.task_states.get(task_id) else { + return true; + }; + let Some(session_id) = task_state.session_id.as_deref() else { + return true; + }; + let _ = session_id; + !matches!( + task_state.session_status, + Some(RootSessionStatus::Question | RootSessionStatus::Idle) + ) && !matches!( + task_state.agent_state, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ) +} diff --git a/lib/src/services/automation_state_file_service.rs b/lib/src/services/automation_state_file_service.rs index 285bc2b..9d64e69 100644 --- a/lib/src/services/automation_state_file_service.rs +++ b/lib/src/services/automation_state_file_service.rs @@ -7,7 +7,11 @@ use std::{ use tokio::time::{MissedTickBehavior, interval}; use super::{ - root_session_service::RootSessionStatus, runtime::automation_state_file_source, + root_session_service::RootSessionStatus, + runtime::{ + automation_state_file_source, automation_task_state_dir_source, + automation_task_state_file_source, + }, workspace_watch::monitor_workspace_snapshots, }; use crate::{ @@ -36,12 +40,7 @@ pub async fn automation_state_file_service( let workspace_directory_path = workspace_directory_path.clone(); async move { tokio::spawn(async move { - watch_workspace( - workspace, - workspace_rx, - automation_state_file_source(&workspace_directory_path, &key), - ) - .await; + watch_workspace(workspace, workspace_rx, workspace_directory_path, key).await; }); Ok(()) } @@ -52,7 +51,8 @@ pub async fn automation_state_file_service( async fn watch_workspace( workspace: Workspace, mut workspace_rx: tokio::sync::watch::Receiver, - state_file: PathBuf, + workspace_directory_path: PathBuf, + workspace_key: String, ) { let mut refresh = interval(STATE_REFRESH_INTERVAL); refresh.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -65,7 +65,13 @@ async fn watch_workspace( && active_task_id_for_snapshot(&snapshot).is_some(); if should_track { - apply_state_file_snapshot(&workspace, read_state_file(&state_file).await); + let task_snapshots = + read_task_state_snapshots(&workspace_directory_path, &workspace_key, &snapshot) + .await; + apply_task_state_snapshots(&workspace, task_snapshots); + let updated_snapshot = workspace.subscribe().borrow().clone(); + mirror_task_state_files(&workspace_directory_path, &workspace_key, &updated_snapshot) + .await; } else { clear_automation_state(&workspace); } @@ -81,12 +87,58 @@ async fn watch_workspace( } } -fn apply_state_file_snapshot(workspace: &Workspace, next: Option) { +async fn read_task_state_snapshots( + workspace_directory_path: &Path, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, +) -> Vec<(String, Option)> { + let mut states = Vec::with_capacity(snapshot.persistent.tasks.len()); + for task in &snapshot.persistent.tasks { + let path = + automation_task_state_file_source(workspace_directory_path, workspace_key, &task.id); + states.push((task.id.clone(), read_state_file(&path).await)); + } + + if states.iter().all(|(_, state)| state.is_none()) { + let legacy = read_state_file(&automation_state_file_source( + workspace_directory_path, + workspace_key, + )) + .await; + if legacy.is_some() { + states.push(("__legacy__".to_string(), legacy)); + } + } + + states +} + +fn apply_task_state_snapshots( + workspace: &Workspace, + task_snapshots: Vec<(String, Option)>, +) { + for (task_id, next) in task_snapshots { + if task_id == "__legacy__" { + apply_state_file_snapshot(workspace, None, next); + } else { + apply_state_file_snapshot(workspace, Some(task_id.as_str()), next); + } + } +} + +fn apply_state_file_snapshot( + workspace: &Workspace, + fixed_task_id: Option<&str>, + next: Option, +) { workspace.update(|snapshot| { let resolved_active_task_id = active_task_id_for_snapshot(snapshot); - let Some(target_task_id) = - state_update_target_task_id(snapshot, resolved_active_task_id.as_deref(), next.as_ref()) - else { + let Some(target_task_id) = state_update_target_task_id( + fixed_task_id, + snapshot, + resolved_active_task_id.as_deref(), + next.as_ref(), + ) else { return if next.is_some() { false } else { @@ -101,9 +153,33 @@ fn apply_state_file_snapshot(workspace: &Workspace, next: Option Option { snapshot.resolved_active_task_id() } +fn task_should_wait_on_vm(is_active: bool, agent_state: Option) -> bool { + !is_active + && !matches!( + agent_state, + Some( + AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale + ) + ) +} + fn state_update_target_task_id( + fixed_task_id: Option<&str>, snapshot: &WorkspaceSnapshot, resolved_active_task_id: Option<&str>, next: Option<&ParsedAutomationState>, ) -> Option { + if let Some(task_id) = fixed_task_id { + return Some(task_id.to_string()); + } + if let Some(thread_id) = next.and_then(|state| state.thread_id.as_deref()) { if let Some(task_id) = task_id_for_session_id(snapshot, thread_id) { return Some(task_id); } - if let Some(active_task_id) = resolved_active_task_id - { + if let Some(active_task_id) = resolved_active_task_id { let expected_session_id = expected_session_id_for_active_task(snapshot, active_task_id); if expected_session_id.is_none() || expected_session_id == Some(thread_id) { return Some(active_task_id.to_string()); @@ -220,9 +314,12 @@ fn expected_session_id_for_active_task<'a>( } fn task_id_for_session_id(snapshot: &WorkspaceSnapshot, session_id: &str) -> Option { - snapshot.task_states.iter().find_map(|(task_id, task_state)| { - (task_state.session_id.as_deref() == Some(session_id)).then(|| task_id.clone()) - }) + snapshot + .task_states + .iter() + .find_map(|(task_id, task_state)| { + (task_state.session_id.as_deref() == Some(session_id)).then(|| task_id.clone()) + }) } async fn read_state_file(path: &Path) -> Option { @@ -231,6 +328,65 @@ async fn read_state_file(path: &Path) -> Option { parse_state_file(&contents) } +async fn mirror_task_state_files( + workspace_directory_path: &Path, + workspace_key: &str, + snapshot: &WorkspaceSnapshot, +) { + let task_dir = automation_task_state_dir_source(workspace_directory_path, workspace_key); + let _ = tokio::fs::create_dir_all(&task_dir).await; + + for task in &snapshot.persistent.tasks { + let Some(task_state) = snapshot.task_states.get(&task.id) else { + continue; + }; + let Some(session_id) = task_state.session_id.as_deref() else { + continue; + }; + let Some(agent_state) = normalized_task_agent_state(task_state) else { + continue; + }; + if agent_state == AutomationAgentState::WaitingOnVm { + continue; + } + let line = format!("{}:{session_id}\n", state_label(agent_state)); + let path = + automation_task_state_file_source(workspace_directory_path, workspace_key, &task.id); + let _ = tokio::fs::write(path, line).await; + } +} + +fn state_label(state: AutomationAgentState) -> &'static str { + match state { + AutomationAgentState::Working => "working", + AutomationAgentState::WaitingOnVm => "working", + AutomationAgentState::Question => "question", + AutomationAgentState::Review => "review", + AutomationAgentState::Idle => "idle", + AutomationAgentState::Stale => "stale", + } +} + +fn normalized_task_agent_state( + task_state: &crate::WorkspaceTaskRuntimeSnapshot, +) -> Option { + match task_state.session_status { + Some(RootSessionStatus::Question) => Some(AutomationAgentState::Question), + Some(RootSessionStatus::Idle) if task_state.session_id.is_some() => { + Some(AutomationAgentState::Review) + } + Some(RootSessionStatus::Idle) => Some(AutomationAgentState::Idle), + Some(RootSessionStatus::Busy) => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => Some(AutomationAgentState::Working), + other => other, + }, + None => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => None, + other => other, + }, + } +} + fn parse_state_file(contents: &str) -> Option { let trimmed = contents.trim(); let (state, thread_id) = @@ -301,11 +457,14 @@ mod tests { #[test] fn active_task_id_falls_back_to_automation_issue_mapping() { let mut snapshot = WorkspaceSnapshot::default(); - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( - "task-42".to_string(), - "https://github.com/example/repo/issues/42".to_string(), - WorkspaceTaskSource::Manual, - )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); snapshot.persistent.automation_issue = Some("https://github.com/example/repo/issues/42".to_string()); @@ -319,11 +478,14 @@ mod tests { fn apply_state_file_snapshot_populates_task_state_for_fallback_active_task() { let workspace = Workspace::new(WorkspaceSnapshot::default()); workspace.update(|snapshot| { - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( - "task-42".to_string(), - "https://github.com/example/repo/issues/42".to_string(), - WorkspaceTaskSource::Manual, - )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); snapshot.persistent.automation_issue = Some("https://github.com/example/repo/issues/42".to_string()); true @@ -331,6 +493,7 @@ mod tests { apply_state_file_snapshot( &workspace, + None, Some(ParsedAutomationState { state: AutomationAgentState::Working, thread_id: Some("thread-42".to_string()), @@ -357,11 +520,14 @@ mod tests { let workspace = Workspace::new(WorkspaceSnapshot::default()); workspace.update(|snapshot| { snapshot.active_task_id = Some("task-42".to_string()); - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( - "task-42".to_string(), - "https://github.com/example/repo/issues/42".to_string(), - WorkspaceTaskSource::Manual, - )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); snapshot.task_states.insert( "task-42".to_string(), crate::WorkspaceTaskRuntimeSnapshot { @@ -377,6 +543,7 @@ mod tests { apply_state_file_snapshot( &workspace, + None, Some(ParsedAutomationState { state: AutomationAgentState::Review, thread_id: Some("thread-old".to_string()), @@ -398,15 +565,74 @@ mod tests { } #[test] - fn apply_state_file_snapshot_preserves_existing_session_when_state_file_missing() { + fn apply_state_file_snapshot_ignores_stale_non_working_state_for_different_session() { let workspace = Workspace::new(WorkspaceSnapshot::default()); workspace.update(|snapshot| { snapshot.active_task_id = Some("task-42".to_string()); - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( "task-42".to_string(), - "https://github.com/example/repo/issues/42".to_string(), - WorkspaceTaskSource::Manual, - )); + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-new".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-new".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-42"), + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-old".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-new")); + assert_eq!( + snapshot.automation_agent_state, + Some(AutomationAgentState::Working) + ); + assert_eq!( + snapshot.automation_session_status, + Some(RootSessionStatus::Busy) + ); + let task_state = snapshot + .task_states + .get("task-42") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-new")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + } + + #[test] + fn apply_state_file_snapshot_preserves_existing_session_when_state_file_missing() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-42".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); snapshot.task_states.insert( "task-42".to_string(), crate::WorkspaceTaskRuntimeSnapshot { @@ -422,7 +648,7 @@ mod tests { true }); - apply_state_file_snapshot(&workspace, None); + apply_state_file_snapshot(&workspace, None, None); let snapshot = workspace.subscribe().borrow().clone(); assert_eq!(snapshot.automation_session_id.as_deref(), Some("thread-42")); @@ -447,16 +673,22 @@ mod tests { fn apply_state_file_snapshot_updates_matching_task_without_reassigning_active_lease() { let workspace = Workspace::new(WorkspaceSnapshot::default()); workspace.update(|snapshot| { - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( - "task-42".to_string(), - "https://github.com/example/repo/issues/42".to_string(), - WorkspaceTaskSource::Manual, - )); - snapshot.persistent.tasks.push(WorkspaceTaskPersistentSnapshot::new( - "task-30".to_string(), - "https://github.com/example/repo/issues/30".to_string(), - WorkspaceTaskSource::Manual, - )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); snapshot.active_task_id = Some("task-30".to_string()); snapshot.persistent.automation_issue = Some("https://github.com/example/repo/issues/30".to_string()); @@ -486,6 +718,7 @@ mod tests { apply_state_file_snapshot( &workspace, + None, Some(ParsedAutomationState { state: AutomationAgentState::Review, thread_id: Some("thread-42".to_string()), @@ -506,6 +739,7 @@ mod tests { assert_eq!(task_42.session_id.as_deref(), Some("thread-42")); assert_eq!(task_42.agent_state, Some(AutomationAgentState::Review)); assert_eq!(task_42.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_42.waiting_on_vm); let task_30 = snapshot .task_states .get("task-30") @@ -513,4 +747,171 @@ mod tests { assert_eq!(task_30.session_id.as_deref(), Some("thread-30")); assert_eq!(task_30.agent_state, Some(AutomationAgentState::Working)); } + + #[test] + fn apply_state_file_snapshot_keeps_non_active_working_task_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + None, + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Busy)); + assert!(task_42.waiting_on_vm); + } + + #[test] + fn apply_state_file_snapshot_targets_explicit_task_file_without_reassigning_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-30".to_string(), + "https://github.com/example/repo/issues/30".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.active_task_id = Some("task-30".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/30".to_string()); + snapshot.task_states.insert( + "task-42".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-42"), + Some(ParsedAutomationState { + state: AutomationAgentState::Review, + thread_id: Some("thread-42".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + assert_eq!(snapshot.active_task_id.as_deref(), Some("task-30")); + let task_42 = snapshot + .task_states + .get("task-42") + .expect("task 42 should remain"); + assert_eq!(task_42.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_42.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_42.waiting_on_vm); + } + + #[test] + fn normalized_task_agent_state_prefers_review_when_idle_session_is_present() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); + } + + #[test] + fn apply_state_file_snapshot_ignores_stale_working_update_for_review_task_session() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.active_task_id = Some("task-39".to_string()); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/example/repo/issues/39".to_string(), + WorkspaceTaskSource::Manual, + )); + snapshot.task_states.insert( + "task-39".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Review), + ..Default::default() + }, + ); + true + }); + + apply_state_file_snapshot( + &workspace, + Some("task-39"), + Some(ParsedAutomationState { + state: AutomationAgentState::Working, + thread_id: Some("thread-39".to_string()), + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-39") + .expect("task 39 should remain"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + } } diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index cf967c3..83d8884 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -16,7 +16,10 @@ use tokio::{ use super::{ CombinedService, GithubStatus, codex_app_server::CodexAppServerClient, - runtime::{AUTOMATION_STATE_ENV, automation_state_file_source, synthetic_codex_home_source}, + runtime::{ + automation_state_file_source, automation_task_state_file_source, + synthetic_codex_home_source, + }, workspace_watch::monitor_workspace_snapshots, }; use crate::{ @@ -32,9 +35,21 @@ const ISSUE_PRIORITY_LABELS: [&str; 4] = [ "type: enhancement", ]; const ISSUE_PRIORITY_BOOST_LABELS: [&str; 2] = ["type: regression", "priority: high"]; +const DEPENDENCY_UPGRADE_LABEL: &str = "type: dependency-upgrade"; +const NON_MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 4] = ["minor", "patch", "pin", "digest"]; +const MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 1] = ["major"]; +const RENOVATE_LOGINS: [&str; 2] = ["renovate[bot]", "app/renovate"]; +const DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = "Dependency upgrade follow-up for PR #"; +const DEPENDENCY_UPGRADE_PR_MARKER_PREFIX: &str = "", + pr_url = pr.url, + marker = DEPENDENCY_UPGRADE_PR_MARKER_PREFIX + ) +} + +fn dependency_upgrade_issue_search_queries(pr: &SelectedPullRequest) -> [String; 2] { + [ + format!( + "\"{DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX}{}\" in:title", + pr.number + ), + format!("\"{}\" in:body", pr.url), + ] +} + async fn add_work_started_comment( assigned_repository: &str, issue: &SelectedIssue, @@ -2269,6 +3203,15 @@ pub(crate) fn issue_reference(url: &str) -> Option { Some(format!("{owner}/{repo}#{number}")) } +fn pull_request_reference(url: &str) -> Option { + let stripped = url.strip_prefix("https://github.com/")?; + let segments = stripped.split('/').collect::>(); + if segments.len() < 4 || segments[2] != "pull" { + return None; + } + Some(format!("#{}", segments[3])) +} + pub(crate) fn normalize_github_issue_spec( assigned_repository: &str, input: &str, @@ -2343,7 +3286,11 @@ struct SelectedIssue { state: Option, #[serde(rename = "isPullRequest")] is_pull_request: Option, + #[serde(default)] + body: Option, labels: Vec, + #[serde(skip)] + dependency_upgrade_pr_url: Option, } impl SelectedIssue { @@ -2374,6 +3321,14 @@ impl SelectedIssue { matches!(self.state.as_deref(), Some("OPEN") | Some("open")) && self.is_pull_request != Some(true) } + + fn backing_pr_url(&self) -> Option<&str> { + self.dependency_upgrade_pr_url.as_deref().or_else(|| { + self.body + .as_deref() + .and_then(extract_dependency_upgrade_pr_marker) + }) + } } fn issue_priority_cmp(left: &SelectedIssue, right: &SelectedIssue) -> std::cmp::Ordering { @@ -2392,16 +3347,118 @@ struct SelectedIssueLabel { name: String, } -#[cfg(test)] -mod tests { - use super::*; - use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; - use crate::WorkspaceSnapshot; +#[derive(Debug, Clone, Deserialize)] +struct SelectedPullRequest { + number: u64, + title: String, + url: String, + state: Option, + #[serde(rename = "isDraft")] + is_draft: Option, + #[serde(default)] + body: Option, + #[serde(default)] + labels: Vec, + author: Option, +} - #[test] - fn normalize_github_repository_spec_accepts_owner_repo_and_urls() { - assert_eq!( - normalize_github_repository_spec("micronaut-projects/micronaut-core"), +impl SelectedPullRequest { + fn has_label(&self, label: &str) -> bool { + self.labels + .iter() + .any(|candidate| candidate.name.eq_ignore_ascii_case(label)) + } + + fn author_login(&self) -> Option<&str> { + self.author.as_ref().map(|author| author.login.as_str()) + } + + fn is_open_dependency_upgrade_candidate(&self) -> bool { + matches!(self.state.as_deref(), Some("OPEN") | Some("open")) + && !self.is_draft.unwrap_or(false) + && self.has_label(DEPENDENCY_UPGRADE_LABEL) + && self + .author_login() + .is_some_and(|login| RENOVATE_LOGINS.iter().any(|candidate| login == *candidate)) + } + + fn is_non_major_dependency_upgrade(&self) -> bool { + if MAJOR_DEPENDENCY_UPGRADE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return false; + } + if NON_MAJOR_DEPENDENCY_UPGRADE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return true; + } + dependency_upgrade_versions_from_text(&self.title) + .or_else(|| { + self.body + .as_deref() + .and_then(dependency_upgrade_versions_from_text) + }) + .is_some_and(|(from_major, to_major)| from_major == to_major) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SelectedGithubActor { + login: String, +} + +fn extract_dependency_upgrade_pr_marker(body: &str) -> Option<&str> { + let marker_start = body.find(DEPENDENCY_UPGRADE_PR_MARKER_PREFIX)?; + let content_start = marker_start + DEPENDENCY_UPGRADE_PR_MARKER_PREFIX.len(); + let content_end = body[content_start..] + .find("-->") + .map(|index| content_start + index) + .unwrap_or(body.len()); + let value = body[content_start..content_end].trim(); + (!value.is_empty()).then_some(value) +} + +fn dependency_upgrade_versions_from_text(text: &str) -> Option<(u64, u64)> { + let lower = text.to_ascii_lowercase(); + let from_index = lower.find(" from ")?; + let to_index = lower[from_index + 6..].find(" to ")? + from_index + 6; + let from_version = extract_leading_version(&text[from_index + 6..to_index])?; + let to_version = extract_leading_version(&text[to_index + 4..])?; + Some((from_version, to_version)) +} + +fn extract_leading_version(text: &str) -> Option { + let token = text + .split_whitespace() + .find(|candidate| candidate.chars().any(|ch| ch.is_ascii_digit()))?; + let token = token + .trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.' && ch != '-') + .trim_start_matches(['v', 'V']); + let major = token + .split(['.', '-']) + .next() + .filter(|segment| !segment.is_empty())?; + major.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceSnapshot; + use crate::services::codex_app_server::{CodexThreadActiveFlag, CodexThreadStatus}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn normalize_github_repository_spec_accepts_owner_repo_and_urls() { + assert_eq!( + normalize_github_repository_spec("micronaut-projects/micronaut-core"), Some("micronaut-projects/micronaut-core".to_string()) ); assert_eq!( @@ -2457,29 +3514,118 @@ mod tests { assert!(!start_retry_is_blocked(None, 4)); } + fn unique_test_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("multicode-{name}-{nonce}")) + } + + #[test] + fn recover_codex_thread_candidates_from_session_logs_prefers_latest_for_matching_cwd() { + let codex_home = unique_test_dir("codex-session-recovery"); + let sessions_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/13"); + fs::create_dir_all(&sessions_dir).expect("session dir should be created"); + + let cwd = "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-39"; + let older = sessions_dir.join("rollout-2026-04-13T07-41-03-019d85c9.jsonl"); + let newer = sessions_dir.join("rollout-2026-04-13T07-46-51-019d85ce.jsonl"); + let unrelated = sessions_dir.join("rollout-2026-04-13T07-50-00-019d85ff.jsonl"); + + fs::write( + &older, + format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d85c9\",\"cwd\":\"{cwd}\",\"timestamp\":\"2026-04-13T07:41:03.609Z\"}}}}\n" + ), + ) + .expect("older session log should be written"); + fs::write( + &newer, + format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d85ce\",\"cwd\":\"{cwd}\",\"timestamp\":\"2026-04-13T07:46:51.609Z\"}}}}\n" + ), + ) + .expect("newer session log should be written"); + fs::write( + &unrelated, + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"019d85ff\",\"cwd\":\"/tmp/other\",\"timestamp\":\"2026-04-13T07:50:00.000Z\"}}\n", + ) + .expect("unrelated session log should be written"); + + let recovered = recover_codex_thread_candidates_from_session_logs( + &codex_home, + &[cwd.to_string()], + ); + + assert_eq!( + recovered.get(cwd).map(|candidate| candidate.id.as_str()), + Some("019d85ce") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + + fn test_issue( + number: u64, + title: &str, + url: &str, + created_at: &str, + labels: Vec, + ) -> SelectedIssue { + SelectedIssue { + number, + title: title.to_string(), + url: url.to_string(), + created_at: created_at.to_string(), + state: Some("OPEN".to_string()), + is_pull_request: Some(false), + body: None, + labels, + dependency_upgrade_pr_url: None, + } + } + + fn test_pull_request( + number: u64, + title: &str, + url: &str, + labels: Vec, + body: Option<&str>, + ) -> SelectedPullRequest { + SelectedPullRequest { + number, + title: title.to_string(), + url: url.to_string(), + state: Some("OPEN".to_string()), + is_draft: Some(false), + body: body.map(ToOwned::to_owned), + labels, + author: Some(SelectedGithubActor { + login: "renovate[bot]".to_string(), + }), + } + } + #[test] fn find_next_issue_prioritizes_boost_labels_then_base_priority_then_newest() { let excluded = HashSet::from(["https://github.com/example/repo/issue/5".to_string()]); let mut issues = vec![ - SelectedIssue { - number: 5, - title: "already claimed".to_string(), - url: "https://github.com/example/repo/issue/5".to_string(), - created_at: "2026-04-09T10:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![SelectedIssueLabel { + test_issue( + 5, + "already claimed", + "https://github.com/example/repo/issue/5", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { name: "type: bug".to_string(), }], - }, - SelectedIssue { - number: 6, - title: "busy".to_string(), - url: "https://github.com/example/repo/issue/6".to_string(), - created_at: "2026-04-09T11:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![ + ), + test_issue( + 6, + "busy", + "https://github.com/example/repo/issue/6", + "2026-04-09T11:00:00Z", + vec![ SelectedIssueLabel { name: "type: bug".to_string(), }, @@ -2487,26 +3633,22 @@ mod tests { name: IN_PROGRESS_LABEL.to_string(), }, ], - }, - SelectedIssue { - number: 7, - title: "plain bug".to_string(), - url: "https://github.com/example/repo/issue/7".to_string(), - created_at: "2026-04-09T09:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![SelectedIssueLabel { + ), + test_issue( + 7, + "plain bug", + "https://github.com/example/repo/issue/7", + "2026-04-09T09:00:00Z", + vec![SelectedIssueLabel { name: "type: bug".to_string(), }], - }, - SelectedIssue { - number: 8, - title: "high priority enhancement".to_string(), - url: "https://github.com/example/repo/issue/8".to_string(), - created_at: "2026-04-09T08:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![ + ), + test_issue( + 8, + "high priority enhancement", + "https://github.com/example/repo/issue/8", + "2026-04-09T08:00:00Z", + vec![ SelectedIssueLabel { name: "type: enhancement".to_string(), }, @@ -2514,15 +3656,13 @@ mod tests { name: "priority: high".to_string(), }, ], - }, - SelectedIssue { - number: 9, - title: "regression bug".to_string(), - url: "https://github.com/example/repo/issue/9".to_string(), - created_at: "2026-04-09T07:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![ + ), + test_issue( + 9, + "regression bug", + "https://github.com/example/repo/issue/9", + "2026-04-09T07:00:00Z", + vec![ SelectedIssueLabel { name: "type: bug".to_string(), }, @@ -2530,7 +3670,7 @@ mod tests { name: "type: regression".to_string(), }, ], - }, + ), ]; issues.sort_by(issue_priority_cmp); @@ -2547,28 +3687,24 @@ mod tests { #[test] fn issue_priority_cmp_prefers_newer_issue_with_same_priority_bucket() { - let older = SelectedIssue { - number: 10, - title: "older".to_string(), - url: "https://github.com/example/repo/issues/10".to_string(), - created_at: "2026-04-09T07:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![SelectedIssueLabel { + let older = test_issue( + 10, + "older", + "https://github.com/example/repo/issues/10", + "2026-04-09T07:00:00Z", + vec![SelectedIssueLabel { name: "type: bug".to_string(), }], - }; - let newer = SelectedIssue { - number: 11, - title: "newer".to_string(), - url: "https://github.com/example/repo/issues/11".to_string(), - created_at: "2026-04-09T08:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![SelectedIssueLabel { + ); + let newer = test_issue( + 11, + "newer", + "https://github.com/example/repo/issues/11", + "2026-04-09T08:00:00Z", + vec![SelectedIssueLabel { name: "type: bug".to_string(), }], - }; + ); assert_eq!( issue_priority_cmp(&older, &newer), @@ -2598,15 +3734,13 @@ mod tests { #[test] fn selected_issue_candidate_must_be_open_and_not_a_pull_request() { - let open_issue = SelectedIssue { - number: 1, - title: "candidate".to_string(), - url: "https://github.com/example/repo/issues/1".to_string(), - created_at: "2026-04-09T10:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![], - }; + let open_issue = test_issue( + 1, + "candidate", + "https://github.com/example/repo/issues/1", + "2026-04-09T10:00:00Z", + vec![], + ); assert!(open_issue.is_open_issue_candidate()); let closed_issue = SelectedIssue { @@ -2625,20 +3759,19 @@ mod tests { #[test] fn ensure_workspace_task_claim_updates_workspace_state() { let workspace = Workspace::new(WorkspaceSnapshot::default()); - let issue = SelectedIssue { - number: 810, - title: "candidate".to_string(), - url: "https://github.com/example/repo/issues/810".to_string(), - created_at: "2026-04-09T10:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![], - }; + let issue = test_issue( + 810, + "candidate", + "https://github.com/example/repo/issues/810", + "2026-04-09T10:00:00Z", + vec![], + ); ensure_workspace_task_claim( &workspace, "example/repo", &issue, + None, WorkspaceTaskSource::Scan, ); @@ -2776,23 +3909,71 @@ mod tests { ); } + #[test] + fn sync_task_runtime_state_releases_yielded_active_task_lease() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-48".to_string(), + "https://github.com/example/repo/issues/48".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-48".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/48".to_string()); + snapshot.task_states.insert( + "task-48".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-48".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }, + ); + snapshot.automation_session_id = Some("thread-48".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Review); + snapshot.automation_session_status = Some(RootSessionStatus::Idle); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert!(next.active_task_id.is_none()); + assert!(next.persistent.automation_issue.is_none()); + assert!(next.automation_session_id.is_none()); + assert!(next.automation_agent_state.is_none()); + assert!(next.automation_session_status.is_none()); + let task_state = next + .task_states + .get("task-48") + .expect("task state should remain"); + assert_eq!(task_state.session_id.as_deref(), Some("thread-48")); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Review)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Idle)); + assert!(!task_state.waiting_on_vm); + } + #[test] fn clear_automation_issue_claim_only_clears_matching_issue() { let workspace = Workspace::new(WorkspaceSnapshot::default()); - let issue = SelectedIssue { - number: 810, - title: "candidate".to_string(), - url: "https://github.com/example/repo/issues/810".to_string(), - created_at: "2026-04-09T10:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![], - }; + let issue = test_issue( + 810, + "candidate", + "https://github.com/example/repo/issues/810", + "2026-04-09T10:00:00Z", + vec![], + ); ensure_workspace_task_claim( &workspace, "example/repo", &issue, + None, WorkspaceTaskSource::Scan, ); clear_automation_issue_claim(&workspace, "https://github.com/example/repo/issues/999"); @@ -2812,6 +3993,24 @@ mod tests { ); } + #[test] + fn task_session_id_is_current_rejects_stale_session_id() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot.task_states.insert( + "task-39".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-new".to_string()), + ..Default::default() + }, + ); + true + }); + + assert!(task_session_id_is_current(&workspace, "task-39", "thread-new")); + assert!(!task_session_id_is_current(&workspace, "task-39", "thread-old")); + } + #[test] fn clear_automation_issue_claim_promotes_next_task_to_active_lease() { let workspace = Workspace::new(WorkspaceSnapshot::default()); @@ -2968,30 +4167,33 @@ mod tests { sync_task_runtime_state(&workspace, &snapshot); let next = workspace.subscribe().borrow().clone(); - assert!(!next - .task_states - .get("task-1") - .expect("active task should exist") - .waiting_on_vm); - assert!(!next - .task_states - .get("task-2") - .expect("question task should exist") - .waiting_on_vm); - assert!(!next - .task_states - .get("task-3") - .expect("idle task should exist") - .waiting_on_vm); + assert!( + !next + .task_states + .get("task-1") + .expect("active task should exist") + .waiting_on_vm + ); + assert!( + !next + .task_states + .get("task-2") + .expect("question task should exist") + .waiting_on_vm + ); + assert!( + !next + .task_states + .get("task-3") + .expect("idle task should exist") + .waiting_on_vm + ); let waiting_task = next .task_states .get("task-4") .expect("blocked task should exist"); assert!(waiting_task.waiting_on_vm); - assert_eq!( - waiting_task.agent_state, - Some(AutomationAgentState::WaitingOnVm) - ); + assert_eq!(waiting_task.agent_state, None); } #[test] @@ -3036,9 +4238,95 @@ mod tests { .get("task-2") .expect("blocked task should exist"); assert!(task_state.waiting_on_vm); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + } + + #[test] + fn sync_task_runtime_state_prefers_claimed_issue_over_stale_unpreserved_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/2".to_string()); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); + assert_eq!(next.active_task_id.as_deref(), Some("task-2")); + assert_eq!( + next.persistent.automation_issue.as_deref(), + Some("https://github.com/example/repo/issues/2") + ); + } + + #[test] + fn sync_task_runtime_state_updates_bridge_state_from_normalized_active_task() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.persistent.automation_issue = + Some("https://github.com/example/repo/issues/1".to_string()); + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-1".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + sync_task_runtime_state(&workspace, &snapshot); + + let next = workspace.subscribe().borrow().clone(); assert_eq!( - task_state.agent_state, - Some(AutomationAgentState::WaitingOnVm) + next.automation_agent_state, + Some(AutomationAgentState::Review) + ); + assert_eq!( + next.automation_session_status, + Some(RootSessionStatus::Idle) ); } @@ -3176,6 +4464,51 @@ mod tests { ); } + #[test] + fn next_schedulable_task_issue_url_includes_waiting_on_vm_task_with_working_state() { + let mut snapshot = WorkspaceSnapshot::default(); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-1".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + session_id: Some("thread-1".to_string()), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + session_id: Some("thread-2".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + + assert_eq!( + next_schedulable_task_issue_url(&snapshot, None).as_deref(), + Some("https://github.com/example/repo/issues/2") + ); + } + #[test] fn task_can_yield_vm_only_for_non_working_states() { assert!(!task_can_yield_vm(Some(AutomationAgentState::Working))); @@ -3358,11 +4691,11 @@ mod tests { #[test] fn codex_task_thread_status_maps_idle_to_review() { assert_eq!( - codex_task_thread_status_to_agent_state(&CodexThreadStatus::Idle), + codex_task_thread_status_to_agent_state(&CodexThreadStatus::Idle, None), AutomationAgentState::Review ); assert_eq!( - codex_thread_status_to_root_session_status(&CodexThreadStatus::Idle), + agent_state_root_status(AutomationAgentState::Review), RootSessionStatus::Idle ); } @@ -3374,15 +4707,31 @@ mod tests { }; assert_eq!( - codex_task_thread_status_to_agent_state(&status), + codex_task_thread_status_to_agent_state(&status, None), AutomationAgentState::Question ); assert_eq!( - codex_thread_status_to_root_session_status(&status), + agent_state_root_status(AutomationAgentState::Question), RootSessionStatus::Question ); } + #[test] + fn codex_task_thread_status_preserves_review_for_ambiguous_active_state() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + + assert_eq!( + codex_task_thread_status_to_agent_state(&status, Some(AutomationAgentState::Review)), + AutomationAgentState::Review + ); + assert_eq!( + codex_task_thread_status_to_agent_state(&status, Some(AutomationAgentState::Question)), + AutomationAgentState::Question + ); + } + #[test] fn set_task_runtime_state_from_codex_marks_reviewing_task_yieldable() { let workspace = Workspace::new(WorkspaceSnapshot::default()); @@ -3451,6 +4800,139 @@ mod tests { assert!(active_task_can_yield_vm(&snapshot)); } + #[test] + fn set_task_runtime_state_from_codex_keeps_blocked_task_waiting_on_vm() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/example/repo/issues/1".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "https://github.com/example/repo/issues/2".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-1".to_string()); + snapshot.task_states.insert( + "task-2".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("task-session-2".to_string()), + waiting_on_vm: true, + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-2", + "task-session-2", + RootSessionStatus::Busy, + AutomationAgentState::Working, + None, + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-2") + .expect("blocked task should exist"); + assert_eq!(task_state.agent_state, Some(AutomationAgentState::Working)); + assert_eq!(task_state.session_status, Some(RootSessionStatus::Busy)); + assert!(task_state.waiting_on_vm); + } + + #[test] + fn set_task_runtime_state_from_codex_sets_pr_created_status_for_review_tasks() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.active_task_id = Some("task-33".to_string()); + snapshot.task_states.insert( + "task-33".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + status: Some("Resuming in background".to_string()), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-33", + "task-session-33", + RootSessionStatus::Idle, + AutomationAgentState::Review, + Some(CodexTaskMetadata { + prs: vec!["https://github.com/example/repo/pull/56".to_string()], + ..Default::default() + }), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-33") + .expect("task state should exist"); + assert_eq!(task_state.status.as_deref(), Some("PR created #56")); + } + + #[test] + fn set_task_runtime_state_from_codex_clears_resuming_status_once_task_is_working() { + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-33".to_string(), + "https://github.com/example/repo/issues/33".to_string(), + WorkspaceTaskSource::Scan, + )); + snapshot.task_states.insert( + "task-33".to_string(), + crate::WorkspaceTaskRuntimeSnapshot { + status: Some("Resuming in background".to_string()), + ..Default::default() + }, + ); + true + }); + + set_task_runtime_state_from_codex( + &workspace, + "task-33", + "task-session-33", + RootSessionStatus::Busy, + AutomationAgentState::Working, + Some(CodexTaskMetadata::default()), + ); + + let snapshot = workspace.subscribe().borrow().clone(); + let task_state = snapshot + .task_states + .get("task-33") + .expect("task state should exist"); + assert_eq!(task_state.status, None); + } + #[test] fn unloaded_codex_task_runtime_preserves_question_state() { let task_state = crate::WorkspaceTaskRuntimeSnapshot { @@ -3467,12 +4949,10 @@ mod tests { #[test] fn codex_task_metadata_from_turns_extracts_issue_and_pr_tags() { let turns = vec![crate::services::codex_app_server::CodexThreadTurn { - items: vec![ - serde_json::json!({ - "type": "agentMessage", - "text": "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1\nhttps://github.com/graemerocher/multicode-test/issues/1\nhttps://github.com/graemerocher/multicode-test/pull/8" - }), - ], + items: vec![serde_json::json!({ + "type": "agentMessage", + "text": "/tmp/multicode-codex-workspaces/e2e-test/work/multicode-test-1\nhttps://github.com/graemerocher/multicode-test/issues/1\nhttps://github.com/graemerocher/multicode-test/pull/8" + })], }]; let metadata = codex_task_metadata_from_turns( @@ -3495,23 +4975,82 @@ mod tests { } #[test] - fn non_active_codex_working_task_is_rendered_waiting_on_vm() { + fn non_active_codex_working_task_keeps_working_state_for_runtime_tracking() { let status = CodexThreadStatus::Active { active_flags: vec![], }; - let mut snapshot = WorkspaceSnapshot::default(); - snapshot.active_task_id = Some("task-12".to_string()); + let task_state = crate::WorkspaceTaskRuntimeSnapshot::default(); + assert_eq!( + codex_runtime_state_for_task(&status, &task_state), + (RootSessionStatus::Busy, AutomationAgentState::Working) + ); + } - let next_agent_state = codex_task_thread_status_to_agent_state(&status); - let next_agent_state = if snapshot.active_task_id.as_deref() != Some("task-7") - && next_agent_state == AutomationAgentState::Working - { - AutomationAgentState::WaitingOnVm + #[test] + fn active_codex_thread_does_not_preserve_review_state() { + let status = CodexThreadStatus::Active { + active_flags: vec![], + }; + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }; + + assert_eq!( + codex_runtime_state_for_task(&status, &task_state), + (RootSessionStatus::Busy, AutomationAgentState::Working) + ); + } + + #[test] + fn unloaded_codex_status_preserves_review_runtime_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + ..Default::default() + }; + + let runtime = if matches!(CodexThreadStatus::NotLoaded, CodexThreadStatus::NotLoaded) { + unloaded_codex_task_runtime(&task_state) } else { - next_agent_state + unreachable!() }; - assert_eq!(next_agent_state, AutomationAgentState::WaitingOnVm); + assert_eq!( + runtime, + (RootSessionStatus::Idle, AutomationAgentState::Review) + ); + } + + #[test] + fn normalized_task_agent_state_recovers_legacy_waiting_on_vm_review_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-8".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::WaitingOnVm), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); + } + + #[test] + fn normalized_task_agent_state_prefers_idle_session_status_over_stale_working_state() { + let task_state = crate::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-9".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + + assert_eq!( + normalized_task_agent_state(&task_state), + Some(AutomationAgentState::Review) + ); } #[test] @@ -3605,35 +5144,132 @@ mod tests { #[test] fn build_issue_prompt_requires_skills_and_publish_approval() { - let issue = SelectedIssue { - number: 980, - title: "candidate".to_string(), - url: "https://github.com/example/repo/issues/980".to_string(), - created_at: "2026-04-09T10:00:00Z".to_string(), - state: Some("OPEN".to_string()), - is_pull_request: Some(false), - labels: vec![], - }; + let issue = test_issue( + 980, + "candidate", + "https://github.com/example/repo/issues/980", + "2026-04-09T10:00:00Z", + vec![], + ); let prompt = build_issue_prompt( "example/repo", &issue, + None, "thread-task-980", std::path::Path::new("/tmp/work/example-repo-980"), + std::path::Path::new("/tmp/state/task-980.state"), ); assert!(prompt.contains("`independent-fix`")); assert!(prompt.contains("`machine-readable-pr`")); assert!(prompt.contains("`autonomous-state`")); - assert!(prompt.contains(AUTOMATION_STATE_ENV)); assert!(prompt.contains("Primary checkout for this task: /tmp/work/example-repo-980")); + assert!(prompt.contains("write autonomous state updates to `/tmp/state/task-980.state`")); assert!(prompt.contains("Use the existing checkout at `/tmp/work/example-repo-980`")); - assert!(prompt.contains( - "write autonomous state updates in the format `:thread-task-980`" - )); + assert!( + prompt + .contains("write autonomous state updates in the format `:thread-task-980`") + ); assert!(prompt.contains( "Run repository commands, builds, Gradle tasks, and focused tests as needed without asking for permission." )); assert!(prompt.contains("Do not commit, push, comment, or open/update a pull request until the user explicitly approves publishing.")); } + + #[test] + fn build_issue_prompt_for_dependency_upgrade_allows_direct_merge() { + let issue = test_issue( + 981, + "dependency upgrade", + "https://github.com/example/repo/issues/981", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + + let prompt = build_issue_prompt( + "example/repo", + &issue, + Some("https://github.com/example/repo/pull/88"), + "thread-task-981", + std::path::Path::new("/tmp/work/example-repo-981"), + std::path::Path::new("/tmp/state/task-981.state"), + ); + + assert!( + prompt.contains( + "backed by Renovate pull request https://github.com/example/repo/pull/88" + ) + ); + assert!(prompt.contains("merge it without waiting for human review")); + assert!(prompt.contains("close GitHub issue https://github.com/example/repo/issues/981")); + assert!(!prompt.contains("explicitly approves publishing")); + } + + #[test] + fn selected_pull_request_non_major_detection_prefers_safe_signals() { + let patch = test_pull_request( + 88, + "Update dependency io.micronaut:micronaut-http-client from 4.4.0 to 4.4.1", + "https://github.com/example/repo/pull/88", + vec![ + SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }, + SelectedIssueLabel { + name: "patch".to_string(), + }, + ], + None, + ); + assert!(patch.is_open_dependency_upgrade_candidate()); + assert!(patch.is_non_major_dependency_upgrade()); + + let major = test_pull_request( + 89, + "Update dependency io.micronaut:micronaut-http-client from 4.4.1 to 5.0.0", + "https://github.com/example/repo/pull/89", + vec![ + SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }, + SelectedIssueLabel { + name: "major".to_string(), + }, + ], + None, + ); + assert!(!major.is_non_major_dependency_upgrade()); + + let inferred_minor = test_pull_request( + 90, + "Update dependency io.micronaut:micronaut-http-client from 4.4.1 to 4.5.0", + "https://github.com/example/repo/pull/90", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ); + assert!(inferred_minor.is_non_major_dependency_upgrade()); + } + + #[test] + fn extract_dependency_upgrade_pr_marker_reads_hidden_comment() { + let body = dependency_upgrade_issue_body(&test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert_eq!( + extract_dependency_upgrade_pr_marker(&body), + Some("https://github.com/example/repo/pull/91") + ); + } } diff --git a/lib/src/services/codex_app_server.rs b/lib/src/services/codex_app_server.rs index a591f98..4dccb4a 100644 --- a/lib/src/services/codex_app_server.rs +++ b/lib/src/services/codex_app_server.rs @@ -17,9 +17,8 @@ use super::config::{CodexAgentConfig, CodexApprovalPolicy, CodexNetworkAccess, C const INITIALIZE_REQUEST_ID: i64 = 1; const INITIAL_REQUEST_ID: i64 = 2; -type CodexSocket = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, ->; +type CodexSocket = + tokio_tungstenite::WebSocketStream>; static NEXT_REQUEST_ID: AtomicI64 = AtomicI64::new(INITIAL_REQUEST_ID); static SHARED_CONNECTIONS: OnceLock>>> = @@ -143,15 +142,15 @@ impl CodexAppServerClient { .await } - async fn connect_initialized( - &self, - ) -> Result { + async fn connect_initialized(&self) -> Result { connect_initialized_socket(&self.uri).await } fn shared_connection(&self) -> Arc { let registry = SHARED_CONNECTIONS.get_or_init(Default::default); - let mut registry = registry.lock().expect("codex shared connection registry poisoned"); + let mut registry = registry + .lock() + .expect("codex shared connection registry poisoned"); registry .entry(self.uri.clone()) .or_insert_with(|| { @@ -187,7 +186,10 @@ impl SharedCodexConnection { continue; }; - if let Err(err) = active_socket.send(Message::Text(request.clone().into())).await { + if let Err(err) = active_socket + .send(Message::Text(request.clone().into())) + .await + { *socket = None; if attempt == 0 { continue; diff --git a/lib/src/services/codex_root_session_service.rs b/lib/src/services/codex_root_session_service.rs index dfbc837..c6e1b7a 100644 --- a/lib/src/services/codex_root_session_service.rs +++ b/lib/src/services/codex_root_session_service.rs @@ -275,8 +275,10 @@ async fn refresh_root_session( ); let current_root_session_id = workspace.subscribe().borrow().root_session_id.clone(); - let thread = match select_thread_for_tracking(current_root_session_id.as_deref(), &response.data) - { + let thread = match select_thread_for_tracking( + current_root_session_id.as_deref(), + &response.data, + ) { Some(thread) => thread, None => { if let Some(current_root_session_id) = current_root_session_id.as_deref() { @@ -295,7 +297,11 @@ async fn refresh_root_session( status = ?status, "clearing stale codex root thread after thread/read" ); - clear_tracked_root_session(workspace, key, Some(current_root_session_id)); + clear_tracked_root_session( + workspace, + key, + Some(current_root_session_id), + ); } else { tracing::debug!( uri = %key.uri, @@ -499,8 +505,7 @@ fn clear_tracked_root_session( { return false; } - if expected_thread_id.is_some() - && snapshot.root_session_id.as_deref() != expected_thread_id + if expected_thread_id.is_some() && snapshot.root_session_id.as_deref() != expected_thread_id { return false; } diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 3ae08db..beeaf89 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -4,7 +4,7 @@ use std::{ path::{Path, PathBuf}, process::{ExitStatus, Stdio}, sync::Arc, - time::SystemTime, + time::{Duration, SystemTime}, }; use tokio::process::Command; @@ -29,13 +29,14 @@ use super::{ codex_app_server::CodexAppServerClient, codex_root_session_service::codex_root_session_service, config::{ - AddedSkillMount, AgentProvider, Config, ExpandedIsolationConfig, expand_shell_path, - inherited_env_value, read_config, resolve_agent_command, validate_handler_config, - validate_remote_config, validate_tool_config_entries, validate_workspace_key, + AddedSkillMount, AgentProvider, CodexAgentConfig, Config, ExpandedIsolationConfig, + expand_shell_path, inherited_env_value, read_config, resolve_agent_command, + validate_handler_config, validate_remote_config, validate_tool_config_entries, + validate_workspace_key, }, multicode_metadata_service, opencode_client_service, persistent_storage, resource_usage_service, root_session_service, - runtime::WorkspaceRuntime, + runtime::{WorkspaceRuntime, automation_task_state_file_source}, runtime_reconciliation_service::runtime_reconciliation_service, transient_storage, usage_aggregation_service, workspace_archive::ArchiveWorkspaceEntry, @@ -380,13 +381,11 @@ impl CombinedService { repository: &str, issue_url: &str, ) -> PathBuf { - self.workspace_path_for_key(key) - .join("work") - .join(format!( - "{}-{}", - repository_repo_name(repository), - issue_url_number(issue_url).unwrap_or("task") - )) + self.workspace_path_for_key(key).join("work").join(format!( + "{}-{}", + repository_repo_name(repository), + issue_url_number(issue_url).unwrap_or("task") + )) } pub async fn ensure_workspace_task_checkout( @@ -401,7 +400,8 @@ impl CombinedService { let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); tokio::fs::create_dir_all(self.workspace_path_for_key(&key)).await?; - self.ensure_repository_checkout(&repository, &repo_root).await?; + self.ensure_repository_checkout(&repository, &repo_root) + .await?; self.ensure_task_worktree(&repo_root, &task_root).await?; unset_repo_local_git_config(&repo_root, "user.name").await?; unset_repo_local_git_config(&repo_root, "user.email").await?; @@ -422,7 +422,8 @@ impl CombinedService { let repo_root = self.workspace_repo_root_path(&key, &repository); let task_root = self.workspace_task_checkout_path(&key, &repository, issue_url); - if !path_has_git_entry(&task_root).await? && tokio::fs::metadata(&task_root).await.is_err() { + if !path_has_git_entry(&task_root).await? && tokio::fs::metadata(&task_root).await.is_err() + { return Ok(()); } @@ -441,9 +442,7 @@ impl CombinedService { } let output = command.output().await?; - if !output.status.success() - && path_has_git_entry(&task_root).await? - { + if !output.status.success() && path_has_git_entry(&task_root).await? { return Err(CombinedServiceError::RepositoryPreparation(format!( "failed to remove task worktree '{}': {}", task_root.display(), @@ -561,6 +560,12 @@ impl CombinedService { self.remove_workspace_task_checkout(&key, assigned_repository, &task.issue_url) .await?; } + remove_path_if_exists(&automation_task_state_file_source( + &self.workspace_directory_path, + &key, + task_id, + )) + .await?; workspace.update(|next| { let mut changed = false; @@ -582,22 +587,23 @@ impl CombinedService { .active_task_id .as_deref() .is_some_and(|active_task_id| { - !next.persistent.tasks.iter().any(|entry| entry.id == active_task_id) + !next + .persistent + .tasks + .iter() + .any(|entry| entry.id == active_task_id) }) { next.active_task_id = next.persistent.tasks.first().map(|entry| entry.id.clone()); changed = true; } - let next_active_issue = next - .active_task_id - .as_deref() - .and_then(|active_task_id| { - next.persistent - .tasks - .iter() - .find(|entry| entry.id == active_task_id) - .map(|entry| entry.issue_url.clone()) - }); + let next_active_issue = next.active_task_id.as_deref().and_then(|active_task_id| { + next.persistent + .tasks + .iter() + .find(|entry| entry.id == active_task_id) + .map(|entry| entry.issue_url.clone()) + }); if next.persistent.automation_issue != next_active_issue { next.persistent.automation_issue = next_active_issue; changed = true; @@ -819,15 +825,26 @@ impl CombinedService { .clone() .ok_or_else(|| "workspace has no root session id".to_string())?; + self.prompt_session(snapshot, &root_session_id, prompt) + .await + } + + pub async fn prompt_session( + &self, + snapshot: &crate::WorkspaceSnapshot, + session_id: &str, + prompt: &str, + ) -> Result<(), String> { + let session_id = session_id.to_string(); match self.agent_provider { AgentProvider::Opencode => { let opencode_client = snapshot .opencode_client .as_ref() .ok_or_else(|| "workspace has no healthy opencode client".to_string())?; - let session_id = root_session_id + let session_id = session_id .parse::() - .map_err(|err| format!("invalid root session id '{root_session_id}': {err}"))?; + .map_err(|err| format!("invalid session id '{session_id}': {err}"))?; let prompt_body = opencode::client::types::SessionPromptAsyncBody { agent: None, format: None, @@ -864,29 +881,33 @@ impl CombinedService { .map(|transient| transient.uri.clone()) .ok_or_else(|| "workspace has no active runtime uri".to_string())?; tracing::info!( - root_session_id, + session_id, uri = %uri, prompt_len = prompt.len(), - "dispatching codex root-session prompt" + "dispatching codex session prompt" ); - let response = CodexAppServerClient::new(uri.clone()) - .turn_start(&root_session_id, prompt, &self.config.agent.codex) - .await; + let response = Self::prompt_codex_session_with_retry( + &uri, + &session_id, + prompt, + &self.config.agent.codex, + ) + .await; match response { Ok(_) => { tracing::info!( - root_session_id, + session_id, uri = %uri, - "codex root-session prompt dispatched" + "codex session prompt dispatched" ); Ok(()) } Err(err) => { tracing::warn!( - root_session_id, + session_id, uri = %uri, error = %err, - "codex root-session prompt dispatch failed" + "codex session prompt dispatch failed" ); Err(err) } @@ -895,6 +916,284 @@ impl CombinedService { } } + pub async fn prompt_task_session( + &self, + workspace_key: &str, + snapshot: &crate::WorkspaceSnapshot, + task_id: &str, + prompt: &str, + ) -> Result<(), String> { + if self.agent_provider != AgentProvider::Codex { + let session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.as_deref()) + .ok_or_else(|| format!("task '{task_id}' does not have a resumable session"))?; + return self.prompt_session(snapshot, session_id, prompt).await; + } + + let workspace = self + .manager + .get_workspace(workspace_key) + .map_err(|err| format!("failed to load workspace '{workspace_key}': {err:?}"))?; + let live_snapshot = workspace.subscribe().borrow().clone(); + let snapshot = &live_snapshot; + let task = snapshot + .task_persistent_snapshot(task_id) + .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; + let assigned_repository = snapshot + .persistent + .assigned_repository + .as_deref() + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an assigned repository") + })?; + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an active runtime") + })?; + self.ensure_workspace_task_checkout(workspace_key, assigned_repository, &task.issue_url) + .await + .map_err(|err| err.summary())?; + let existing_session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.clone()); + + if let Some(session_id) = existing_session_id.as_deref() { + match Self::prompt_codex_session_with_retry( + &uri, + session_id, + prompt, + &self.config.agent.codex, + ) + .await + { + Ok(()) => return Ok(()), + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + tracing::warn!( + workspace_key, + task_id, + session_id, + error = %error, + "replacing stale codex task session after interrupted attach" + ); + } + Err(error) => return Err(error), + } + } + + self.start_fresh_codex_task_session( + workspace_key, + task_id, + prompt, + existing_session_id.as_deref(), + ) + .await + } + + pub async fn restart_task_session( + &self, + workspace_key: &str, + snapshot: &crate::WorkspaceSnapshot, + task_id: &str, + prompt: &str, + ) -> Result<(), String> { + if self.agent_provider != AgentProvider::Codex { + return self + .prompt_task_session(workspace_key, snapshot, task_id, prompt) + .await; + } + + tracing::info!( + workspace_key, + task_id, + "starting fresh codex task session" + ); + let previous_session_id = snapshot + .task_states + .get(task_id) + .and_then(|task_state| task_state.session_id.as_deref()); + self.start_fresh_codex_task_session(workspace_key, task_id, prompt, previous_session_id) + .await + } + + async fn start_fresh_codex_task_session( + &self, + workspace_key: &str, + task_id: &str, + prompt: &str, + previous_session_id: Option<&str>, + ) -> Result<(), String> { + let workspace = self + .manager + .get_workspace(workspace_key) + .map_err(|err| format!("failed to load workspace '{workspace_key}': {err:?}"))?; + let live_snapshot = workspace.subscribe().borrow().clone(); + let snapshot = &live_snapshot; + let task = snapshot + .task_persistent_snapshot(task_id) + .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; + let assigned_repository = snapshot + .persistent + .assigned_repository + .as_deref() + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an assigned repository") + })?; + let uri = snapshot + .transient + .as_ref() + .map(|transient| transient.uri.clone()) + .ok_or_else(|| { + format!("workspace '{workspace_key}' does not have an active runtime") + })?; + let cwd = self + .ensure_workspace_task_checkout(workspace_key, assigned_repository, &task.issue_url) + .await + .map_err(|err| err.summary())?; + let client = CodexAppServerClient::new(uri.clone()); + let session_id = client + .thread_start(cwd.to_string_lossy().as_ref(), &self.config.agent.codex) + .await? + .thread + .id; + let prompt = + Self::rewrite_codex_task_prompt_session_id(prompt, previous_session_id, &session_id); + Self::wait_for_codex_thread_ready(&client, &session_id).await?; + workspace.update(|next| { + let task_state = next.task_states.entry(task_id.to_string()).or_default(); + let mut changed = false; + if task_state.session_id.as_deref() != Some(session_id.as_str()) { + task_state.session_id = Some(session_id.clone()); + changed = true; + } + if next.active_task_id.as_deref() == Some(task_id) { + if next.automation_session_id.as_deref() != Some(session_id.as_str()) { + next.automation_session_id = Some(session_id.clone()); + changed = true; + } + if next.automation_agent_state != Some(crate::AutomationAgentState::Working) { + next.automation_agent_state = Some(crate::AutomationAgentState::Working); + changed = true; + } + if next.automation_session_status != Some(super::root_session_service::RootSessionStatus::Busy) { + next.automation_session_status = + Some(super::root_session_service::RootSessionStatus::Busy); + changed = true; + } + } + changed + }); + Self::prompt_codex_session_with_retry(&uri, &session_id, &prompt, &self.config.agent.codex) + .await + } + + fn rewrite_codex_task_prompt_session_id( + prompt: &str, + previous_session_id: Option<&str>, + session_id: &str, + ) -> String { + match previous_session_id { + Some(previous_session_id) + if !previous_session_id.is_empty() && previous_session_id != session_id => + { + prompt.replace(previous_session_id, session_id) + } + _ => prompt.to_string(), + } + } + + async fn prompt_codex_session_with_retry( + uri: &str, + session_id: &str, + prompt: &str, + config: &CodexAgentConfig, + ) -> Result<(), String> { + let client = CodexAppServerClient::new(uri.to_string()); + let mut last_error: Option = None; + + for attempt in 0..15 { + match client.turn_start(session_id, prompt, config).await { + Ok(_) => return Ok(()), + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + tracing::info!( + session_id, + uri = %uri, + attempt, + error = %error, + "codex session prompt hit transient thread materialization error; waiting for thread" + ); + last_error = Some(error); + match Self::wait_for_codex_thread_ready(&client, session_id).await { + Ok(()) => {} + Err(wait_error) => { + tracing::info!( + session_id, + uri = %uri, + attempt, + error = %wait_error, + "codex thread still not ready after wait; retrying turn_start" + ); + last_error = Some(wait_error); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => return Err(error), + } + } + + Err(last_error.unwrap_or_else(|| { + format!("failed to dispatch codex prompt for session '{session_id}'") + })) + } + + async fn wait_for_codex_thread_ready( + client: &CodexAppServerClient, + session_id: &str, + ) -> Result<(), String> { + let mut last_error: Option = None; + + for attempt in 0..25 { + match client.thread_read(session_id).await { + Ok(response) => { + let ready = response.thread.status.as_ref().is_some_and(|status| { + !matches!( + status, + super::codex_app_server::CodexThreadStatus::NotLoaded + ) + }); + if ready { + return Ok(()); + } + last_error = Some(format!( + "thread '{session_id}' read succeeded but is not ready yet" + )); + } + Err(error) if Self::is_codex_thread_materialization_error(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + + if attempt < 24 { + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + Err(last_error.unwrap_or_else(|| { + format!("timed out waiting for codex thread '{session_id}' to materialize") + })) + } + + fn is_codex_thread_materialization_error(error: &str) -> bool { + error.contains("thread not found") || error.contains("thread not loaded") + } + #[cfg_attr(not(test), allow(dead_code))] async fn build_systemd_bwrap_command( &self, @@ -2128,7 +2427,11 @@ token = { command = "gh auth token" } .args(args) .status() .expect("git command should run"); - assert!(status.success(), "git command should succeed: git -C {repo_root:?} {}", args.join(" ")); + assert!( + status.success(), + "git command should succeed: git -C {repo_root:?} {}", + args.join(" ") + ); } fn init_test_git_repository(repo_root: &Path) { @@ -2627,10 +2930,8 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("fake opencode should be written"); make_executable(&fake_opencode); - let _path_guard = EnvVarGuard::set_value( - "PATH", - "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin", - ); + let _path_guard = + EnvVarGuard::set_value("PATH", "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"); let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); @@ -3313,7 +3614,9 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] "git worktree should expose a .git file" ); assert!( - tokio::fs::metadata(task_root.join("README.md")).await.is_ok(), + tokio::fs::metadata(task_root.join("README.md")) + .await + .is_ok(), "worktree should contain repository files" ); @@ -3327,7 +3630,9 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] "task worktree should be removed" ); assert!( - tokio::fs::symlink_metadata(repo_root.join(".git")).await.is_ok(), + tokio::fs::symlink_metadata(repo_root.join(".git")) + .await + .is_ok(), "base checkout should remain" ); }); @@ -5100,6 +5405,29 @@ isolated = ["~/.config/opencode"] ); } + #[test] + fn rewrite_codex_task_prompt_session_id_replaces_stale_task_session_id() { + let prompt = "For this task session/thread, write autonomous state updates in the format `:old-session` so multicode can attribute the state to this specific session.\nreview:old-session"; + let rewritten = CombinedService::rewrite_codex_task_prompt_session_id( + prompt, + Some("old-session"), + "new-session", + ); + + assert!(rewritten.contains(":new-session")); + assert!(rewritten.contains("review:new-session")); + assert!(!rewritten.contains("old-session")); + } + + #[test] + fn rewrite_codex_task_prompt_session_id_leaves_prompt_unchanged_without_prior_session() { + let prompt = "create a PR"; + let rewritten = + CombinedService::rewrite_codex_task_prompt_session_id(prompt, None, "new-session"); + + assert_eq!(rewritten, prompt); + } + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { args.windows(sequence.len()).any(|window| { window diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index 325c98d..547081e 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -144,6 +144,21 @@ pub(crate) fn automation_state_file_source(workspace_directory_path: &Path, key: automation_state_dir_source(workspace_directory_path, key).join(AUTOMATION_STATE_FILE_NAME) } +pub(crate) fn automation_task_state_dir_source( + workspace_directory_path: &Path, + key: &str, +) -> PathBuf { + automation_state_dir_source(workspace_directory_path, key).join("tasks") +} + +pub(crate) fn automation_task_state_file_source( + workspace_directory_path: &Path, + key: &str, + task_id: &str, +) -> PathBuf { + automation_task_state_dir_source(workspace_directory_path, key).join(format!("{task_id}.state")) +} + async fn prepare_synthetic_codex_home( source_root: &Path, added_skills: &[super::config::AddedSkillMount], @@ -199,16 +214,12 @@ async fn write_synthetic_codex_config( target: &Path, config: &CodexAgentConfig, ) -> Result<(), std::io::Error> { - let mut contents = match tokio::fs::read_to_string(target).await { + let contents = match tokio::fs::read_to_string(target).await { Ok(existing) => existing, Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), Err(err) => return Err(err), }; - - if !contents.is_empty() && !contents.ends_with('\n') { - contents.push('\n'); - } - contents.push_str(&render_multicode_codex_config_overrides(config)); + let contents = rewrite_synthetic_codex_config(&contents, config); tokio::fs::write(target, contents).await } @@ -245,6 +256,84 @@ fn render_multicode_codex_config_overrides(config: &CodexAgentConfig) -> String lines.join("\n") + "\n" } +fn rewrite_synthetic_codex_config(existing: &str, config: &CodexAgentConfig) -> String { + let mut root_lines = Vec::new(); + let mut section_lines = Vec::new(); + let mut in_root = true; + let mut skipping_managed_block = false; + + for line in existing.lines() { + let trimmed = line.trim(); + if trimmed == "# Managed by multicode" { + skipping_managed_block = true; + continue; + } + if skipping_managed_block { + if trimmed.is_empty() || is_root_codex_override_line(trimmed, config) { + continue; + } + skipping_managed_block = false; + } + + if trimmed.starts_with('[') { + in_root = false; + } + + if in_root && is_root_codex_override_line(trimmed, config) { + continue; + } + + if in_root { + root_lines.push(line); + } else { + section_lines.push(line); + } + } + + while root_lines.last().is_some_and(|line| line.trim().is_empty()) { + root_lines.pop(); + } + + let mut rewritten = String::new(); + if !root_lines.is_empty() { + rewritten.push_str(&root_lines.join("\n")); + rewritten.push('\n'); + if !rewritten.ends_with("\n\n") { + rewritten.push('\n'); + } + } + rewritten.push_str(&render_multicode_codex_config_overrides(config)); + + let section_body = section_lines.join("\n"); + if !section_body.trim().is_empty() { + if !rewritten.ends_with("\n\n") { + rewritten.push('\n'); + } + rewritten.push_str(§ion_body); + if !rewritten.ends_with('\n') { + rewritten.push('\n'); + } + } + + rewritten +} + +fn is_root_codex_override_line(line: &str, config: &CodexAgentConfig) -> bool { + if line.starts_with("approval_policy =") || line.starts_with("sandbox_mode =") { + return true; + } + if config.profile.is_some() && line.starts_with("profile =") { + return true; + } + if config.model.is_some() && line.starts_with("model =") { + return true; + } + if config.model_provider.is_some() && line.starts_with("model_provider =") { + return true; + } + false +} + fn codex_approval_policy_config_value(policy: CodexApprovalPolicy) -> &'static str { match policy { CodexApprovalPolicy::Untrusted => "untrusted", @@ -2313,6 +2402,105 @@ mod tests { ); } + #[test] + fn synthetic_codex_config_rewrites_root_overrides_before_tables() { + let existing = concat!( + "approval_policy = \"on-request\"\n", + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "sandbox_mode = \"workspace-write\"\n", + "web_search_request = true\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + "\n", + "[notice.model_migrations]\n", + "\"gpt-5.3-codex\" = \"gpt-5.4\"\n", + "# Managed by multicode\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + ); + + let rewritten = rewrite_synthetic_codex_config( + existing, + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: Some("default".to_string()), + model: Some("gpt-5-codex".to_string()), + model_provider: Some("openai".to_string()), + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!( + rewritten, + concat!( + "web_search_request = true\n", + "\n", + "# Managed by multicode\n", + "profile = \"default\"\n", + "model = \"gpt-5-codex\"\n", + "model_provider = \"openai\"\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + "\n", + "[notice.model_migrations]\n", + "\"gpt-5.3-codex\" = \"gpt-5.4\"\n", + ) + ); + } + + #[test] + fn synthetic_codex_config_preserves_host_provider_when_not_overridden() { + let existing = concat!( + "approval_policy = \"on-request\"\n", + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "sandbox_mode = \"workspace-write\"\n", + "web_search_request = true\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + ); + + let rewritten = rewrite_synthetic_codex_config( + existing, + &CodexAgentConfig { + commands: vec!["codex".to_string()], + profile: None, + model: None, + model_provider: None, + approval_policy: CodexApprovalPolicy::Never, + sandbox_mode: CodexSandboxMode::ExternalSandbox, + network_access: CodexNetworkAccess::Enabled, + }, + ); + + assert_eq!( + rewritten, + concat!( + "model_provider = \"oca\"\n", + "model = \"gpt-5.4\"\n", + "profile = \"gpt-5-3-codex\"\n", + "web_search_request = true\n", + "\n", + "# Managed by multicode\n", + "approval_policy = \"never\"\n", + "sandbox_mode = \"danger-full-access\"\n", + "\n", + "[profiles.gpt-5-4]\n", + "model = \"gpt-5.4\"\n", + ) + ); + } + #[test] fn apple_container_implicitly_mounts_host_gitconfig() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 13f50f7..9f4f9af 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -15,3 +15,4 @@ tracing = "0" size = "0" toml = "1" rustix = { version = "1", features = ["fs"] } +serde_json = "1" diff --git a/tui/src/app.rs b/tui/src/app.rs index 3173c6c..27ee283 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -1,12 +1,181 @@ use crate::ops::*; use crate::system::*; use crate::*; -use multicode_lib::services::GithubTokenConfig; +use multicode_lib::services::{ + GithubTokenConfig, + codex_app_server::{CodexAppServerClient, CodexThreadStatus}, +}; use std::os::unix::fs::FileTypeExt; const NERD_FONT_GITHUB_GLYPH: &str = "\u{f408}"; const CODEX_AUTO_RESUME_PROMPT: &str = "Continue autonomously from where you left off. Do not wait for approval for repository commands, builds, Gradle tasks, or focused tests. Only stop to ask before committing, pushing, commenting on GitHub, or opening or updating a pull request."; +pub(crate) fn count_codex_session_turn_metrics(contents: &str) -> CodexSessionTurnMetrics { + CodexSessionTurnMetrics { + started: contents.matches("\"type\":\"task_started\"").count(), + completed: contents.matches("\"type\":\"task_complete\"").count(), + aborted: contents.matches("\"type\":\"turn_aborted\"").count(), + } +} + +fn codex_session_log_root( + workspace_directory_path: &std::path::Path, + workspace_key: &str, +) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("codex") + .join(workspace_key) + .join("home") + .join("sessions") +} + +fn find_codex_session_log_path( + workspace_directory_path: &std::path::Path, + workspace_key: &str, + session_id: &str, +) -> Option { + let root = codex_session_log_root(workspace_directory_path, workspace_key); + let mut stack = vec![root]; + let suffix = format!("{session_id}.jsonl"); + while let Some(path) = stack.pop() { + let entries = std::fs::read_dir(&path).ok()?; + for entry in entries.flatten() { + let entry_path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + stack.push(entry_path); + continue; + } + if file_type.is_file() + && entry_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(entry_path); + } + } + } + None +} + +fn read_codex_session_turn_metrics( + workspace_directory_path: &std::path::Path, + workspace_key: &str, + session_id: &str, +) -> Option { + let path = find_codex_session_log_path(workspace_directory_path, workspace_key, session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + Some(count_codex_session_turn_metrics(&contents)) +} + +pub(crate) fn last_user_message_from_codex_session_log_contents(contents: &str) -> Option { + contents.lines().filter_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("user_message") { + return None; + } + payload + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToOwned::to_owned) + }) + .last() +} + +fn first_user_message_from_codex_session_log_contents(contents: &str) -> Option { + contents.lines().find_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("user_message") { + return None; + } + payload + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn interrupted_codex_resume_prompt_from_session_log_contents(contents: &str) -> Option { + let first = first_user_message_from_codex_session_log_contents(contents); + let last = last_user_message_from_codex_session_log_contents(contents); + match (first, last) { + (Some(first), Some(last)) if first != last => Some(format!( + "{first}\n\nAdditional user instruction from the interrupted interactive attach:\n{last}" + )), + (_, Some(last)) => Some(last), + (Some(first), None) => Some(first), + (None, None) => None, + } +} + +async fn read_last_codex_session_user_message( + workspace_directory_path: std::path::PathBuf, + workspace_key: String, + session_id: String, +) -> Option { + tokio::task::spawn_blocking(move || { + let path = + find_codex_session_log_path(&workspace_directory_path, &workspace_key, &session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + last_user_message_from_codex_session_log_contents(&contents) + }) + .await + .ok() + .flatten() +} + +async fn read_interrupted_codex_resume_prompt( + workspace_directory_path: std::path::PathBuf, + workspace_key: String, + session_id: String, +) -> Option { + tokio::task::spawn_blocking(move || { + let path = + find_codex_session_log_path(&workspace_directory_path, &workspace_key, &session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + interrupted_codex_resume_prompt_from_session_log_contents(&contents) + }) + .await + .ok() + .flatten() +} + +pub(crate) fn should_resume_codex_task_after_incomplete_attached_turn( + initial_metrics: Option, + current_metrics: Option, + thread_status: Option<&CodexThreadStatus>, +) -> bool { + let Some(initial_metrics) = initial_metrics else { + return false; + }; + let Some(current_metrics) = current_metrics else { + return false; + }; + let started_new_turn = current_metrics.started > initial_metrics.started; + let aborted_new_turn = current_metrics.aborted > initial_metrics.aborted; + if !started_new_turn && !aborted_new_turn { + return false; + } + if current_metrics.completed > initial_metrics.completed && !aborted_new_turn { + return false; + } + match thread_status { + Some(CodexThreadStatus::Active { .. }) => false, + Some(CodexThreadStatus::SystemError) => false, + Some(CodexThreadStatus::Idle | CodexThreadStatus::NotLoaded) | None => true, + } +} + pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { let url = Url::parse(target).ok()?; if url.host_str()? != "github.com" { @@ -70,6 +239,34 @@ pub(crate) fn should_auto_resume_autonomous_codex_after_attach( } } +pub(crate) fn should_auto_resume_task_codex_after_attach( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, + attached_session_id: Option<&str>, + initial_agent_state: Option, +) -> bool { + let Some(task_state) = task_state else { + return matches!(initial_agent_state, Some(AutomationAgentState::Working)); + }; + + if let Some(attached_session_id) = attached_session_id + && task_state.session_id.as_deref() != Some(attached_session_id) + { + return false; + } + + match task_effective_agent_state(Some(task_state)) { + Some(AutomationAgentState::Working) => true, + Some( + AutomationAgentState::WaitingOnVm + | AutomationAgentState::Question + | AutomationAgentState::Review + | AutomationAgentState::Idle + | AutomationAgentState::Stale, + ) => false, + None => matches!(initial_agent_state, Some(AutomationAgentState::Working)), + } +} + pub(crate) fn restored_selected_row( entries: &[TableEntry], previous_selected_entry: Option<&TableEntry>, @@ -84,7 +281,10 @@ pub(crate) fn restored_selected_row( return 0; } - if let Some(position) = entries.iter().position(|entry| entry == previous_selected_entry) { + if let Some(position) = entries + .iter() + .position(|entry| entry == previous_selected_entry) + { return position; } @@ -194,6 +394,7 @@ impl TuiState { custom_link_action: None, custom_link_original_value: None, pending_delete_target: None, + attached_session: None, starting_workspace_key: None, started_wait_since: None, previous_machine_cpu_totals: None, @@ -446,19 +647,19 @@ impl TuiState { }; candidates - .into_iter() - .filter(|candidate| candidate.kind == link.kind) - .filter_map(|candidate| { - self.workspace_link_validation_results - .get(&candidate) - .and_then(|result| match result { - WorkspaceLinkValidationResult::Valid(argument) => { - Some((candidate, argument.clone())) - } - WorkspaceLinkValidationResult::Invalid(_) => None, - }) - }) - .collect() + .into_iter() + .filter(|candidate| candidate.kind == link.kind) + .filter_map(|candidate| { + self.workspace_link_validation_results + .get(&candidate) + .and_then(|result| match result { + WorkspaceLinkValidationResult::Valid(argument) => { + Some((candidate, argument.clone())) + } + WorkspaceLinkValidationResult::Invalid(_) => None, + }) + }) + .collect() } fn normalize_selected_link_target_index(&mut self) { @@ -599,10 +800,7 @@ impl TuiState { active_links.extend(workspace_links(snapshot)); active_links.extend(workspace_issue_pr_links(snapshot)); for task in &snapshot.persistent.tasks { - active_links.extend(task_links( - task, - task_runtime_snapshot(snapshot, &task.id), - )); + active_links.extend(task_links(task, task_runtime_snapshot(snapshot, &task.id))); } } @@ -662,13 +860,9 @@ impl TuiState { fn refresh_github_link_statuses(&mut self) { let mut active_issue_or_pr_links = HashSet::new(); for snapshot in self.snapshots.values() { - active_issue_or_pr_links.extend( - workspace_issue_pr_links(snapshot) - .into_iter() - .filter(|link| { - matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr) - }), - ); + active_issue_or_pr_links.extend(workspace_issue_pr_links(snapshot).into_iter().filter( + |link| matches!(link.kind, WorkspaceLinkKind::Issue | WorkspaceLinkKind::Pr), + )); for task in &snapshot.persistent.tasks { active_issue_or_pr_links.extend( task_links(task, task_runtime_snapshot(snapshot, &task.id)) @@ -874,6 +1068,54 @@ impl TuiState { workspace_attach_target(snapshot) } + fn record_attached_session(&mut self, key: &str, target: &AttachTarget) { + let task_id = self.selected_task_id().map(str::to_string); + let initial_agent_state = self.snapshots.get(key).and_then(|snapshot| { + task_id + .as_deref() + .and_then(|task_id| task_runtime_snapshot(snapshot, task_id)) + .and_then(|task_state| task_effective_agent_state(Some(task_state))) + .or_else(|| { + if task_id.is_none() { + snapshot.automation_agent_state + } else { + None + } + }) + }); + let session_id = match target { + AttachTarget::Opencode { session_id, .. } => session_id.clone(), + AttachTarget::Codex { thread_id, .. } => thread_id.clone(), + }; + let initial_turn_metrics = + if self.service.agent_provider() == multicode_lib::services::AgentProvider::Codex { + session_id.as_deref().and_then(|session_id| { + read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + key, + session_id, + ) + }) + } else { + None + }; + tracing::info!( + workspace_key = %key, + task_id = task_id.as_deref().unwrap_or(""), + session_id = session_id.as_deref().unwrap_or(""), + initial_agent_state = ?initial_agent_state, + initial_turn_metrics = ?initial_turn_metrics, + "recorded attached session" + ); + self.attached_session = Some(AttachedSession { + workspace_key: key.to_string(), + task_id, + session_id, + initial_agent_state, + initial_turn_metrics, + }); + } + fn attach_env_for_workspace(&self, key: &str) -> Vec<(String, String)> { if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { return Vec::new(); @@ -927,6 +1169,7 @@ impl TuiState { match self.snapshot_attach_target(&key) { Ok(target) => { + self.record_attached_session(&key, &target); let custom_description = self .snapshots .get(&key) @@ -946,7 +1189,14 @@ impl TuiState { self.handle_attach_exit(&key).await; } Err(err) => { - self.status = format!("Failed to attach to workspace '{key}': {err}"); + tracing::warn!( + workspace_key = %key, + error = %err, + "attach session exited with error" + ); + if !self.handle_attach_exit_after_error(&key, &err).await { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } } } } @@ -957,50 +1207,279 @@ impl TuiState { } async fn handle_attach_exit(&mut self, key: &str) { + tracing::info!(workspace_key = %key, "handling attach exit"); if self.maybe_resume_autonomous_codex_after_attach(key).await { return; } self.status = format!("Detached from workspace '{key}' agent session"); } + async fn handle_attach_exit_after_error(&mut self, key: &str, err: &io::Error) -> bool { + tracing::info!( + workspace_key = %key, + error = %err, + attached_session = ?self.attached_session, + "handling attach exit after error" + ); + if self + .attached_session + .as_ref() + .is_some_and(|attached| attached.workspace_key == key) + && self.maybe_resume_autonomous_codex_after_attach(key).await + { + return true; + } + false + } + + fn mark_task_resuming_in_background(&mut self, workspace_key: &str, task_id: &str) { + let Ok(workspace) = self.service.manager.get_workspace(workspace_key) else { + return; + }; + workspace.update(|snapshot| { + let task_state = snapshot.task_states.entry(task_id.to_string()).or_default(); + let mut changed = false; + if task_state.agent_state != Some(AutomationAgentState::Working) { + task_state.agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if task_state.session_status != Some(RootSessionStatus::Busy) { + task_state.session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let task_status = Some("Resuming in background".to_string()); + if task_state.status != task_status { + task_state.status = task_status; + changed = true; + } + if task_state.waiting_on_vm { + task_state.waiting_on_vm = false; + changed = true; + } + if snapshot.active_task_id.as_deref() != Some(task_id) { + snapshot.active_task_id = Some(task_id.to_string()); + changed = true; + } + if snapshot.automation_agent_state != Some(AutomationAgentState::Working) { + snapshot.automation_agent_state = Some(AutomationAgentState::Working); + changed = true; + } + if snapshot.automation_session_status != Some(RootSessionStatus::Busy) { + snapshot.automation_session_status = Some(RootSessionStatus::Busy); + changed = true; + } + let automation_status = Some(format!("Resuming {task_id} in background")); + if snapshot.automation_status != automation_status { + snapshot.automation_status = automation_status; + changed = true; + } + changed + }); + } + async fn maybe_resume_autonomous_codex_after_attach(&mut self, key: &str) -> bool { if self.service.agent_provider() != multicode_lib::services::AgentProvider::Codex { + tracing::info!( + workspace_key = %key, + provider = ?self.service.agent_provider(), + "skipping codex auto-resume because agent provider is not Codex" + ); + self.attached_session = None; return false; } - for _ in 0..10 { + let attached_session = self.attached_session.clone(); + tracing::info!( + workspace_key = %key, + attached_session = ?attached_session, + "evaluating codex auto-resume after attach" + ); + for _ in 0..8 { self.sync_from_manager(); let snapshot = self.snapshots.get(key).cloned(); let Some(snapshot) = snapshot else { + tracing::info!( + workspace_key = %key, + "workspace disappeared while evaluating codex auto-resume after attach" + ); + self.attached_session = None; return false; }; - if should_auto_resume_autonomous_codex_after_attach(&snapshot) { - match self - .service - .prompt_root_session(&snapshot, CODEX_AUTO_RESUME_PROMPT) - .await - { - Ok(()) => { - self.status = format!( - "Detached from workspace '{key}'; autonomous Codex work was no longer running interactively, so multicode resumed it automatically" - ); - } - Err(err) => { - self.status = format!( - "Detached from workspace '{key}'; autonomous Codex work stopped after attach, but multicode failed to resume it automatically: {err}" - ); + let interrupted_resume_prompt = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(_), + session_id: Some(session_id), + initial_turn_metrics, + .. + }) if workspace_key == key => { + let should_resume_interrupted = self + .should_resume_interrupted_task_codex_after_attach( + &snapshot, + workspace_key, + Some(session_id.as_str()), + *initial_turn_metrics, + ) + .await; + if should_resume_interrupted { + read_interrupted_codex_resume_prompt( + self.service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id.clone(), + ) + .await + } else { + None } } + _ => None, + }; + + let should_resume = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + session_id, + initial_agent_state, + .. + }) if workspace_key == key => { + should_auto_resume_task_codex_after_attach( + task_runtime_snapshot(&snapshot, task_id), + session_id.as_deref(), + *initial_agent_state, + ) || interrupted_resume_prompt.is_some() + } + _ => should_auto_resume_autonomous_codex_after_attach(&snapshot), + }; + tracing::info!( + workspace_key = %key, + should_resume, + automation_agent_state = ?snapshot.automation_agent_state, + automation_session_status = ?snapshot.automation_session_status, + root_session_status = ?snapshot.root_session_status, + "evaluated codex auto-resume after attach" + ); + + if should_resume { + if let Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + .. + }) = attached_session.as_ref() + && workspace_key == key + { + self.mark_task_resuming_in_background(key, task_id); + self.sync_from_manager(); + } + let service = self.service.clone(); + let workspace_key = key.to_string(); + let snapshot_for_resume = snapshot.clone(); + let attached_session_for_resume = attached_session.clone(); + tokio::spawn(async move { + let resume_result = match attached_session_for_resume.as_ref() { + Some(AttachedSession { + workspace_key: attached_workspace_key, + task_id: Some(task_id), + session_id: Some(session_id), + .. + }) if attached_workspace_key == &workspace_key => { + if let Some(resume_prompt) = interrupted_resume_prompt.clone() { + service + .restart_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } else { + let resume_prompt = read_last_codex_session_user_message( + service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id.clone(), + ) + .await + .unwrap_or_else(|| CODEX_AUTO_RESUME_PROMPT.to_string()); + service + .prompt_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } + } + _ => { + service + .prompt_root_session( + &snapshot_for_resume, + CODEX_AUTO_RESUME_PROMPT, + ) + .await + } + }; + tracing::info!( + workspace_key = %workspace_key, + resume_result = ?resume_result, + "finished codex auto-resume attempt after attach" + ); + }); + self.status = format!( + "Detached from workspace '{key}'; autonomous Codex resume was scheduled in the background" + ); + self.attached_session = None; return true; } tokio::time::sleep(Duration::from_millis(100)).await; } + self.attached_session = None; false } + async fn should_resume_interrupted_task_codex_after_attach( + &self, + snapshot: &WorkspaceSnapshot, + workspace_key: &str, + session_id: Option<&str>, + initial_turn_metrics: Option, + ) -> bool { + let Some(session_id) = session_id else { + return false; + }; + let current_turn_metrics = read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + workspace_key, + session_id, + ); + let current_thread_status = match snapshot.transient.as_ref() { + Some(transient) => CodexAppServerClient::new(transient.uri.clone()) + .thread_read(session_id) + .await + .ok() + .and_then(|response| response.thread.status), + None => None, + }; + let should_resume = should_resume_codex_task_after_incomplete_attached_turn( + initial_turn_metrics, + current_turn_metrics, + current_thread_status.as_ref(), + ); + tracing::info!( + workspace_key = %workspace_key, + session_id, + initial_turn_metrics = ?initial_turn_metrics, + current_turn_metrics = ?current_turn_metrics, + current_thread_status = ?current_thread_status, + should_resume, + "evaluated interrupted codex task resume after attach" + ); + should_resume + } + pub(crate) fn poll_running_prompt_tool(&mut self) { let completion = match self.running_operation.as_mut() { Some(running_tool) => match running_tool.result_rx.try_recv() { @@ -1439,6 +1918,7 @@ impl TuiState { } match self.snapshot_attach_target(&key) { Ok(target) => { + self.record_attached_session(&key, &target); let custom_description = self .snapshots .get(&key) @@ -1458,9 +1938,19 @@ impl TuiState { self.handle_attach_exit(&key).await; } Err(err) => { - self.status = format!( - "Failed to attach to workspace '{key}': {err}" - ) + tracing::warn!( + workspace_key = %key, + error = %err, + "attach session exited with error" + ); + if !self + .handle_attach_exit_after_error(&key, &err) + .await + { + self.status = format!( + "Failed to attach to workspace '{key}': {err}" + ); + } } } } diff --git a/tui/src/main.rs b/tui/src/main.rs index eb00509..5b11ed7 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -138,6 +138,7 @@ struct TuiState { custom_link_action: Option, custom_link_original_value: Option, pending_delete_target: Option, + attached_session: Option, starting_workspace_key: Option, started_wait_since: Option, previous_machine_cpu_totals: Option, @@ -152,6 +153,22 @@ struct TuiState { should_quit: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AttachedSession { + workspace_key: String, + task_id: Option, + session_id: Option, + initial_agent_state: Option, + initial_turn_metrics: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct CodexSessionTurnMetrics { + started: usize, + completed: usize, + aborted: usize, +} + struct RunningOperation { workspace_key: String, operation_name: String, @@ -342,8 +359,13 @@ fn task_issue_link<'a>( .unwrap_or(task.issue_url.as_str()) } -fn task_pr_link(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> Option<&str> { - task_state.and_then(|state| state.pr.first().map(String::as_str)) +fn task_pr_link<'a>( + task: &'a WorkspaceTaskPersistentSnapshot, + task_state: Option<&'a WorkspaceTaskRuntimeSnapshot>, +) -> Option<&'a str> { + task_state + .and_then(|state| state.pr.first().map(String::as_str)) + .or(task.backing_pr_url.as_deref()) } fn github_link_badge(url: &str) -> String { @@ -361,7 +383,10 @@ fn github_link_badge(url: &str) -> String { } fn task_server_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> &'static str { - match task_state.and_then(|state| state.agent_state) { + if task_state.is_some_and(|state| state.waiting_on_vm) { + return "Waiting on VM"; + } + match task_effective_agent_state(task_state) { Some(AutomationAgentState::Working) => "Busy", Some(AutomationAgentState::Question) => "Question", Some(AutomationAgentState::Review | AutomationAgentState::Idle) => "Idle", @@ -400,19 +425,41 @@ fn task_description( { return status.trim().to_string(); } - match task_state.and_then(|state| state.agent_state) { + if task_state.is_some_and(|state| state.waiting_on_vm) { + return "Queued until VM is free".to_string(); + } + match task_effective_agent_state(task_state) { Some(AutomationAgentState::Working) => format!("Working {}", task_issue_reference(task)), Some(AutomationAgentState::Question) => format!("Question {}", task_issue_reference(task)), Some(AutomationAgentState::Review) => format!("Review {}", task_issue_reference(task)), - Some(AutomationAgentState::WaitingOnVm) => { - format!("Waiting on VM {}", task_issue_reference(task)) - } + Some(AutomationAgentState::WaitingOnVm) => "Queued until VM is free".to_string(), Some(AutomationAgentState::Idle) => format!("Wait close {}", task_issue_reference(task)), Some(AutomationAgentState::Stale) => format!("Stalled {}", task_issue_reference(task)), None => task_issue_reference(task), } } +fn task_effective_agent_state( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> Option { + let task_state = task_state?; + match task_state.session_status { + Some(RootSessionStatus::Question) => Some(AutomationAgentState::Question), + Some(RootSessionStatus::Idle) if task_state.session_id.is_some() => { + Some(AutomationAgentState::Review) + } + Some(RootSessionStatus::Idle) => Some(AutomationAgentState::Idle), + Some(RootSessionStatus::Busy) => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) => Some(AutomationAgentState::Working), + other => other, + }, + None => match task_state.agent_state { + Some(AutomationAgentState::WaitingOnVm) if !task_state.waiting_on_vm => None, + other => other, + }, + } +} + fn next_non_stopped_row( current_row: usize, ordered_keys: &[String], @@ -692,7 +739,7 @@ fn task_links( value: task_issue_link(task, task_state).to_string(), source: WorkspaceLinkSource::Task, }]; - if let Some(pr) = task_pr_link(task_state) { + if let Some(pr) = task_pr_link(task, task_state) { links.push(WorkspaceLink { kind: WorkspaceLinkKind::Pr, value: pr.to_string(), @@ -852,7 +899,9 @@ fn compare_target_path_for_task( .join(format!("{repo_name}-{issue_number}")), workspace_path.join(repo_name), ]; - candidates.into_iter().find(|candidate| is_git_checkout(candidate)) + candidates + .into_iter() + .find(|candidate| is_git_checkout(candidate)) } fn compare_target_path_from_workspace( @@ -883,7 +932,9 @@ fn compare_target_path_from_workspace( candidates.push(workspace_path.join(repo_name)); } - candidates.into_iter().find(|candidate| is_git_checkout(candidate)) + candidates + .into_iter() + .find(|candidate| is_git_checkout(candidate)) } fn is_git_checkout(path: &Path) -> bool { diff --git a/tui/src/render.rs b/tui/src/render.rs index 602ee85..430c36b 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -246,26 +246,28 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { let issue_link = task_links .iter() .find(|link| link.kind == WorkspaceLinkKind::Issue); - let pr_link = task_links.iter().find(|link| link.kind == WorkspaceLinkKind::Pr); + let pr_link = task_links + .iter() + .find(|link| link.kind == WorkspaceLinkKind::Pr); let issue_status = issue_link.and_then(|link| app.github_link_statuses.get(link)); let pr_status = pr_link.and_then(|link| app.github_link_statuses.get(link)); - let issue_cell = if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status - { - let (kind, color) = issue_icon_kind_and_color(issue_status.state); - status_icon_cell( - kind, - if archived { Color::DarkGray } else { color }, - selected_link_kind == Some(WorkspaceLinkKind::Issue), - ) - } else { - Cell::from(github_link_badge(task_issue_link(task, task_state))).style( - if selected_link_kind == Some(WorkspaceLinkKind::Issue) { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }, - ) - }; + let issue_cell = + if let Some(GithubLinkStatusView::Issue(issue_status)) = issue_status { + let (kind, color) = issue_icon_kind_and_color(issue_status.state); + status_icon_cell( + kind, + if archived { Color::DarkGray } else { color }, + selected_link_kind == Some(WorkspaceLinkKind::Issue), + ) + } else { + Cell::from(github_link_badge(task_issue_link(task, task_state))).style( + if selected_link_kind == Some(WorkspaceLinkKind::Issue) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }, + ) + }; let pr_cell = if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { let (kind, color) = pr_icon_kind_and_color(*pr_status); status_icon_cell( @@ -274,12 +276,18 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { selected_link_kind == Some(WorkspaceLinkKind::Pr), ) } else { - Cell::from(task_pr_link(task_state).map(github_link_badge).unwrap_or_default()) - .style(if selected_link_kind == Some(WorkspaceLinkKind::Pr) { + Cell::from( + task_pr_link(task, task_state) + .map(github_link_badge) + .unwrap_or_default(), + ) + .style( + if selected_link_kind == Some(WorkspaceLinkKind::Pr) { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() - }) + }, + ) }; rows.push( Row::new(vec![ diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 6212d63..1040c8a 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -2,12 +2,18 @@ use crate::*; #[cfg(test)] mod tests { - use multicode_lib::{AutomationAgentState, services::HandlerConfig}; + use multicode_lib::{ + AutomationAgentState, RootSessionStatus, + services::{HandlerConfig, codex_app_server::CodexThreadStatus}, + }; use super::*; use crate::app::{ - compact_github_tooltip_target, restored_selected_row, - should_auto_resume_autonomous_codex_after_attach, starting_modal_failure_status, + compact_github_tooltip_target, count_codex_session_turn_metrics, + last_user_message_from_codex_session_log_contents, restored_selected_row, + should_auto_resume_autonomous_codex_after_attach, + should_auto_resume_task_codex_after_attach, + should_resume_codex_task_after_incomplete_attached_turn, starting_modal_failure_status, }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, @@ -257,6 +263,38 @@ mod tests { assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); } + #[test] + fn last_user_message_from_codex_session_log_prefers_real_user_events() { + let contents = r#"{"type":"event_msg","payload":{"type":"user_message","message":"first prompt"}} +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\nThe user interrupted the previous turn on purpose.\n"}]}} +{"type":"event_msg","payload":{"type":"user_message","message":"create a PR"}}"#; + + assert_eq!( + last_user_message_from_codex_session_log_contents(contents).as_deref(), + Some("create a PR") + ); + } + + #[test] + fn task_links_fall_back_to_persistent_backing_pr_url() { + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/graemerocher/multicode-test/issues/1".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ) + .with_backing_pr_url(Some( + "https://github.com/graemerocher/multicode-test/pull/8".to_string(), + )); + + let links = crate::task_links(&task, None); + assert_eq!(links.len(), 2); + assert_eq!(links[1].kind, WorkspaceLinkKind::Pr); + assert_eq!( + links[1].value, + "https://github.com/graemerocher/multicode-test/pull/8" + ); + } + #[test] fn workspace_links_hide_issue_and_pr_when_tasks_exist() { let mut started = snapshot(true, Some("http://example")); @@ -275,7 +313,9 @@ mod tests { let mut stopped = WorkspaceSnapshot::default(); stopped.persistent.assigned_repository = Some("micronaut-projects/micronaut-serialization".to_string()); - assert!(crate::app::should_request_autonomous_issue_scan(&stopped, 5)); + assert!(crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); stopped .persistent @@ -286,14 +326,23 @@ mod tests { .to_string(), multicode_lib::WorkspaceTaskSource::Manual, )); - assert!(crate::app::should_request_autonomous_issue_scan(&stopped, 5)); - assert!(!crate::app::should_request_autonomous_issue_scan(&stopped, 1)); + assert!(crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); + assert!(!crate::app::should_request_autonomous_issue_scan( + &stopped, 1 + )); stopped.persistent.archived = true; - assert!(!crate::app::should_request_autonomous_issue_scan(&stopped, 5)); + assert!(!crate::app::should_request_autonomous_issue_scan( + &stopped, 5 + )); let unassigned = WorkspaceSnapshot::default(); - assert!(!crate::app::should_request_autonomous_issue_scan(&unassigned, 5)); + assert!(!crate::app::should_request_autonomous_issue_scan( + &unassigned, + 5 + )); } #[test] @@ -324,6 +373,153 @@ mod tests { assert!(!should_auto_resume_autonomous_codex_after_attach(&snapshot)); } + #[test] + fn task_server_label_prefers_idle_session_status_over_stale_working_agent_state() { + let task_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-39".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + let task = multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/graemerocher/multicode-test/issues/39".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + ); + + assert_eq!(crate::task_server_label(Some(&task_state)), "Idle"); + assert_eq!( + crate::task_description(&task, Some(&task_state)), + "Review multicode-test#39" + ); + } + + #[test] + fn task_auto_resume_after_attach_only_resumes_when_task_is_still_working() { + let review_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Idle), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + assert!(!should_auto_resume_task_codex_after_attach( + Some(&review_state), + Some("thread-4"), + Some(AutomationAgentState::Review) + )); + + let busy_state = multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-4".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }; + assert!(should_auto_resume_task_codex_after_attach( + Some(&busy_state), + Some("thread-4"), + Some(AutomationAgentState::Working) + )); + } + + #[test] + fn codex_session_turn_metrics_count_started_and_completed_turns() { + let metrics = count_codex_session_turn_metrics( + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}\n\ + {\"type\":\"event_msg\",\"payload\":{\"type\":\"task_complete\"}}\n\ + {\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}\n", + ); + + assert_eq!(metrics.started, 2); + assert_eq!(metrics.completed, 1); + } + + #[test] + fn incomplete_attached_codex_turn_resumes_when_thread_is_idle() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 3, + aborted: 0, + }), + Some(&CodexThreadStatus::Idle), + )); + } + + #[test] + fn completed_attached_codex_turn_does_not_resume() { + assert!(!should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 4, + aborted: 0, + }), + Some(&CodexThreadStatus::Idle), + )); + } + + #[test] + fn active_attached_codex_turn_does_not_resume() { + assert!(!should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 3, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 3, + aborted: 0, + }), + Some(&CodexThreadStatus::Active { + active_flags: Vec::new(), + }), + )); + } + + #[test] + fn incomplete_attached_codex_turn_resumes_when_thread_status_is_unavailable() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 3, + completed: 1, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 0, + }), + None, + )); + } + + #[test] + fn aborted_attached_codex_turn_resumes_even_if_started_count_did_not_advance() { + assert!(should_resume_codex_task_after_incomplete_attached_turn( + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 0, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 1, + aborted: 1, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + #[test] fn workspace_attach_target_requires_started_state() { let err = workspace_attach_target(&snapshot(false, Some("http://example"))) diff --git a/workspace-skills/autonomous-state/SKILL.md b/workspace-skills/autonomous-state/SKILL.md index 53442cf..bc49297 100644 --- a/workspace-skills/autonomous-state/SKILL.md +++ b/workspace-skills/autonomous-state/SKILL.md @@ -3,8 +3,13 @@ name: autonomous-state description: Maintain the multicode autonomous state file while working autonomously so the host can detect working, question, review, idle, and stalled states. --- -When operating in a multicode autonomous workspace, the environment variable `MULTICODE_AUTONOMOUS_STATE_PATH` -points to a writable state file owned by multicode. You must keep this file updated. +When operating in a multicode autonomous workspace, multicode may either: + +- provide an explicit task-specific state file path in the prompt, or +- provide a fallback path via the environment variable `MULTICODE_AUTONOMOUS_STATE_PATH`. + +Always prefer the explicit task-specific path from the prompt when one is provided. Only fall back to +`MULTICODE_AUTONOMOUS_STATE_PATH` when no explicit task file path was given. Write exactly one line to that file. @@ -25,8 +30,9 @@ If no session/thread id was provided, fall back to the plain state word: Use shell commands like: ```sh -mkdir -p "$(dirname "$MULTICODE_AUTONOMOUS_STATE_PATH")" -printf '%s\n' working > "$MULTICODE_AUTONOMOUS_STATE_PATH" +STATE_FILE="${MULTICODE_AUTONOMOUS_STATE_PATH}" +mkdir -p "$(dirname "$STATE_FILE")" +printf '%s\n' working > "$STATE_FILE" ``` Required workflow: From 094e0fa2f13936c7d10c763db8d60d55da1bc955 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 13 Apr 2026 10:25:35 +0200 Subject: [PATCH 16/75] Show per-task cost and build status Co-authored-by: OpenAI Codex --- lib/src/lib.rs | 1 + .../services/autonomous_workspace_service.rs | 164 +++++++++++++++--- tui/src/main.rs | 41 ++++- tui/src/render.rs | 43 ++++- tui/src/tests.rs | 69 ++++++++ 5 files changed, 289 insertions(+), 29 deletions(-) diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f01a5b5..8dbf294 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -104,6 +104,7 @@ pub struct WorkspaceTaskRuntimeSnapshot { pub session_status: Option, pub agent_state: Option, pub status: Option, + pub usage_total_tokens: Option, pub waiting_on_vm: bool, pub repository: Vec, pub issue: Vec, diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 83d8884..1d8e14d 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -1476,6 +1476,81 @@ struct CodexTaskMetadata { prs: Vec, } +fn codex_session_log_root(workspace_directory_path: &Path, workspace_key: &str) -> PathBuf { + workspace_directory_path + .join(".multicode") + .join("codex") + .join(workspace_key) + .join("home") + .join("sessions") +} + +fn find_codex_session_log_path( + workspace_directory_path: &Path, + workspace_key: &str, + session_id: &str, +) -> Option { + let root = codex_session_log_root(workspace_directory_path, workspace_key); + let mut stack = vec![root]; + let suffix = format!("{session_id}.jsonl"); + while let Some(path) = stack.pop() { + let entries = std::fs::read_dir(&path).ok()?; + for entry in entries.flatten() { + let entry_path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + stack.push(entry_path); + continue; + } + if file_type.is_file() + && entry_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(entry_path); + } + } + } + None +} + +fn codex_usage_total_tokens_from_session_log_contents(contents: &str) -> Option { + contents + .lines() + .filter_map(|line| { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("token_count") { + return None; + } + payload + .get("info") + .and_then(|info| info.get("total_token_usage")) + .and_then(|usage| usage.get("total_tokens")) + .and_then(serde_json::Value::as_u64) + }) + .last() +} + +async fn read_codex_task_usage_total_tokens( + workspace_directory_path: PathBuf, + workspace_key: String, + session_id: String, +) -> Option { + spawn_blocking(move || { + let path = + find_codex_session_log_path(&workspace_directory_path, &workspace_key, &session_id)?; + let contents = std::fs::read_to_string(path).ok()?; + codex_usage_total_tokens_from_session_log_contents(&contents) + }) + .await + .ok() + .flatten() +} + async fn recover_codex_task_sessions( service: &CombinedService, workspace: &Workspace, @@ -1617,6 +1692,7 @@ async fn reconcile_codex_task_runtime_states( issues: vec![task.issue_url.clone()], ..Default::default() }), + None, ); write_authoritative_task_state_file( service.workspace_directory_path(), @@ -1673,6 +1749,12 @@ async fn reconcile_codex_task_runtime_states( } else { codex_runtime_state_for_task(status, task_state) }; + let usage_total_tokens = read_codex_task_usage_total_tokens( + service.workspace_directory_path().to_path_buf(), + workspace_key.to_string(), + task_session_id.clone(), + ) + .await; tracing::warn!( workspace_key, task_id = %task.id, @@ -1702,6 +1784,7 @@ async fn reconcile_codex_task_runtime_states( next_session_status, next_agent_state, Some(metadata), + usage_total_tokens, ); write_authoritative_task_state_file( service.workspace_directory_path(), @@ -1793,12 +1876,14 @@ fn recover_latest_codex_thread_candidate_for_cwd( codex_home: &Path, cwd: &str, ) -> Option { - let mut candidate = recover_codex_thread_candidates_from_state_db(codex_home, &[cwd.to_string()]) - .ok() - .and_then(|mut entries| entries.remove(cwd)); - - let log_candidate = recover_codex_thread_candidates_from_session_logs(codex_home, &[cwd.to_string()]) - .remove(cwd); + let mut candidate = + recover_codex_thread_candidates_from_state_db(codex_home, &[cwd.to_string()]) + .ok() + .and_then(|mut entries| entries.remove(cwd)); + + let log_candidate = + recover_codex_thread_candidates_from_session_logs(codex_home, &[cwd.to_string()]) + .remove(cwd); if should_prefer_recovered_candidate(log_candidate.as_ref(), candidate.as_ref()) { candidate = log_candidate; } @@ -1892,10 +1977,13 @@ fn recover_codex_thread_candidates_from_session_logs( stack.push(entry.path()); continue; } - if !file_type.is_file() || entry.path().extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + if !file_type.is_file() + || entry.path().extension().and_then(|ext| ext.to_str()) != Some("jsonl") + { continue; } - let Some(candidate) = recover_codex_thread_candidate_from_session_log(&entry.path()) else { + let Some(candidate) = recover_codex_thread_candidate_from_session_log(&entry.path()) + else { continue; }; if !task_cwds.iter().any(|cwd| cwd == &candidate.cwd) { @@ -1923,8 +2011,14 @@ fn recover_codex_thread_candidate_from_session_log( return None; } let payload = value.get("payload")?; - let id = payload.get("id").and_then(serde_json::Value::as_str)?.to_string(); - let cwd = payload.get("cwd").and_then(serde_json::Value::as_str)?.to_string(); + let id = payload + .get("id") + .and_then(serde_json::Value::as_str)? + .to_string(); + let cwd = payload + .get("cwd") + .and_then(serde_json::Value::as_str)? + .to_string(); let timestamp = payload .get("timestamp") .and_then(serde_json::Value::as_str) @@ -1963,10 +2057,9 @@ async fn recover_latest_codex_task_session_id( let cwd = task_cwd.to_string_lossy().into_owned(); let current_session_id = current_session_id.map(ToOwned::to_owned); spawn_blocking(move || { - recover_latest_codex_thread_candidate_for_cwd(&codex_home, &cwd) - .and_then(|candidate| { - (current_session_id.as_deref() != Some(candidate.id.as_str())).then_some(candidate.id) - }) + recover_latest_codex_thread_candidate_for_cwd(&codex_home, &cwd).and_then(|candidate| { + (current_session_id.as_deref() != Some(candidate.id.as_str())).then_some(candidate.id) + }) }) .await .ok() @@ -2063,11 +2156,13 @@ fn set_task_runtime_state_from_codex( session_status: RootSessionStatus, agent_state: AutomationAgentState, metadata: Option, + usage_total_tokens: Option, ) { workspace.update(|snapshot| { let mut changed = false; let is_active = snapshot.active_task_id.as_deref() == Some(task_id); - let next_status_text = codex_task_status_text(agent_state, metadata.as_ref(), task_id, snapshot); + let next_status_text = + codex_task_status_text(agent_state, metadata.as_ref(), task_id, snapshot); if is_active { if snapshot.automation_session_id.as_deref() != Some(session_id) { snapshot.automation_session_id = Some(session_id.to_string()); @@ -2104,6 +2199,10 @@ fn set_task_runtime_state_from_codex( task_state.waiting_on_vm = should_wait; changed = true; } + if task_state.usage_total_tokens != usage_total_tokens { + task_state.usage_total_tokens = usage_total_tokens; + changed = true; + } if let Some(metadata) = metadata { if task_state.repository != metadata.repositories { task_state.repository = metadata.repositories; @@ -3553,10 +3652,8 @@ mod tests { ) .expect("unrelated session log should be written"); - let recovered = recover_codex_thread_candidates_from_session_logs( - &codex_home, - &[cwd.to_string()], - ); + let recovered = + recover_codex_thread_candidates_from_session_logs(&codex_home, &[cwd.to_string()]); assert_eq!( recovered.get(cwd).map(|candidate| candidate.id.as_str()), @@ -4007,8 +4104,16 @@ mod tests { true }); - assert!(task_session_id_is_current(&workspace, "task-39", "thread-new")); - assert!(!task_session_id_is_current(&workspace, "task-39", "thread-old")); + assert!(task_session_id_is_current( + &workspace, + "task-39", + "thread-new" + )); + assert!(!task_session_id_is_current( + &workspace, + "task-39", + "thread-old" + )); } #[test] @@ -4768,6 +4873,7 @@ mod tests { prs: vec!["https://github.com/example/repo/pull/11".to_string()], ..Default::default() }), + Some(54_611), ); let snapshot = workspace.subscribe().borrow().clone(); @@ -4797,6 +4903,7 @@ mod tests { task_state.pr, vec!["https://github.com/example/repo/pull/11".to_string()] ); + assert_eq!(task_state.usage_total_tokens, Some(54_611)); assert!(active_task_can_yield_vm(&snapshot)); } @@ -4839,6 +4946,7 @@ mod tests { RootSessionStatus::Busy, AutomationAgentState::Working, None, + None, ); let snapshot = workspace.subscribe().borrow().clone(); @@ -4884,6 +4992,7 @@ mod tests { prs: vec!["https://github.com/example/repo/pull/56".to_string()], ..Default::default() }), + None, ); let snapshot = workspace.subscribe().borrow().clone(); @@ -4923,6 +5032,7 @@ mod tests { RootSessionStatus::Busy, AutomationAgentState::Working, Some(CodexTaskMetadata::default()), + None, ); let snapshot = workspace.subscribe().borrow().clone(); @@ -4974,6 +5084,18 @@ mod tests { ); } + #[test] + fn codex_usage_total_tokens_from_session_log_contents_reads_latest_token_count() { + let contents = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":123}}}} +{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":456789}}}} +"#; + + assert_eq!( + codex_usage_total_tokens_from_session_log_contents(contents), + Some(456_789) + ); + } + #[test] fn non_active_codex_working_task_keeps_working_state_for_runtime_tracking() { let status = CodexThreadStatus::Active { diff --git a/tui/src/main.rs b/tui/src/main.rs index 5b11ed7..57a5a57 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -534,14 +534,36 @@ fn format_price(cost: f64) -> String { format!("${cost:.2}") } +fn task_cost_cell_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> String { + if let Some(tokens) = task_state.and_then(|state| state.usage_total_tokens) { + return format_tokens_spaced(tokens); + } + String::new() +} + +fn workspace_usage_totals(snapshot: &WorkspaceSnapshot) -> (Option, Option) { + let task_tokens = snapshot + .task_states + .values() + .filter_map(|task_state| task_state.usage_total_tokens) + .reduce(|sum, tokens| sum.saturating_add(tokens)); + if task_tokens.is_some() { + return (None, task_tokens); + } + ( + snapshot + .usage_total_cost + .filter(|cost| cost.is_finite() && *cost > 0.0), + snapshot.usage_total_tokens, + ) +} + fn cost_cell_label(snapshot: &WorkspaceSnapshot) -> String { - if let Some(cost) = snapshot - .usage_total_cost - .filter(|cost| cost.is_finite() && *cost > 0.0) - { + let (cost, tokens) = workspace_usage_totals(snapshot); + if let Some(cost) = cost { return format_price(cost); } - if let Some(tokens) = snapshot.usage_total_tokens { + if let Some(tokens) = tokens { return format_tokens_spaced(tokens); } String::new() @@ -1098,6 +1120,15 @@ fn table_column_widths( server_width = server_width.max(content_width(server_cell_label(snapshot))); cpu_width = cpu_width.max(content_width(&cpu_cell_label(snapshot))); cost_width = cost_width.max(content_width(&cost_cell_label(snapshot))); + for task in &snapshot.persistent.tasks { + workspace_width = workspace_width.max(content_width(&task_row_label(task))); + server_width = server_width.max(content_width(task_server_label( + task_runtime_snapshot(snapshot, &task.id), + ))); + cost_width = cost_width.max(content_width(&task_cost_cell_label( + task_runtime_snapshot(snapshot, &task.id), + ))); + } } } ( diff --git a/tui/src/render.rs b/tui/src/render.rs index 430c36b..f1b8abd 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -289,6 +289,43 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { }, ) }; + let cost = task_cost_cell_label(task_state); + let cost = right_align_cell_text(&cost, cost_width); + let (build_cell, review_status_cell) = + if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { + ( + pr_build_icon_color(*pr_status).map_or_else( + Cell::default, + |build_color| { + status_icon_cell( + StatusIconKind::Server, + if archived { + Color::DarkGray + } else { + build_color + }, + false, + ) + }, + ), + pr_review_icon_color(*pr_status).map_or_else( + Cell::default, + |review_color| { + status_icon_cell( + StatusIconKind::Eye, + if archived { + Color::DarkGray + } else { + review_color + }, + false, + ) + }, + ), + ) + } else { + (Cell::default(), Cell::default()) + }; rows.push( Row::new(vec![ Cell::from(task_row_label(task)), @@ -296,12 +333,12 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { .style(task_server_style(task_state, archived)), Cell::from(""), Cell::from(""), - Cell::from(""), + Cell::from(cost), Cell::from(""), issue_cell, pr_cell, - Cell::from(""), - Cell::from(""), + build_cell, + review_status_cell, Cell::from(task_description(task, task_state)), ]) .style(workspace_row_style(snapshot)), diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 1040c8a..60fa06b 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -2342,6 +2342,40 @@ mod tests { assert_eq!(cost_cell_label(&snapshot), "1 234 567"); } + #[test] + fn task_cost_cell_label_uses_task_tokens() { + let task_state = WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(987_654), + ..Default::default() + }; + + assert_eq!(task_cost_cell_label(Some(&task_state)), "987 654"); + assert_eq!(task_cost_cell_label(None), ""); + } + + #[test] + fn workspace_cost_cell_label_sums_task_tokens_before_workspace_usage() { + let mut snapshot = snapshot(true, Some("http://example")); + snapshot.usage_total_cost = Some(2.5); + snapshot.usage_total_tokens = Some(1_234_567); + snapshot.task_states.insert( + "task-1".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(111), + ..Default::default() + }, + ); + snapshot.task_states.insert( + "task-2".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(222), + ..Default::default() + }, + ); + + assert_eq!(cost_cell_label(&snapshot), "333"); + } + #[test] fn cpu_and_ram_cell_labels_format_usage_values() { let mut snapshot = snapshot(true, Some("http://example")); @@ -2618,6 +2652,41 @@ mod tests { ); } + #[test] + fn table_column_widths_include_task_cost_and_server_labels() { + let mut workspace = snapshot(true, Some("http://example")); + workspace + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + "task-39".to_string(), + "https://github.com/graemerocher/multicode-test/issues/39".to_string(), + multicode_lib::WorkspaceTaskSource::Scan, + )); + workspace.task_states.insert( + "task-39".to_string(), + WorkspaceTaskRuntimeSnapshot { + usage_total_tokens: Some(123_456_789), + waiting_on_vm: true, + ..Default::default() + }, + ); + let mut snapshots = HashMap::new(); + snapshots.insert("e2e-test".to_string(), workspace); + let ordered_keys = vec!["e2e-test".to_string()]; + + let (_, server_width, _, _, cost_width, _, _, _, _, _) = table_column_widths( + &ordered_keys, + &snapshots, + "Machine:", + "2200%", + &machine_ram_cell_label(Some(0)), + ); + + assert!(server_width >= content_width("Waiting on VM")); + assert!(cost_width >= content_width("123 456 789")); + } + #[test] fn workspace_ordering_keeps_archived_last_and_newest_first() { let mut snapshots = HashMap::new(); From b88da0281b851416aed031c3a4f78d571f938d31 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 13 Apr 2026 10:31:09 +0200 Subject: [PATCH 17/75] Compact token fallback in cost column Co-authored-by: OpenAI Codex --- tui/src/main.rs | 17 ++++++----------- tui/src/tests.rs | 10 +++++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tui/src/main.rs b/tui/src/main.rs index 57a5a57..fc11906 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -518,16 +518,11 @@ fn effective_server_status(snapshot: &WorkspaceSnapshot) -> RootSessionStatus { .unwrap_or(RootSessionStatus::Idle) } -fn format_tokens_spaced(tokens: u64) -> String { - let digits = tokens.to_string(); - let mut reversed = String::with_capacity(digits.len() + digits.len() / 3); - for (index, ch) in digits.chars().rev().enumerate() { - if index > 0 && index % 3 == 0 { - reversed.push(' '); - } - reversed.push(ch); +fn format_tokens_compact(tokens: u64) -> String { + if tokens < 1_000 { + return tokens.to_string(); } - reversed.chars().rev().collect() + format!("{}k", tokens / 1_000) } fn format_price(cost: f64) -> String { @@ -536,7 +531,7 @@ fn format_price(cost: f64) -> String { fn task_cost_cell_label(task_state: Option<&WorkspaceTaskRuntimeSnapshot>) -> String { if let Some(tokens) = task_state.and_then(|state| state.usage_total_tokens) { - return format_tokens_spaced(tokens); + return format_tokens_compact(tokens); } String::new() } @@ -564,7 +559,7 @@ fn cost_cell_label(snapshot: &WorkspaceSnapshot) -> String { return format_price(cost); } if let Some(tokens) = tokens { - return format_tokens_spaced(tokens); + return format_tokens_compact(tokens); } String::new() } diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 60fa06b..17d085e 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -2336,20 +2336,20 @@ mod tests { assert_eq!(cost_cell_label(&snapshot), "$2.50"); snapshot.usage_total_cost = Some(0.0); - assert_eq!(cost_cell_label(&snapshot), "1 234 567"); + assert_eq!(cost_cell_label(&snapshot), "1234k"); snapshot.usage_total_cost = None; - assert_eq!(cost_cell_label(&snapshot), "1 234 567"); + assert_eq!(cost_cell_label(&snapshot), "1234k"); } #[test] - fn task_cost_cell_label_uses_task_tokens() { + fn task_cost_cell_label_uses_compact_task_tokens() { let task_state = WorkspaceTaskRuntimeSnapshot { usage_total_tokens: Some(987_654), ..Default::default() }; - assert_eq!(task_cost_cell_label(Some(&task_state)), "987 654"); + assert_eq!(task_cost_cell_label(Some(&task_state)), "987k"); assert_eq!(task_cost_cell_label(None), ""); } @@ -2684,7 +2684,7 @@ mod tests { ); assert!(server_width >= content_width("Waiting on VM")); - assert!(cost_width >= content_width("123 456 789")); + assert!(cost_width >= content_width("123456k")); } #[test] From 07dfc683a22c9a08eec76077aeff136f5771597a Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 13 Apr 2026 10:37:16 +0200 Subject: [PATCH 18/75] Adjust issue claim comment and assignee Co-authored-by: OpenAI Codex --- .../services/autonomous_workspace_service.rs | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 1d8e14d..1d32dbb 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -43,6 +43,7 @@ const DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = "Dependency upgrade follow-u const DEPENDENCY_UPGRADE_PR_MARKER_PREFIX: &str = "", pr_url = pr.url, @@ -4281,6 +4283,12 @@ impl SelectedIssue { .any(|candidate| candidate.name.eq_ignore_ascii_case(label)) } + fn is_in_progress(&self) -> bool { + IN_PROGRESS_LABEL_ALIASES + .iter() + .any(|label| self.has_label(label)) + } + fn has_priority_boost_label(&self) -> bool { ISSUE_PRIORITY_BOOST_LABELS .iter() @@ -4882,7 +4890,7 @@ mod tests { let selected = issues .into_iter() - .filter(|issue| !issue.has_label(IN_PROGRESS_LABEL)) + .filter(|issue| !issue.is_in_progress()) .filter(|issue| !excluded.contains(&issue.url)) .next() .expect("one issue should remain"); @@ -4890,6 +4898,26 @@ mod tests { assert_eq!(selected.number, 9); } + #[test] + fn selected_issue_treats_hyphenated_in_progress_label_as_in_progress() { + let issue = test_issue( + 10, + "busy elsewhere", + "https://github.com/example/repo/issue/10", + "2026-04-09T12:00:00Z", + vec![ + SelectedIssueLabel { + name: "type: bug".to_string(), + }, + SelectedIssueLabel { + name: "status: in-progress".to_string(), + }, + ], + ); + + assert!(issue.is_in_progress()); + } + #[test] fn discover_issue_backing_pr_url_prefers_explicit_issue_reference() { let issue = test_issue( @@ -7062,9 +7090,33 @@ mod tests { ); assert!(prompt.contains("merge it without waiting for human review")); assert!(prompt.contains("close GitHub issue https://github.com/example/repo/issues/981")); + assert!(prompt.contains( + "Do not leave placeholder comments, placeholder reviews, or dummy approvals" + )); assert!(!prompt.contains("explicitly approves publishing")); } + #[test] + fn dependency_upgrade_issue_body_uses_updated_queue_text() { + let body = dependency_upgrade_issue_body(&test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert!( + body.contains("This issue was created automatically by multicode to prrocess the PR.") + ); + assert!(body.contains( + "If the update is still a non-major version bump and CI is passing, rebase and merge the PR without waiting for human review, then close this issue." + )); + assert!(!body.contains("so the autonomous queue can process the PR")); + } + #[test] fn merged_dependency_upgrade_issue_urls_ignores_non_dependency_tasks() { let mut snapshot = WorkspaceSnapshot::default(); From cc4b221f54560cf8128a7d3ad4a4d23a9e992d1e Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 16 Apr 2026 17:46:06 +0200 Subject: [PATCH 63/75] Clear ignored issue claims on GitHub Co-Authored-By: OpenAI Codex --- .../services/autonomous_workspace_service.rs | 83 +++++++++++++ lib/src/services/combined.rs | 116 +++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 81dd46a..88634ff 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -4092,6 +4092,25 @@ async fn assign_issue_to_me( } } +pub(crate) async fn clear_issue_claim_for_ignore( + service: &CombinedService, + assigned_repository: &str, + issue_url: &str, +) -> Result<(), String> { + let token = resolved_gh_token(service).await?; + let Some(issue) = fetch_issue(assigned_repository, issue_url, &token).await? else { + return Ok(()); + }; + + for label in IN_PROGRESS_LABEL_ALIASES { + if issue.has_label(label) { + remove_issue_label(assigned_repository, &issue.url, label, &token).await?; + } + } + + remove_issue_assignee(assigned_repository, &issue.url, "@me", &token).await +} + async fn add_issue_label( assigned_repository: &str, issue: &SelectedIssue, @@ -4125,6 +4144,70 @@ async fn add_issue_label( } } +async fn remove_issue_label( + assigned_repository: &str, + issue_url: &str, + label: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--remove-label", + label, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +async fn remove_issue_assignee( + assigned_repository: &str, + issue_url: &str, + assignee: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--remove-assignee", + assignee, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + fn apply_gh_env(command: &mut Command, token: &str) { command.env("GH_TOKEN", token); command.env("GITHUB_TOKEN", token); diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 466c142..20831d5 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -650,6 +650,16 @@ impl CombinedService { .await; } + if ignore_future_scans && let Some(assigned_repository) = assigned_repository.as_deref() { + super::autonomous_workspace_service::clear_issue_claim_for_ignore( + self, + assigned_repository, + &task.issue_url, + ) + .await + .map_err(CombinedServiceError::InvalidToolExecution)?; + } + if let Some(assigned_repository) = assigned_repository.as_deref() { self.remove_workspace_task_checkout(&key, assigned_repository, &task.issue_url) .await?; @@ -4102,15 +4112,24 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] .expect("fake opencode should be written"); make_executable(&fake_opencode); + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[]}'\n exit 0\nfi\nexit 0\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); let _home_guard = EnvVarGuard::set("HOME", &home); let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); let config_path = root.path().join("config.toml"); fs::write( &config_path, format!( - "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[isolation]\n", + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[github]\ntoken = {{ command = \"printf test-token\" }}\n\n[isolation]\n", workspace_directory.display() ), ) @@ -4156,6 +4175,101 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] }); } + #[test] + fn remove_and_ignore_workspace_task_clears_issue_assignment_and_in_progress_label() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let workspace_directory = home.join("workspaces"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + + let fake_opencode = bin_dir.join("opencode"); + fs::write(&fake_opencode, "#!/bin/sh\nexit 0\n") + .expect("fake opencode should be written"); + make_executable(&fake_opencode); + + let gh_log = root.path().join("gh.log"); + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> '{}'\nprintf -- '---\\n' >> '{}'\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[{{\"name\":\"status: in-progress\"}}],\"assignees\":[{{\"login\":\"graemerocher\"}}]}}'\n exit 0\nfi\nexit 0\n", + gh_log.display(), + gh_log.display() + ), + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + + let _path_guard = EnvVarGuard::set("PATH", &bin_dir); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + "workspace-directory = \"{}\"\nopencode = [\"opencode\"]\n\n[github]\ntoken = {{ command = \"printf test-token\" }}\n\n[isolation]\n", + workspace_directory.display() + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace_with_repository("alpha", "example/repo") + .await + .expect("workspace should be created with repository"); + service + .assign_workspace_issue("alpha", Some("#42")) + .await + .expect("issue assignment should succeed"); + + let workspace = service + .manager + .get_workspace("alpha") + .expect("workspace should exist"); + let task_id = workspace + .subscribe() + .borrow() + .persistent + .tasks + .first() + .expect("task should exist") + .id + .clone(); + + service + .remove_workspace_task("alpha", &task_id, true) + .await + .expect("task removal with ignore should succeed"); + + let gh_calls = fs::read_to_string(&gh_log).expect("gh log should exist"); + assert!(gh_calls.contains("issue\nview\nhttps://github.com/example/repo/issues/42")); + assert!(gh_calls.contains( + "issue\nedit\nhttps://github.com/example/repo/issues/42\n--repo\nexample/repo\n--remove-label\nstatus: in-progress" + )); + assert!(gh_calls.contains( + "issue\nedit\nhttps://github.com/example/repo/issues/42\n--repo\nexample/repo\n--remove-assignee\n@me" + )); + }); + } + #[test] fn ensure_and_remove_workspace_task_checkout_manage_git_worktree() { let runtime = tokio::runtime::Builder::new_current_thread() From 51e270dc562da632c36cfb6124acb4edaad7e6b5 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 09:42:06 +0200 Subject: [PATCH 64/75] Improve Codex task attach handling Co-Authored-By: OpenAI Codex --- tui/src/app.rs | 416 ++++++++++++++++++++++++++++++++++++++++++++--- tui/src/main.rs | 6 + tui/src/ops.rs | 11 ++ tui/src/tests.rs | 206 ++++++++++++++++++++++- 4 files changed, 614 insertions(+), 25 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index aea5477..b183aa6 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -358,6 +358,101 @@ pub(crate) fn should_auto_resume_task_codex_after_attach( } } +fn codex_observer_attach_prompt(task_id: &str) -> String { + format!( + "Another Codex task session is already actively working on {task_id}. You are attached only for observation and user-directed inspection. Do not make repository changes, do not start duplicate work, and do not send autonomous follow-up prompts to the active task. Briefly confirm you are attached and then wait for the user." + ) +} + +pub(crate) fn working_codex_task_attach_target( + snapshot: &WorkspaceSnapshot, + task_id: Option<&str>, + cwd: Option, +) -> io::Result> { + let Some(task_id) = task_id else { + return Ok(None); + }; + let Some(task_state) = task_runtime_snapshot(snapshot, task_id) else { + return Ok(None); + }; + if task_effective_agent_state(Some(task_state)) != Some(AutomationAgentState::Working) { + return Ok(None); + } + + let Some(uri) = codex_attach_uri(snapshot)? else { + return Ok(None); + }; + Ok(Some(AttachTarget::CodexNew { + uri, + cwd, + prompt: Some(codex_observer_attach_prompt(task_id)), + })) +} + +pub(crate) fn should_retry_codex_task_attach_with_last_thread( + provider: multicode_lib::services::AgentProvider, + snapshot: Option<&WorkspaceSnapshot>, + workspace_key: &str, + attached_workspace_key: Option<&str>, + task_id: Option<&str>, + session_id: Option<&str>, +) -> Option { + if provider != multicode_lib::services::AgentProvider::Codex + || attached_workspace_key != Some(workspace_key) + || session_id.is_none() + { + return None; + } + + let snapshot = snapshot?; + let task_state = task_runtime_snapshot(snapshot, task_id?)?; + if !matches!(task_state.status.as_deref(), Some("NotLoaded")) { + return None; + } + + let Ok(Some(uri)) = codex_attach_uri(snapshot) else { + return None; + }; + Some(AttachTarget::Codex { + uri, + thread_id: None, + }) +} + +pub(crate) fn should_start_fresh_codex_task_session_after_failed_attach( + provider: multicode_lib::services::AgentProvider, + attached_workspace_key: Option<&str>, + workspace_key: &str, + task_id: Option<&str>, + session_id: Option<&str>, + initial_turn_metrics: Option, + current_turn_metrics: Option, + current_thread_status: Option<&CodexThreadStatus>, +) -> bool { + let Some(_task_id) = task_id else { + return false; + }; + let Some(_session_id) = session_id else { + return false; + }; + let Some(initial_turn_metrics) = initial_turn_metrics else { + return false; + }; + let Some(current_turn_metrics) = current_turn_metrics else { + return false; + }; + + provider == multicode_lib::services::AgentProvider::Codex + && attached_workspace_key == Some(workspace_key) + && initial_turn_metrics.completed == 0 + && initial_turn_metrics.aborted > 0 + && current_turn_metrics == initial_turn_metrics + && matches!( + current_thread_status, + Some(CodexThreadStatus::NotLoaded | CodexThreadStatus::SystemError) + ) +} + fn task_can_yield_vm_for_attach(task_state: &WorkspaceTaskRuntimeSnapshot) -> bool { matches!( task_effective_agent_state(Some(task_state)), @@ -1396,11 +1491,22 @@ impl TuiState { .snapshots .get(key) .ok_or_else(|| io::Error::other(format!("workspace snapshot missing for '{key}'")))?; + if self.service.agent_provider() == multicode_lib::services::AgentProvider::Codex { + let cwd = self + .attach_cwd_for_workspace(key) + .map(|path| path.to_string_lossy().into_owned()); + if let Some(target) = + working_codex_task_attach_target(snapshot, self.selected_task_id(), cwd)? + { + return Ok(target); + } + } snapshot_attach_target_for_selection(snapshot, self.selected_task_id()) } fn record_attached_session(&mut self, key: &str, target: &AttachTarget) { let task_id = self.selected_task_id().map(str::to_string); + let fresh_codex_session = matches!(target, AttachTarget::CodexNew { .. }); let initial_agent_state = self.snapshots.get(key).and_then(|snapshot| { task_id .as_deref() @@ -1417,16 +1523,21 @@ impl TuiState { let session_id = match target { AttachTarget::Opencode { session_id, .. } => session_id.clone(), AttachTarget::Codex { thread_id, .. } => thread_id.clone(), + AttachTarget::CodexNew { .. } => None, }; let initial_turn_metrics = if self.service.agent_provider() == multicode_lib::services::AgentProvider::Codex { - session_id.as_deref().and_then(|session_id| { - read_codex_session_turn_metrics( - self.service.workspace_directory_path(), - key, - session_id, - ) - }) + if fresh_codex_session && task_id.is_some() { + Some(CodexSessionTurnMetrics::default()) + } else { + session_id.as_deref().and_then(|session_id| { + read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + key, + session_id, + ) + }) + } } else { None }; @@ -1444,6 +1555,7 @@ impl TuiState { session_id, initial_agent_state, initial_turn_metrics, + fresh_codex_session, }); } @@ -1675,7 +1787,15 @@ impl TuiState { .await { Ok(_) => { - self.handle_attach_exit(&key).await; + if !self + .retry_codex_task_attach_with_fresh_session(terminal, &key) + .await + && !self + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await + { + self.handle_attach_exit(&key).await; + } } Err(err) => { tracing::warn!( @@ -1683,7 +1803,14 @@ impl TuiState { error = %err, "attach session exited with error" ); - if !self.handle_attach_exit_after_error(&key, &err).await { + if !self + .retry_codex_task_attach_with_fresh_session(terminal, &key) + .await + && !self + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await + && !self.handle_attach_exit_after_error(&key, &err).await + { self.status = format!("Failed to attach to workspace '{key}': {err}"); } } @@ -1721,6 +1848,196 @@ impl TuiState { false } + async fn retry_codex_task_attach_with_last_thread( + &mut self, + terminal: &mut Terminal>, + key: &str, + ) -> bool { + let attached_session = self.attached_session.clone(); + let Some(target) = should_retry_codex_task_attach_with_last_thread( + self.service.agent_provider(), + self.snapshots.get(key), + key, + attached_session + .as_ref() + .map(|attached| attached.workspace_key.as_str()), + attached_session + .as_ref() + .and_then(|attached| attached.task_id.as_deref()), + attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()), + ) else { + return false; + }; + + let previous_session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + .unwrap_or(""); + let custom_description = self + .snapshots + .get(key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + tracing::info!( + workspace_key = %key, + previous_session_id, + "retrying codex task attach with last thread after explicit thread exited" + ); + self.record_attached_session(key, &target); + match attach_in_tmux( + terminal, + self.service.agent_command(), + &target, + self.attach_cwd_for_workspace(key).as_deref(), + &self.attach_env_for_workspace(key), + key, + &custom_description, + ) + .await + { + Ok(_) => { + self.handle_attach_exit(key).await; + } + Err(err) => { + tracing::warn!( + workspace_key = %key, + error = %err, + "codex retry attach session exited with error" + ); + if !self.handle_attach_exit_after_error(key, &err).await { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + true + } + + async fn retry_codex_task_attach_with_fresh_session( + &mut self, + terminal: &mut Terminal>, + key: &str, + ) -> bool { + let attached_session = self.attached_session.clone(); + let session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()); + let current_turn_metrics = session_id.and_then(|session_id| { + read_codex_session_turn_metrics( + self.service.workspace_directory_path(), + key, + session_id, + ) + }); + let current_thread_status = match ( + self.snapshots.get(key).and_then(|snapshot| snapshot.transient.as_ref()), + session_id, + ) { + (Some(transient), Some(session_id)) => CodexAppServerClient::new(transient.uri.clone()) + .thread_read(session_id) + .await + .ok() + .and_then(|response| response.thread.status), + _ => None, + }; + let should_start_fresh = should_start_fresh_codex_task_session_after_failed_attach( + self.service.agent_provider(), + attached_session + .as_ref() + .map(|attached| attached.workspace_key.as_str()), + key, + attached_session + .as_ref() + .and_then(|attached| attached.task_id.as_deref()), + session_id, + attached_session + .as_ref() + .and_then(|attached| attached.initial_turn_metrics), + current_turn_metrics, + current_thread_status.as_ref(), + ); + tracing::info!( + workspace_key = %key, + session_id = session_id.unwrap_or(""), + initial_turn_metrics = ?attached_session + .as_ref() + .and_then(|attached| attached.initial_turn_metrics), + current_turn_metrics = ?current_turn_metrics, + current_thread_status = ?current_thread_status, + should_start_fresh, + "evaluated fresh codex task session fallback after failed attach" + ); + if !should_start_fresh { + return false; + } + + let Some(snapshot) = self.snapshots.get(key) else { + return false; + }; + let Ok(Some(uri)) = codex_attach_uri(snapshot) else { + return false; + }; + let cwd = self + .attach_cwd_for_workspace(key) + .map(|path| path.to_string_lossy().into_owned()); + let prompt = if let Some(session_id) = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + { + read_interrupted_codex_resume_prompt( + self.service.workspace_directory_path().to_path_buf(), + key.to_string(), + session_id.to_string(), + ) + .await + .or_else(|| Some("Continue work on this task.".to_string())) + } else { + Some("Continue work on this task.".to_string()) + }; + let target = AttachTarget::CodexNew { uri, cwd, prompt }; + let custom_description = self + .snapshots + .get(key) + .map(|snapshot| snapshot.persistent.description.clone()) + .unwrap_or_default(); + tracing::info!( + workspace_key = %key, + previous_session_id = attached_session + .as_ref() + .and_then(|attached| attached.session_id.as_deref()) + .unwrap_or(""), + "starting fresh codex task session after failed resume attach" + ); + self.record_attached_session(key, &target); + match attach_in_tmux( + terminal, + self.service.agent_command(), + &target, + self.attach_cwd_for_workspace(key).as_deref(), + &self.attach_env_for_workspace(key), + key, + &custom_description, + ) + .await + { + Ok(_) => { + self.handle_attach_exit(key).await; + } + Err(err) => { + tracing::warn!( + workspace_key = %key, + error = %err, + "fresh codex attach session exited with error" + ); + if !self.handle_attach_exit_after_error(key, &err).await { + self.status = format!("Failed to attach to workspace '{key}': {err}"); + } + } + } + true + } + fn mark_task_resuming_in_background(&mut self, workspace_key: &str, task_id: &str) { let Ok(workspace) = self.service.manager.get_workspace(workspace_key) else { return; @@ -1976,11 +2293,35 @@ impl TuiState { return false; }; + let effective_task_session_id = match attached_session.as_ref() { + Some(AttachedSession { + workspace_key, + task_id: Some(task_id), + session_id, + fresh_codex_session, + .. + }) if workspace_key == key => session_id.clone().or_else(|| { + if *fresh_codex_session { + task_runtime_snapshot(&snapshot, task_id).and_then(|task_state| { + task_state.session_id.clone() + }) + } else { + None + } + }), + _ => None, + }; + let resume_attached_session = attached_session.clone().map(|mut attached| { + if attached.fresh_codex_session && attached.session_id.is_none() { + attached.session_id = effective_task_session_id.clone(); + } + attached + }); + let interrupted_resume_prompt = match attached_session.as_ref() { Some(AttachedSession { workspace_key, task_id: Some(_), - session_id: Some(session_id), initial_turn_metrics, .. }) if workspace_key == key => { @@ -1988,17 +2329,21 @@ impl TuiState { .should_resume_interrupted_task_codex_after_attach( &snapshot, workspace_key, - Some(session_id.as_str()), + effective_task_session_id.as_deref(), *initial_turn_metrics, ) .await; if should_resume_interrupted { - read_interrupted_codex_resume_prompt( - self.service.workspace_directory_path().to_path_buf(), - workspace_key.clone(), - session_id.clone(), - ) - .await + if let Some(session_id) = effective_task_session_id.clone() { + read_interrupted_codex_resume_prompt( + self.service.workspace_directory_path().to_path_buf(), + workspace_key.clone(), + session_id, + ) + .await + } else { + None + } } else { None } @@ -2010,13 +2355,12 @@ impl TuiState { Some(AttachedSession { workspace_key, task_id: Some(task_id), - session_id, initial_agent_state, .. }) if workspace_key == key => { should_auto_resume_task_codex_after_attach( task_runtime_snapshot(&snapshot, task_id), - session_id.as_deref(), + effective_task_session_id.as_deref(), *initial_agent_state, ) || interrupted_resume_prompt.is_some() } @@ -2047,7 +2391,7 @@ impl TuiState { self.queue_task_codex_resume_until_vm_available( key.to_string(), task_id.clone(), - attached_session + resume_attached_session .clone() .expect("attached task session should exist"), interrupted_resume_prompt.clone(), @@ -2064,7 +2408,7 @@ impl TuiState { let service = self.service.clone(); let workspace_key = key.to_string(); let snapshot_for_resume = snapshot.clone(); - let attached_session_for_resume = attached_session.clone(); + let attached_session_for_resume = resume_attached_session.clone(); tokio::spawn(async move { let resume_result = match attached_session_for_resume.as_ref() { Some(AttachedSession { @@ -2917,7 +3261,19 @@ impl TuiState { .await { Ok(_) => { - self.handle_attach_exit(&key).await; + if !self + .retry_codex_task_attach_with_fresh_session( + terminal, &key, + ) + .await + && !self + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await + { + self.handle_attach_exit(&key).await; + } } Err(err) => { tracing::warn!( @@ -2926,7 +3282,17 @@ impl TuiState { "attach session exited with error" ); if !self - .handle_attach_exit_after_error(&key, &err) + .retry_codex_task_attach_with_fresh_session( + terminal, &key, + ) + .await + && !self + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await + && !self + .handle_attach_exit_after_error(&key, &err) .await { self.status = format!( @@ -3607,7 +3973,9 @@ pub(crate) fn snapshot_attach_target_for_selection( && (matches!( task_effective_agent_state(task_state), Some(AutomationAgentState::Stale) - ) || snapshot.persistent.automation_paused + ) || task_state.is_some_and(|task_state| { + matches!(task_state.status.as_deref(), Some("NotLoaded")) + }) || snapshot.persistent.automation_paused && task_state .and_then(|task_state| task_state.session_id.as_deref()) .is_none()); diff --git a/tui/src/main.rs b/tui/src/main.rs index f39d66f..ab1c3ec 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -199,6 +199,7 @@ struct AttachedSession { session_id: Option, initial_agent_state: Option, initial_turn_metrics: Option, + fresh_codex_session: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -346,6 +347,11 @@ enum AttachTarget { uri: String, thread_id: Option, }, + CodexNew { + uri: String, + cwd: Option, + prompt: Option, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/tui/src/ops.rs b/tui/src/ops.rs index d045b22..2c71bd4 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -360,6 +360,17 @@ pub(crate) fn attach_cli_args(agent_command: &str, target: &AttachTarget) -> Vec } args } + AttachTarget::CodexNew { uri, cwd, prompt } => { + let mut args = vec![agent_command.to_string(), "--remote".to_string(), uri.clone()]; + if let Some(cwd) = cwd.as_deref() { + args.push("-C".to_string()); + args.push(cwd.to_string()); + } + if let Some(prompt) = prompt.as_deref() { + args.push(prompt.to_string()); + } + args + } } } diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 042ead4..a20281c 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -16,10 +16,13 @@ mod tests { restored_selected_row, shell_command_in_repo, should_auto_resume_autonomous_codex_after_attach, should_auto_resume_task_codex_after_attach, + should_start_fresh_codex_task_session_after_failed_attach, + should_retry_codex_task_attach_with_last_thread, should_queue_task_codex_resume_until_vm_available, should_restart_codex_task_for_pr_request, should_resume_codex_task_after_incomplete_attached_turn, snapshot_attach_cwd_for_selection, snapshot_attach_target_for_selection, starting_modal_failure_status, + working_codex_task_attach_target, }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, @@ -39,7 +42,7 @@ mod tests { }; use multicode_lib::{ PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, - TransientWorkspaceSnapshot, + TransientWorkspaceSnapshot, services::AgentProvider, }; use std::{ fs, @@ -991,6 +994,186 @@ mod tests { ); } + #[test] + fn snapshot_attach_target_for_selection_uses_last_codex_thread_when_task_is_not_loaded() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-missing".to_string()), + agent_state: Some(AutomationAgentState::Review), + status: Some("NotLoaded".to_string()), + ..Default::default() + }, + ); + + let target = snapshot_attach_target_for_selection(&started, Some("task-42")) + .expect("not-loaded codex task should attach via last thread"); + + assert_eq!( + target, + AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + } + ); + } + + #[test] + fn should_retry_codex_task_attach_with_last_thread_for_not_loaded_task() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-stale".to_string()), + agent_state: Some(AutomationAgentState::Review), + status: Some("NotLoaded".to_string()), + ..Default::default() + }, + ); + + let target = should_retry_codex_task_attach_with_last_thread( + AgentProvider::Codex, + Some(&started), + "ws", + Some("ws"), + Some("task-42"), + Some("thread-stale"), + ); + + assert_eq!( + target, + Some(AttachTarget::Codex { + uri: "ws://127.0.0.1:3456/".to_string(), + thread_id: None, + }) + ); + } + + #[test] + fn should_not_retry_codex_task_attach_without_explicit_thread() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + status: Some("NotLoaded".to_string()), + ..Default::default() + }, + ); + + let target = should_retry_codex_task_attach_with_last_thread( + AgentProvider::Codex, + Some(&started), + "ws", + Some("ws"), + Some("task-42"), + None, + ); + + assert_eq!(target, None); + } + + #[test] + fn should_start_fresh_codex_task_session_after_failed_attach_for_aborted_only_thread() { + assert!(should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + + #[test] + fn should_not_start_fresh_codex_task_session_when_attach_changed_thread_metrics() { + assert!(!should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 1, + }), + Some(CodexSessionTurnMetrics { + started: 5, + completed: 0, + aborted: 1, + }), + Some(&CodexThreadStatus::NotLoaded), + )); + } + + #[test] + fn should_not_start_fresh_codex_task_session_when_thread_is_still_idle() { + assert!(!should_start_fresh_codex_task_session_after_failed_attach( + AgentProvider::Codex, + Some("ws"), + "ws", + Some("task-42"), + Some("thread-42"), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(CodexSessionTurnMetrics { + started: 4, + completed: 0, + aborted: 4, + }), + Some(&CodexThreadStatus::Idle), + )); + } + + #[test] + fn working_codex_task_attach_target_uses_fresh_observer_session() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); + started.task_states.insert( + "task-42".to_string(), + multicode_lib::WorkspaceTaskRuntimeSnapshot { + session_id: Some("thread-42".to_string()), + session_status: Some(RootSessionStatus::Busy), + agent_state: Some(AutomationAgentState::Working), + ..Default::default() + }, + ); + + let target = working_codex_task_attach_target( + &started, + Some("task-42"), + Some("/tmp/task-42".to_string()), + ) + .expect("working codex task attach target should be computed"); + + assert_eq!( + target, + Some(AttachTarget::CodexNew { + uri: "ws://127.0.0.1:3456/".to_string(), + cwd: Some("/tmp/task-42".to_string()), + prompt: Some("Another Codex task session is already actively working on task-42. You are attached only for observation and user-directed inspection. Do not make repository changes, do not start duplicate work, and do not send autonomous follow-up prompts to the active task. Briefly confirm you are attached and then wait for the user.".to_string()), + }) + ); + } + #[test] fn snapshot_attach_target_for_selection_prefers_task_attach_when_task_session_exists() { let mut started = snapshot(true, Some("http://opencode:secret@127.0.0.1:3000/")); @@ -1056,6 +1239,27 @@ mod tests { ); } + #[test] + fn attach_cli_args_start_fresh_remote_codex_session_in_task_checkout() { + let target = AttachTarget::CodexNew { + uri: "ws://127.0.0.1:3456".to_string(), + cwd: Some("/tmp/task".to_string()), + prompt: Some("Continue work on this task.".to_string()), + }; + + assert_eq!( + attach_cli_args("codex", &target), + vec![ + "codex".to_string(), + "--remote".to_string(), + "ws://127.0.0.1:3456".to_string(), + "-C".to_string(), + "/tmp/task".to_string(), + "Continue work on this task.".to_string(), + ] + ); + } + #[test] fn tmux_session_command_restores_original_term_inside_session() { let command = vec!["opencode".to_string(), "attach".to_string()]; From b8257173546e34541b75f4be0415b3d805967ed7 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 11:27:52 +0200 Subject: [PATCH 65/75] Preserve Codex permissions after task attach Co-Authored-By: OpenAI Codex --- tui/src/app.rs | 100 +++++++++++++++++++++++++++++++++-------------- tui/src/ops.rs | 6 ++- tui/src/tests.rs | 29 +++++++++++--- 3 files changed, 99 insertions(+), 36 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index b183aa6..2e5e7b5 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -358,6 +358,13 @@ pub(crate) fn should_auto_resume_task_codex_after_attach( } } +pub(crate) fn should_restart_task_codex_after_attach( + attached_session_id: Option<&str>, + fresh_codex_session: bool, +) -> bool { + attached_session_id.is_some() && !fresh_codex_session +} + fn codex_observer_attach_prompt(task_id: &str) -> String { format!( "Another Codex task session is already actively working on {task_id}. You are attached only for observation and user-directed inspection. Do not make repository changes, do not start duplicate work, and do not send autonomous follow-up prompts to the active task. Briefly confirm you are attached and then wait for the user." @@ -1791,8 +1798,8 @@ impl TuiState { .retry_codex_task_attach_with_fresh_session(terminal, &key) .await && !self - .retry_codex_task_attach_with_last_thread(terminal, &key) - .await + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await { self.handle_attach_exit(&key).await; } @@ -1807,8 +1814,8 @@ impl TuiState { .retry_codex_task_attach_with_fresh_session(terminal, &key) .await && !self - .retry_codex_task_attach_with_last_thread(terminal, &key) - .await + .retry_codex_task_attach_with_last_thread(terminal, &key) + .await && !self.handle_attach_exit_after_error(&key, &err).await { self.status = format!("Failed to attach to workspace '{key}': {err}"); @@ -1931,7 +1938,9 @@ impl TuiState { ) }); let current_thread_status = match ( - self.snapshots.get(key).and_then(|snapshot| snapshot.transient.as_ref()), + self.snapshots + .get(key) + .and_then(|snapshot| snapshot.transient.as_ref()), session_id, ) { (Some(transient), Some(session_id)) => CodexAppServerClient::new(transient.uri.clone()) @@ -2221,7 +2230,7 @@ impl TuiState { let resume_prompt = read_last_codex_session_user_message( service.workspace_directory_path().to_path_buf(), workspace_key.clone(), - session_id, + session_id.clone(), ) .await .unwrap_or_else(|| CODEX_AUTO_RESUME_PROMPT.to_string()); @@ -2240,9 +2249,28 @@ impl TuiState { } }); } - service - .prompt_task_session(&workspace_key, &snapshot, &task_id, &resume_prompt) - .await + if should_restart_task_codex_after_attach( + Some(session_id.as_str()), + attached_session.fresh_codex_session, + ) { + service + .restart_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } } else { service .prompt_task_session( @@ -2302,9 +2330,8 @@ impl TuiState { .. }) if workspace_key == key => session_id.clone().or_else(|| { if *fresh_codex_session { - task_runtime_snapshot(&snapshot, task_id).and_then(|task_state| { - task_state.session_id.clone() - }) + task_runtime_snapshot(&snapshot, task_id) + .and_then(|task_state| task_state.session_id.clone()) } else { None } @@ -2415,6 +2442,7 @@ impl TuiState { workspace_key: attached_workspace_key, task_id: Some(task_id), session_id: Some(session_id), + fresh_codex_session, .. }) if attached_workspace_key == &workspace_key => { if let Some(resume_prompt) = interrupted_resume_prompt.clone() { @@ -2476,14 +2504,28 @@ impl TuiState { } }); } - service - .prompt_task_session( - &workspace_key, - &snapshot_for_resume, - task_id, - &resume_prompt, - ) - .await + if should_restart_task_codex_after_attach( + Some(session_id.as_str()), + *fresh_codex_session, + ) { + service + .restart_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot_for_resume, + task_id, + &resume_prompt, + ) + .await + } } } _ => { @@ -3267,10 +3309,10 @@ impl TuiState { ) .await && !self - .retry_codex_task_attach_with_last_thread( - terminal, &key, - ) - .await + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await { self.handle_attach_exit(&key).await; } @@ -3287,13 +3329,13 @@ impl TuiState { ) .await && !self - .retry_codex_task_attach_with_last_thread( - terminal, &key, - ) - .await + .retry_codex_task_attach_with_last_thread( + terminal, &key, + ) + .await && !self .handle_attach_exit_after_error(&key, &err) - .await + .await { self.status = format!( "Failed to attach to workspace '{key}': {err}" diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 2c71bd4..fb0d2b3 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -361,7 +361,11 @@ pub(crate) fn attach_cli_args(agent_command: &str, target: &AttachTarget) -> Vec args } AttachTarget::CodexNew { uri, cwd, prompt } => { - let mut args = vec![agent_command.to_string(), "--remote".to_string(), uri.clone()]; + let mut args = vec![ + agent_command.to_string(), + "--remote".to_string(), + uri.clone(), + ]; if let Some(cwd) = cwd.as_deref() { args.push("-C".to_string()); args.push(cwd.to_string()); diff --git a/tui/src/tests.rs b/tui/src/tests.rs index a20281c..51571c0 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -16,13 +16,13 @@ mod tests { restored_selected_row, shell_command_in_repo, should_auto_resume_autonomous_codex_after_attach, should_auto_resume_task_codex_after_attach, - should_start_fresh_codex_task_session_after_failed_attach, - should_retry_codex_task_attach_with_last_thread, should_queue_task_codex_resume_until_vm_available, - should_restart_codex_task_for_pr_request, - should_resume_codex_task_after_incomplete_attached_turn, snapshot_attach_cwd_for_selection, - snapshot_attach_target_for_selection, starting_modal_failure_status, - working_codex_task_attach_target, + should_restart_codex_task_for_pr_request, should_restart_task_codex_after_attach, + should_resume_codex_task_after_incomplete_attached_turn, + should_retry_codex_task_attach_with_last_thread, + should_start_fresh_codex_task_session_after_failed_attach, + snapshot_attach_cwd_for_selection, snapshot_attach_target_for_selection, + starting_modal_failure_status, working_codex_task_attach_target, }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, @@ -594,6 +594,23 @@ mod tests { )); } + #[test] + fn direct_task_attach_restarts_background_codex_session_after_detach() { + assert!(should_restart_task_codex_after_attach( + Some("thread-4"), + false + )); + } + + #[test] + fn fresh_task_attach_keeps_existing_background_codex_session() { + assert!(!should_restart_task_codex_after_attach( + Some("thread-4"), + true + )); + assert!(!should_restart_task_codex_after_attach(None, true)); + } + #[test] fn detached_task_resume_queues_when_another_task_owns_vm() { let mut snapshot = multicode_lib::WorkspaceSnapshot::default(); From af726070d6e395c504a754e36061c52b2ae0bc2f Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 11:51:47 +0200 Subject: [PATCH 66/75] Batch Renovate dependency upgrades Co-Authored-By: OpenAI Codex --- .../services/autonomous_workspace_service.rs | 520 +++++++++++------- 1 file changed, 333 insertions(+), 187 deletions(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 88634ff..cbdd2e1 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -45,8 +45,10 @@ const DEPENDENCY_UPGRADE_LABEL: &str = "type: dependency-upgrade"; const NON_MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 4] = ["minor", "patch", "pin", "digest"]; const MAJOR_DEPENDENCY_UPGRADE_LABELS: [&str; 1] = ["major"]; const RENOVATE_LOGINS: [&str; 2] = ["renovate[bot]", "app/renovate"]; -const DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = "Dependency upgrade follow-up for PR #"; +const DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX: &str = + "Dependency upgrade follow-up for Renovate batch"; const DEPENDENCY_UPGRADE_PR_MARKER_PREFIX: &str = "", - pr_url = pr.url, - marker = DEPENDENCY_UPGRADE_PR_MARKER_PREFIX + "Track dependency-upgrade automation for the current batch of Renovate pull requests.\n\n\ +Current Renovate candidates:\n\ +{candidate_list}\n\n\ +This issue was created automatically by multicode to process the Renovate batch.\n\ +Create or update a single combined dependency-upgrade pull request associated with this issue, keep CI passing for that combined PR, and merge only the combined PR once it is green. Do not merge the individual Renovate PRs directly.\n\n\ +{marker_prefix}{marker} -->", + marker_prefix = DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX ) } -fn dependency_upgrade_issue_search_queries(pr: &SelectedPullRequest) -> [String; 2] { - let pr_search_fragment = pr - .url - .strip_prefix("https://github.com/") - .unwrap_or(pr.url.as_str()); - [ - format!( - "\"{DEPENDENCY_UPGRADE_ISSUE_TITLE_PREFIX}{}\" in:title", - pr.number - ), - format!("\"{pr_search_fragment}\" in:body"), - ] +fn dependency_upgrade_pr_urls_from_pull_requests(prs: &[SelectedPullRequest]) -> Vec { + prs.iter().map(|pr| pr.url.clone()).collect() } async fn assign_issue_to_me( @@ -4144,6 +4191,41 @@ async fn add_issue_label( } } +async fn edit_issue_title_and_body( + assigned_repository: &str, + issue_url: &str, + title: &str, + body: &str, + token: &str, +) -> Result<(), String> { + let mut command = Command::new(gh_program()); + apply_gh_env(&mut command, token); + let output = command + .args([ + "issue", + "edit", + issue_url, + "--repo", + assigned_repository, + "--title", + title, + "--body", + body, + ]) + .output() + .await + .map_err(|err| format!("failed to run gh issue edit for {issue_url}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "gh issue edit failed for {issue_url}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + async fn remove_issue_label( assigned_repository: &str, issue_url: &str, @@ -4356,7 +4438,7 @@ struct SelectedIssue { body: Option, labels: Vec, #[serde(skip)] - dependency_upgrade_pr_url: Option, + dependency_upgrade_pr_urls: Vec, } impl SelectedIssue { @@ -4394,12 +4476,20 @@ impl SelectedIssue { && self.is_pull_request != Some(true) } - fn backing_pr_url(&self) -> Option<&str> { - self.dependency_upgrade_pr_url.as_deref().or_else(|| { - self.body - .as_deref() - .and_then(extract_dependency_upgrade_pr_marker) - }) + fn dependency_upgrade_pr_urls(&self) -> Vec { + if !self.dependency_upgrade_pr_urls.is_empty() { + return self.dependency_upgrade_pr_urls.clone(); + } + self.body + .as_deref() + .map(extract_dependency_upgrade_pr_urls) + .unwrap_or_default() + } + + fn legacy_dependency_upgrade_pr_url(&self) -> Option<&str> { + self.body + .as_deref() + .and_then(extract_dependency_upgrade_pr_marker) } } @@ -4497,6 +4587,24 @@ fn extract_dependency_upgrade_pr_marker(body: &str) -> Option<&str> { (!value.is_empty()).then_some(value) } +fn extract_dependency_upgrade_pr_urls(body: &str) -> Vec { + if let Some(marker_start) = body.find(DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX) { + let content_start = marker_start + DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX.len(); + let content_end = body[content_start..] + .find("-->") + .map(|index| content_start + index) + .unwrap_or(body.len()); + let value = body[content_start..content_end].trim(); + if let Ok(pr_urls) = serde_json::from_str::>(value) { + return pr_urls; + } + } + + extract_dependency_upgrade_pr_marker(body) + .map(|url| vec![url.to_string()]) + .unwrap_or_default() +} + fn dependency_upgrade_versions_from_text(text: &str) -> Option<(u64, u64)> { dependency_upgrade_versions_from_from_to_text(text) .or_else(|| dependency_upgrade_versions_from_arrow_text(text)) @@ -4876,7 +4984,7 @@ mod tests { is_pull_request: Some(false), body: None, labels, - dependency_upgrade_pr_url: None, + dependency_upgrade_pr_urls: Vec::new(), } } @@ -7146,8 +7254,8 @@ mod tests { } #[test] - fn build_issue_prompt_for_dependency_upgrade_allows_direct_merge() { - let issue = test_issue( + fn build_issue_prompt_for_dependency_upgrade_requires_combined_pr() { + let mut issue = test_issue( 981, "dependency upgrade", "https://github.com/example/repo/issues/981", @@ -7156,22 +7264,27 @@ mod tests { name: DEPENDENCY_UPGRADE_LABEL.to_string(), }], ); + issue.dependency_upgrade_pr_urls = vec![ + "https://github.com/example/repo/pull/88".to_string(), + "https://github.com/example/repo/pull/91".to_string(), + ]; let prompt = build_issue_prompt( "example/repo", &issue, - Some("https://github.com/example/repo/pull/88"), + Some("https://github.com/example/repo/pull/999"), "thread-task-981", std::path::Path::new("/tmp/work/example-repo-981"), std::path::Path::new("/tmp/state/task-981.state"), ); - assert!( - prompt.contains( - "backed by Renovate pull request https://github.com/example/repo/pull/88" - ) - ); - assert!(prompt.contains("merge it without waiting for human review")); + assert!(prompt.contains("tracks the following Renovate pull requests")); + assert!(prompt.contains("https://github.com/example/repo/pull/88")); + assert!(prompt.contains("https://github.com/example/repo/pull/91")); + assert!(prompt.contains( + "Treat https://github.com/example/repo/pull/999 as the single combined dependency-upgrade PR" + )); + assert!(prompt.contains("Do not merge the individual Renovate PRs directly.")); assert!(prompt.contains("close GitHub issue https://github.com/example/repo/issues/981")); assert!(prompt.contains( "Do not leave placeholder comments, placeholder reviews, or dummy approvals" @@ -7181,23 +7294,34 @@ mod tests { #[test] fn dependency_upgrade_issue_body_uses_updated_queue_text() { - let body = dependency_upgrade_issue_body(&test_pull_request( - 91, - "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", - "https://github.com/example/repo/pull/91", - vec![SelectedIssueLabel { - name: DEPENDENCY_UPGRADE_LABEL.to_string(), - }], - None, - )); + let body = dependency_upgrade_issue_body(&[ + test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 92, + "Update dependency io.micronaut:micronaut-json-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/92", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); - assert!( - body.contains("This issue was created automatically by multicode to prrocess the PR.") - ); + assert!(body.contains("Current Renovate candidates:")); + assert!(body.contains("https://github.com/example/repo/pull/91")); + assert!(body.contains("https://github.com/example/repo/pull/92")); assert!(body.contains( - "If the update is still a non-major version bump and CI is passing, rebase and merge the PR without waiting for human review, then close this issue." + "Create or update a single combined dependency-upgrade pull request associated with this issue" )); - assert!(!body.contains("so the autonomous queue can process the PR")); + assert!(body.contains("Do not merge the individual Renovate PRs directly.")); } #[test] @@ -7300,41 +7424,63 @@ mod tests { } #[test] - fn extract_dependency_upgrade_pr_marker_reads_hidden_comment() { - let body = dependency_upgrade_issue_body(&test_pull_request( - 91, - "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", - "https://github.com/example/repo/pull/91", - vec![SelectedIssueLabel { - name: DEPENDENCY_UPGRADE_LABEL.to_string(), - }], - None, - )); + fn extract_dependency_upgrade_pr_urls_reads_hidden_comment() { + let body = dependency_upgrade_issue_body(&[ + test_pull_request( + 91, + "Update dependency io.micronaut:micronaut-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/91", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 92, + "Update dependency io.micronaut:micronaut-json-core from 4.4.1 to 4.4.2", + "https://github.com/example/repo/pull/92", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); assert_eq!( - extract_dependency_upgrade_pr_marker(&body), - Some("https://github.com/example/repo/pull/91") + extract_dependency_upgrade_pr_urls(&body), + vec![ + "https://github.com/example/repo/pull/91".to_string(), + "https://github.com/example/repo/pull/92".to_string() + ] ); } #[test] - fn dependency_upgrade_issue_search_queries_use_searchable_pr_url_fragment() { - let queries = dependency_upgrade_issue_search_queries(&test_pull_request( - 728, - "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", - "https://github.com/micronaut-projects/micronaut-redis/pull/728", - vec![SelectedIssueLabel { - name: DEPENDENCY_UPGRADE_LABEL.to_string(), - }], - None, - )); + fn dependency_upgrade_issue_title_includes_batch_size() { + let queries = dependency_upgrade_issue_title(&[ + test_pull_request( + 728, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/micronaut-projects/micronaut-redis/pull/728", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + test_pull_request( + 729, + "fix(deps): update dependency io.micronaut.redis:micronaut-redis-lettuce to v6.6.0", + "https://github.com/micronaut-projects/micronaut-redis/pull/729", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + ), + ]); assert_eq!( queries, - [ - "\"Dependency upgrade follow-up for PR #728\" in:title".to_string(), - "\"micronaut-projects/micronaut-redis/pull/728\" in:body".to_string(), - ] + "Dependency upgrade follow-up for Renovate batch (2 PRs)".to_string() ); } From 934b306a46a8b580ef73f4d85d4ae8388143a400 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 12:35:52 +0200 Subject: [PATCH 67/75] Add Codex CI fix shortcut Co-Authored-By: OpenAI Codex --- lib/src/services/config.rs | 2 +- tui/src/app.rs | 161 +++++++++++++++++++++++++++++++++++++ tui/src/main.rs | 4 + tui/src/render.rs | 1 + tui/src/tests.rs | 153 ++++++++++++++++++++++++++++++++++- 5 files changed, 319 insertions(+), 2 deletions(-) diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 250dcf2..9624c22 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -579,7 +579,7 @@ pub(super) fn validate_tool_config_entries( tools: &[ToolConfig], ) -> Result<(), CombinedServiceError> { let mut seen_keys = HashSet::new(); - let reserved = ['q', 'a', 'd', 's']; + let reserved = ['q', 'a', 'd', 'f', 's']; for (index, tool) in tools.iter().enumerate() { if tool.name.trim().is_empty() { diff --git a/tui/src/app.rs b/tui/src/app.rs index 2e5e7b5..2a5260a 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -10,6 +10,7 @@ use std::os::unix::fs::FileTypeExt; const NERD_FONT_GITHUB_GLYPH: &str = "\u{f408}"; const CODEX_AUTO_RESUME_PROMPT: &str = "Continue autonomously from where you left off. Do not wait for approval for repository commands, builds, Gradle tasks, or focused tests. Only stop to ask before committing, pushing, commenting on GitHub, or opening or updating a pull request."; const CODEX_CREATE_PR_APPROVAL_PROMPT: &str = "The local changes for this task are approved for publishing. Create or update the pull request now from this task checkout. Push the branch if needed, use the correct upstream base branch, include an appropriate type label such as `type: docs` for documentation-only changes, `type: bug` for bug fixes, `type: improvement` for minor improvements, or `type: enhancement` for broader enhancements, assign the pull request to yourself, assign it automatically to the next Micronaut project release at the organization level under https://github.com/orgs/micronaut-projects/projects, prefer the next semantically versioned release project that is typically suffixed with a milestone such as `5.0.0-M2` and otherwise suffixed with `Release` such as `5.0.0 Release`, request Copilot review, emit the link, and stop once the PR is ready for human review. If a PR already exists, update it instead of creating a duplicate. Do not merge the PR."; +const CODEX_FIX_CI_PROMPT: &str = "Use the existing pull request for this issue and fix any failing CI checks, including Sonar failures. Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior. Push fixes as needed, monitor the CI after each push, and continue addressing failures until the pull request is green. Stop only when all CI is passing or you need human input."; pub(crate) fn repository_diff_shell_command() -> &'static str { r#"tmp="$(mktemp -t multicode-diff.XXXXXX)" || exit 1 @@ -221,6 +222,22 @@ pub(crate) fn should_restart_codex_task_for_pr_request( matches!(task_state.status.as_deref(), Some("NotLoaded")) } +pub(crate) fn should_offer_codex_ci_fix( + has_pr_link: bool, + pr_status: Option, +) -> bool { + has_pr_link + && match pr_status { + Some(GithubPrStatus { + state: GithubPrState::Open, + build, + .. + }) => build != GithubPrBuildState::Succeeded, + Some(_) => false, + None => true, + } +} + pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { let url = Url::parse(target).ok()?; if url.host_str()? != "github.com" { @@ -1613,6 +1630,34 @@ impl TuiState { && task_persistent_snapshot(snapshot, task_id).is_some() } + fn selected_task_pr_link(&self) -> Option { + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + Some(task_pr_link(task, task_runtime_snapshot(snapshot, task_id))?.to_string()) + } + + fn selected_task_pr_status(&self) -> Option { + let pr_link = self.selected_task_pr_link()?; + let link = WorkspaceLink { + kind: WorkspaceLinkKind::Pr, + value: pr_link, + source: WorkspaceLinkSource::Task, + }; + match self.github_link_statuses.get(&link) { + Some(GithubLinkStatusView::Pr(pr_status)) => Some(*pr_status), + _ => None, + } + } + + pub(crate) fn selected_task_can_request_ci_fix(&self) -> bool { + self.selected_task_can_request_pr_creation() + && should_offer_codex_ci_fix( + self.selected_task_pr_link().is_some(), + self.selected_task_pr_status(), + ) + } + async fn approve_selected_task_for_pr_creation(&mut self) { if !self.selected_task_can_request_pr_creation() { return; @@ -1736,6 +1781,113 @@ impl TuiState { ); } + async fn fix_selected_task_ci(&mut self) { + if !self.selected_task_can_request_pr_creation() { + return; + } + let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + return; + }; + let Some(task_id) = self.selected_task_id().map(str::to_string) else { + return; + }; + let Some(snapshot) = self.snapshots.get(&workspace_key).cloned() else { + return; + }; + let previous_snapshot = snapshot.clone(); + let (progress_tx, progress_rx) = watch::channel("Preparing CI fix request...".to_string()); + let (result_tx, result_rx) = oneshot::channel(); + let service = self.service.clone(); + let workspace_key_for_task = workspace_key.clone(); + let task_id_for_task = task_id.clone(); + + self.mark_task_resuming_in_background(&workspace_key, &task_id); + tokio::spawn(async move { + let _ = progress_tx.send( + "Restarting the Codex task session before asking it to fix CI...".to_string(), + ); + let result = service + .restart_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_FIX_CI_PROMPT, + ) + .await; + + if let Err(err) = result { + if let Ok(workspace) = service.manager.get_workspace(&workspace_key_for_task) { + workspace.update(|next| { + let mut changed = false; + match previous_snapshot + .task_states + .get(&task_id_for_task) + .cloned() + { + Some(previous_task_state) => { + if next.task_states.get(&task_id_for_task) + != Some(&previous_task_state) + { + next.task_states + .insert(task_id_for_task.clone(), previous_task_state); + changed = true; + } + } + None => { + if next.task_states.remove(&task_id_for_task).is_some() { + changed = true; + } + } + } + if next.active_task_id != previous_snapshot.active_task_id { + next.active_task_id = previous_snapshot.active_task_id.clone(); + changed = true; + } + if next.automation_agent_state != previous_snapshot.automation_agent_state { + next.automation_agent_state = previous_snapshot.automation_agent_state; + changed = true; + } + if next.automation_session_status + != previous_snapshot.automation_session_status + { + next.automation_session_status = + previous_snapshot.automation_session_status; + changed = true; + } + if next.automation_status != previous_snapshot.automation_status { + next.automation_status = previous_snapshot.automation_status.clone(); + changed = true; + } + changed + }); + } + let _ = result_tx.send(Err(err)); + return; + } + + let _ = progress_tx.send( + "Codex accepted the CI fix request and is continuing in the background." + .to_string(), + ); + let _ = result_tx.send(Ok(())); + }); + + self.running_operation = Some(RunningOperation { + workspace_key: workspace_key.clone(), + operation_name: format!("Fix CI {task_id}"), + success_status: Some(format!( + "CI fix request sent for '{task_id}' in workspace '{workspace_key}'; Codex is continuing in the background" + )), + progress_rx, + result_rx, + completion_action: RunningOperationCompletionAction::None, + cancel: None, + }); + self.status = format!( + "Requested CI fixes for '{task_id}' in workspace '{workspace_key}'; sending the request to Codex in the background" + ); + } + pub(crate) async fn handle_key( &mut self, terminal: &mut Terminal>, @@ -3420,6 +3572,15 @@ impl TuiState { self.status = format!("{} workspace '{}'", operation_name, key); } } + KeyCode::Char('f') => { + if link_selected { + return; + } + if self.selected_task_id().is_none() { + return; + } + self.fix_selected_task_ci().await; + } KeyCode::Char('c') => { if link_selected { return; diff --git a/tui/src/main.rs b/tui/src/main.rs index ab1c3ec..03408e9 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -1412,6 +1412,7 @@ fn help_line( selected_workspace_can_assign_issue: bool, selected_workspace_can_diff: bool, selected_workspace_can_edit: bool, + selected_task_can_fix_ci: bool, tool_progress_can_cancel: bool, tool_hotkeys: &[(String, String)], status: &str, @@ -1460,6 +1461,9 @@ fn help_line( } if workspace_supports_task_approval(snapshot) { push_hotkey(&mut spans, "a", " approve "); + if selected_task_can_fix_ci { + push_hotkey(&mut spans, "f", " fix CI "); + } } for (tool_key, tool_name) in tool_hotkeys { push_hotkey(&mut spans, tool_key.clone(), format!(" {} ", tool_name)); diff --git a/tui/src/render.rs b/tui/src/render.rs index d30fe77..bb175ad 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -438,6 +438,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { }) && app.selected_link_index.is_none(), app.selected_workspace_can_diff(), app.selected_workspace_can_edit(), + app.selected_task_can_request_ci_fix(), app.running_operation_is_cancellable(), &app.contextual_tool_hotkeys(), &app.status, diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 51571c0..f48d3dc 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -15,7 +15,7 @@ mod tests { last_user_message_from_codex_session_log_contents, repository_diff_shell_command, restored_selected_row, shell_command_in_repo, should_auto_resume_autonomous_codex_after_attach, - should_auto_resume_task_codex_after_attach, + should_auto_resume_task_codex_after_attach, should_offer_codex_ci_fix, should_queue_task_codex_resume_until_vm_available, should_restart_codex_task_for_pr_request, should_restart_task_codex_after_attach, should_resume_codex_task_after_incomplete_attached_turn, @@ -59,6 +59,89 @@ mod tests { path: PathBuf, } + fn help_line( + mode: UiMode, + selected_row: usize, + workspace_count: usize, + selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, + selected_workspace_link_count: usize, + selected_link_index: Option, + selected_link_is_custom: bool, + selected_link_is_placeholder: bool, + selected_link_kind: Option, + selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_issue: bool, + selected_workspace_can_diff: bool, + selected_workspace_can_edit: bool, + tool_progress_can_cancel: bool, + tool_hotkeys: &[(String, String)], + status: &str, + ) -> Line<'static> { + super::help_line( + mode, + selected_row, + workspace_count, + selected_workspace, + selected_task_row, + selected_workspace_link_count, + selected_link_index, + selected_link_is_custom, + selected_link_is_placeholder, + selected_link_kind, + selected_workspace_has_refreshable_github_link, + selected_workspace_can_assign_issue, + selected_workspace_can_diff, + selected_workspace_can_edit, + false, + tool_progress_can_cancel, + tool_hotkeys, + status, + ) + } + + fn help_line_with_task_fix( + mode: UiMode, + selected_row: usize, + workspace_count: usize, + selected_workspace: Option<&WorkspaceSnapshot>, + selected_task_row: bool, + selected_workspace_link_count: usize, + selected_link_index: Option, + selected_link_is_custom: bool, + selected_link_is_placeholder: bool, + selected_link_kind: Option, + selected_workspace_has_refreshable_github_link: bool, + selected_workspace_can_assign_issue: bool, + selected_workspace_can_diff: bool, + selected_workspace_can_edit: bool, + selected_task_can_fix_ci: bool, + tool_progress_can_cancel: bool, + tool_hotkeys: &[(String, String)], + status: &str, + ) -> Line<'static> { + super::help_line( + mode, + selected_row, + workspace_count, + selected_workspace, + selected_task_row, + selected_workspace_link_count, + selected_link_index, + selected_link_is_custom, + selected_link_is_placeholder, + selected_link_kind, + selected_workspace_has_refreshable_github_link, + selected_workspace_can_assign_issue, + selected_workspace_can_diff, + selected_workspace_can_edit, + selected_task_can_fix_ci, + tool_progress_can_cancel, + tool_hotkeys, + status, + ) + } + impl TestDir { fn new() -> Self { let unique = SystemTime::now() @@ -2733,6 +2816,74 @@ mod tests { assert!(!text.contains("a archive")); } + #[test] + fn help_line_shows_fix_ci_hotkey_for_failing_task_row() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + let line = help_line_with_task_fix( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 0, + None, + false, + false, + None, + false, + false, + true, + true, + true, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("a approve")); + assert!(text.contains("f fix CI")); + } + + #[test] + fn should_offer_codex_ci_fix_requires_pr_link_and_open_non_green_pr() { + assert!(should_offer_codex_ci_fix(true, Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }))); + assert!(should_offer_codex_ci_fix(true, Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Building, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }))); + assert!(!should_offer_codex_ci_fix(true, Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }))); + assert!(!should_offer_codex_ci_fix(true, Some(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }))); + assert!(should_offer_codex_ci_fix(true, None)); + assert!(!should_offer_codex_ci_fix(false, None)); + } + #[test] fn help_line_shows_open_github_for_selected_issue_on_task_row() { let started = snapshot(true, Some("http://example")); From d13df4d318f7f9e9ebdc50ffd5f498319d4df9a6 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 13:48:42 +0200 Subject: [PATCH 68/75] Refine Codex CI fix resume flow Co-Authored-By: OpenAI Codex --- tui/src/app.rs | 156 ++++++++++++++++++++++++++++++++++++++++++----- tui/src/tests.rs | 39 ++++++++++++ 2 files changed, 180 insertions(+), 15 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 2a5260a..4ee4617 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -10,7 +10,54 @@ use std::os::unix::fs::FileTypeExt; const NERD_FONT_GITHUB_GLYPH: &str = "\u{f408}"; const CODEX_AUTO_RESUME_PROMPT: &str = "Continue autonomously from where you left off. Do not wait for approval for repository commands, builds, Gradle tasks, or focused tests. Only stop to ask before committing, pushing, commenting on GitHub, or opening or updating a pull request."; const CODEX_CREATE_PR_APPROVAL_PROMPT: &str = "The local changes for this task are approved for publishing. Create or update the pull request now from this task checkout. Push the branch if needed, use the correct upstream base branch, include an appropriate type label such as `type: docs` for documentation-only changes, `type: bug` for bug fixes, `type: improvement` for minor improvements, or `type: enhancement` for broader enhancements, assign the pull request to yourself, assign it automatically to the next Micronaut project release at the organization level under https://github.com/orgs/micronaut-projects/projects, prefer the next semantically versioned release project that is typically suffixed with a milestone such as `5.0.0-M2` and otherwise suffixed with `Release` such as `5.0.0 Release`, request Copilot review, emit the link, and stop once the PR is ready for human review. If a PR already exists, update it instead of creating a duplicate. Do not merge the PR."; -const CODEX_FIX_CI_PROMPT: &str = "Use the existing pull request for this issue and fix any failing CI checks, including Sonar failures. Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior. Push fixes as needed, monitor the CI after each push, and continue addressing failures until the pull request is green. Stop only when all CI is passing or you need human input."; +const CODEX_FIX_CI_INSTRUCTIONS: &str = "Fix any failing CI checks for this task's existing pull request, including Sonar failures. Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior. Push fixes as needed, monitor CI after each push, and continue until the pull request is green. Do not create a new pull request. Do not merge the pull request. Stop only when all CI is passing or you need human input."; + +pub(crate) fn build_codex_fix_ci_prompt( + assigned_repository: &str, + issue_url: &str, + backing_pr_url: Option<&str>, + cwd: &std::path::Path, + task_state_path: &std::path::Path, + task_session_id: Option<&str>, +) -> String { + let pr_instruction = backing_pr_url.map_or_else( + || "If a pull request already exists for this issue, use that existing pull request instead of creating a duplicate.".to_string(), + |backing_pr_url| format!("Use the existing pull request {backing_pr_url}."), + ); + let session_instruction = task_session_id.map_or_else( + String::new, + |task_session_id| { + format!( + "For this task session/thread, write autonomous state updates in the format `:{task_session_id}` so multicode can attribute the state to this specific session.\n\\\n" + ) + }, + ); + format!( + "You are operating in an autonomous multicode workspace for repository {assigned_repository}.\n\ +Continue autonomously from where you left off.\n\ +Start from the existing checkout for GitHub issue {issue_url}.\n\ +Primary checkout for this task: {cwd}\n\ +Before you proceed, load and follow these workspace skills as appropriate: `independent-fix`, `machine-readable-clone`, `machine-readable-issue`, `machine-readable-pr`, `git-commit-coauthorship`, `micronaut-projects-guide`, and `autonomous-state`.\n\ +For this task, write autonomous state updates to `{task_state_path}`. Do not write task state to any shared workspace file.\n\ +{session_instruction}\ +{pr_instruction}\n\ +Your job is to:\n\ +1. Inspect the current failing CI status for this task and understand every failing check.\n\ +2. Reproduce and fix the underlying problems in the existing checkout.\n\ +3. Run focused verification locally.\n\ +4. Commit and push branch updates as needed.\n\ +5. Monitor CI after each push and keep addressing failures until it is green.\n\ +6. Emit the machine-readable repository / issue / PR tags while you work.\n\ +7. Run repository commands, builds, Gradle tasks, focused tests, git commits, branch pushes, and pull request updates as needed without asking for permission.\n\ +{instructions}\n\ +\n\ +Keep going until CI is green or you need human feedback.", + cwd = cwd.display(), + task_state_path = task_state_path.display(), + session_instruction = session_instruction, + instructions = CODEX_FIX_CI_INSTRUCTIONS + ) +} pub(crate) fn repository_diff_shell_command() -> &'static str { r#"tmp="$(mktemp -t multicode-diff.XXXXXX)" || exit 1 @@ -1658,6 +1705,35 @@ impl TuiState { ) } + fn selected_task_ci_fix_prompt(&self) -> Option { + let workspace_key = self.selected_workspace_key()?; + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + let assigned_repository = snapshot.persistent.assigned_repository.as_deref()?; + let cwd = + self.service + .workspace_task_checkout_path(workspace_key, assigned_repository, &task.issue_url); + let task_state_path = self + .service + .workspace_directory_path() + .join(".multicode") + .join("automation") + .join(workspace_key) + .join("tasks") + .join(format!("{task_id}.state")); + Some(build_codex_fix_ci_prompt( + assigned_repository, + &task.issue_url, + task_pr_link(task, task_runtime_snapshot(snapshot, task_id)) + .or(task.backing_pr_url.as_deref()), + &cwd, + &task_state_path, + task_runtime_snapshot(snapshot, task_id) + .and_then(|task_state| task_state.session_id.as_deref()), + )) + } + async fn approve_selected_task_for_pr_creation(&mut self) { if !self.selected_task_can_request_pr_creation() { return; @@ -1709,6 +1785,18 @@ impl TuiState { .await }; + let result = match result { + Ok(()) => service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_AUTO_RESUME_PROMPT, + ) + .await, + Err(err) => Err(err), + }; + if let Err(err) = result { if let Ok(workspace) = service.manager.get_workspace(&workspace_key_for_task) { workspace.update(|next| { @@ -1791,29 +1879,49 @@ impl TuiState { let Some(task_id) = self.selected_task_id().map(str::to_string) else { return; }; + let Some(prompt) = self.selected_task_ci_fix_prompt() else { + return; + }; let Some(snapshot) = self.snapshots.get(&workspace_key).cloned() else { return; }; let previous_snapshot = snapshot.clone(); + let should_restart = + should_restart_codex_task_for_pr_request(task_runtime_snapshot(&snapshot, &task_id)); let (progress_tx, progress_rx) = watch::channel("Preparing CI fix request...".to_string()); let (result_tx, result_rx) = oneshot::channel(); let service = self.service.clone(); let workspace_key_for_task = workspace_key.clone(); let task_id_for_task = task_id.clone(); + self.persist_task_resume_prompt(&workspace_key, &task_id, &prompt); self.mark_task_resuming_in_background(&workspace_key, &task_id); tokio::spawn(async move { - let _ = progress_tx.send( - "Restarting the Codex task session before asking it to fix CI...".to_string(), - ); - let result = service - .restart_task_session( - &workspace_key_for_task, - &snapshot, - &task_id_for_task, - CODEX_FIX_CI_PROMPT, - ) - .await; + let progress_message = if should_restart { + "Restarting the Codex task session before asking it to fix CI..." + } else { + "Asking Codex to fix CI failures and continue in the background..." + }; + let _ = progress_tx.send(progress_message.to_string()); + let result = if should_restart { + service + .restart_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + &prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + &prompt, + ) + .await + }; if let Err(err) = result { if let Ok(workspace) = service.manager.get_workspace(&workspace_key_for_task) { @@ -2375,9 +2483,27 @@ impl TuiState { } }); } - service - .restart_task_session(&workspace_key, &snapshot, &task_id, &resume_prompt) - .await + if should_restart_codex_task_for_pr_request( + task_runtime_snapshot(&snapshot, &task_id), + ) { + service + .restart_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } else { + service + .prompt_task_session( + &workspace_key, + &snapshot, + &task_id, + &resume_prompt, + ) + .await + } } else if let Some(session_id) = attached_session.session_id.clone() { let resume_prompt = read_last_codex_session_user_message( service.workspace_directory_path().to_path_buf(), diff --git a/tui/src/tests.rs b/tui/src/tests.rs index f48d3dc..a6b7107 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -11,6 +11,7 @@ mod tests { use super::*; use crate::app::{ + build_codex_fix_ci_prompt, compact_github_tooltip_target, count_codex_session_turn_metrics, github_repository_url, last_user_message_from_codex_session_log_contents, repository_diff_shell_command, restored_selected_row, shell_command_in_repo, @@ -2884,6 +2885,44 @@ mod tests { assert!(!should_offer_codex_ci_fix(false, None)); } + #[test] + fn build_codex_fix_ci_prompt_includes_autonomous_context_and_ci_instructions() { + let prompt = build_codex_fix_ci_prompt( + "micronaut-projects/micronaut-kafka", + "https://github.com/micronaut-projects/micronaut-kafka/issues/873", + Some("https://github.com/micronaut-projects/micronaut-kafka/pull/1308"), + std::path::Path::new("/tmp/work/micronaut-kafka-873"), + std::path::Path::new("/tmp/state/task-873.state"), + Some("thread-task-873"), + ); + + assert!(prompt.contains( + "You are operating in an autonomous multicode workspace for repository micronaut-projects/micronaut-kafka." + )); + assert!(prompt.contains("Continue autonomously from where you left off.")); + assert!(prompt.contains( + "Start from the existing checkout for GitHub issue https://github.com/micronaut-projects/micronaut-kafka/issues/873." + )); + assert!(prompt.contains("Primary checkout for this task: /tmp/work/micronaut-kafka-873")); + assert!(prompt.contains( + "write autonomous state updates to `/tmp/state/task-873.state`" + )); + assert!(prompt.contains( + "write autonomous state updates in the format `:thread-task-873`" + )); + assert!(prompt.contains("Use the existing pull request https://github.com/micronaut-projects/micronaut-kafka/pull/1308.")); + assert!(prompt.contains("`machine-readable-pr`")); + assert!(prompt.contains("`autonomous-state`")); + assert!(prompt.contains( + "Run repository commands, builds, Gradle tasks, focused tests, git commits, branch pushes, and pull request updates as needed without asking for permission." + )); + assert!(prompt.contains( + "Existing tests must never be changed just to satisfy failing checks or to mask regressions; preserve the intended existing behavior." + )); + assert!(prompt.contains("Do not create a new pull request.")); + assert!(prompt.contains("Do not merge the pull request.")); + } + #[test] fn help_line_shows_open_github_for_selected_issue_on_task_row() { let started = snapshot(true, Some("http://example")); From 7d39ac5a437fa8fac0845472d339241a70837c08 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 13:57:59 +0200 Subject: [PATCH 69/75] Fix task GitHub open and Codex attach routing Co-Authored-By: OpenAI Codex --- tui/src/app.rs | 25 ++++++++++++------------- tui/src/main.rs | 1 + tui/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 4ee4617..b1d4515 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -429,16 +429,10 @@ pub(crate) fn should_restart_task_codex_after_attach( attached_session_id.is_some() && !fresh_codex_session } -fn codex_observer_attach_prompt(task_id: &str) -> String { - format!( - "Another Codex task session is already actively working on {task_id}. You are attached only for observation and user-directed inspection. Do not make repository changes, do not start duplicate work, and do not send autonomous follow-up prompts to the active task. Briefly confirm you are attached and then wait for the user." - ) -} - pub(crate) fn working_codex_task_attach_target( snapshot: &WorkspaceSnapshot, task_id: Option<&str>, - cwd: Option, + _cwd: Option, ) -> io::Result> { let Some(task_id) = task_id else { return Ok(None); @@ -453,10 +447,9 @@ pub(crate) fn working_codex_task_attach_target( let Some(uri) = codex_attach_uri(snapshot)? else { return Ok(None); }; - Ok(Some(AttachTarget::CodexNew { + Ok(Some(AttachTarget::Codex { uri, - cwd, - prompt: Some(codex_observer_attach_prompt(task_id)), + thread_id: task_state.session_id.clone(), })) } @@ -1734,6 +1727,13 @@ impl TuiState { )) } + fn selected_task_default_github_url(&self) -> Option { + let snapshot = self.selected_workspace_snapshot()?; + let task_id = self.selected_task_id()?; + let task = task_persistent_snapshot(snapshot, task_id)?; + Some(task_issue_link(task, task_runtime_snapshot(snapshot, task_id)).to_string()) + } + async fn approve_selected_task_for_pr_creation(&mut self) { if !self.selected_task_can_request_pr_creation() { return; @@ -3450,6 +3450,8 @@ impl TuiState { return; }; ("GitHub link", argument.clone()) + } else if let Some(url) = self.selected_task_default_github_url() { + ("GitHub issue", url) } else { let Some(url) = self.selected_workspace_github_repository_url() else { return; @@ -3780,9 +3782,6 @@ impl TuiState { self.request_selected_workspace_queue_next_issue(); } KeyCode::Char('o') => { - if !link_selected && self.selected_task_id().is_some() { - return; - } self.open_selected_github_target().await; } KeyCode::Char('s') => { diff --git a/tui/src/main.rs b/tui/src/main.rs index 03408e9..efefdd3 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -1465,6 +1465,7 @@ fn help_line( push_hotkey(&mut spans, "f", " fix CI "); } } + push_hotkey(&mut spans, "o", " open GitHub "); for (tool_key, tool_name) in tool_hotkeys { push_hotkey(&mut spans, tool_key.clone(), format!(" {} ", tool_name)); } diff --git a/tui/src/tests.rs b/tui/src/tests.rs index a6b7107..df76543 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -1245,7 +1245,7 @@ mod tests { } #[test] - fn working_codex_task_attach_target_uses_fresh_observer_session() { + fn working_codex_task_attach_target_uses_existing_task_thread() { let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); assign_active_task(&mut started, "https://github.com/example/repo/issues/42"); started.task_states.insert( @@ -1267,10 +1267,9 @@ mod tests { assert_eq!( target, - Some(AttachTarget::CodexNew { + Some(AttachTarget::Codex { uri: "ws://127.0.0.1:3456/".to_string(), - cwd: Some("/tmp/task-42".to_string()), - prompt: Some("Another Codex task session is already actively working on task-42. You are attached only for observation and user-directed inspection. Do not make repository changes, do not start duplicate work, and do not send autonomous follow-up prompts to the active task. Briefly confirm you are attached and then wait for the user.".to_string()), + thread_id: Some("thread-42".to_string()), }) ); } @@ -2849,6 +2848,39 @@ mod tests { assert!(text.contains("a approve")); assert!(text.contains("f fix CI")); + assert!(text.contains("o open GitHub")); + } + + #[test] + fn help_line_shows_open_github_for_task_row_focus_without_selected_link() { + let started = snapshot(true, Some("http://example")); + let line = help_line( + UiMode::Normal, + 2, + 2, + Some(&started), + true, + 2, + None, + false, + false, + None, + false, + false, + false, + false, + false, + no_tool_hotkeys(), + "", + ); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains("o open GitHub")); + assert!(text.contains("x remove issue")); } #[test] From 93556825d843b75b2858d4c44d0d59cf6e9d7e49 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 14:50:00 +0200 Subject: [PATCH 70/75] Fix stale Codex CI fix session restarts Co-Authored-By: OpenAI Codex --- .../services/autonomous_workspace_service.rs | 354 +++++++++++++++--- lib/src/services/combined.rs | 128 ++++++- tui/src/app.rs | 103 +++-- tui/src/tests.rs | 176 +++++++-- 4 files changed, 637 insertions(+), 124 deletions(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index cbdd2e1..87125d5 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -1293,7 +1293,20 @@ fn sync_task_runtime_state(workspace: &Workspace, snapshot: &WorkspaceSnapshot) .as_deref() .filter(|task_id| active_task_lease_must_be_preserved(next, task_id)) .map(ToOwned::to_owned) - .or_else(|| snapshot.resolved_active_task_id()); + .or_else(|| { + let claimed_task_id = snapshot + .persistent + .automation_issue + .as_deref() + .and_then(|issue_url| task_id_for_issue(snapshot, issue_url)); + match claimed_task_id.as_deref() { + Some(task_id) if snapshot.active_task_id.as_deref() != Some(task_id) => { + Some(task_id.to_string()) + } + Some(task_id) if snapshot.active_task_id.is_none() => Some(task_id.to_string()), + _ => None, + } + }); if next.active_task_id != resolved_active_task_id { next.active_task_id = resolved_active_task_id.clone(); changed = true; @@ -1348,10 +1361,10 @@ fn sync_task_runtime_state(workspace: &Workspace, snapshot: &WorkspaceSnapshot) fn active_task_lease_must_be_preserved(snapshot: &WorkspaceSnapshot, task_id: &str) -> bool { task_persistent_snapshot(snapshot, task_id).is_some() - && snapshot.task_states.get(task_id).is_some_and(|task_state| { - task_state.session_id.is_some() - && !task_can_yield_vm(normalized_task_agent_state(task_state)) - }) + && snapshot + .task_states + .get(task_id) + .is_some_and(|task_state| !task_can_yield_vm(normalized_task_agent_state(task_state))) } fn active_task_id_for_snapshot(snapshot: &WorkspaceSnapshot) -> Option { @@ -1674,6 +1687,8 @@ struct CodexRecoveredThreadCandidate { cwd: String, sort_key: String, has_user_event: bool, + autonomous_issue_urls: Vec, + autonomous_pr_urls: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -1769,7 +1784,7 @@ async fn recover_codex_task_sessions( return; } - let missing_task_cwds = snapshot + let missing_tasks = snapshot .persistent .tasks .iter() @@ -1780,26 +1795,25 @@ async fn recover_codex_task_sessions( .and_then(|state| state.session_id.as_deref()) .is_none() }) - .map(|task| { - ( - task.id.clone(), - task_cwd_path(service, workspace_key, assigned_repository, &task.issue_url) - .to_string_lossy() - .into_owned(), - ) + .map(|task| CodexTaskRecoveryDescriptor { + task_id: task.id.clone(), + cwd: task_cwd_path(service, workspace_key, assigned_repository, &task.issue_url) + .to_string_lossy() + .into_owned(), + issue_url: task.issue_url.clone(), + backing_pr_url: task.backing_pr_url.clone(), }) .collect::>(); - if missing_task_cwds.is_empty() { + if missing_tasks.is_empty() { return; } let codex_home = synthetic_codex_home_source(service.workspace_directory_path(), workspace_key); - let recovered = spawn_blocking(move || { - recover_codex_thread_ids_from_state_db(&codex_home, &missing_task_cwds) - }) - .await - .ok() - .and_then(Result::ok); + let recovered = + spawn_blocking(move || recover_codex_thread_ids_for_tasks(&codex_home, &missing_tasks)) + .await + .ok() + .and_then(Result::ok); let Some(recovered) = recovered else { return; }; @@ -1864,6 +1878,8 @@ async fn reconcile_codex_task_runtime_states( service.workspace_directory_path(), workspace_key, &task_cwd, + &task.issue_url, + task.backing_pr_url.as_deref(), Some(&task_session_id), ) .await @@ -1925,6 +1941,8 @@ async fn reconcile_codex_task_runtime_states( service.workspace_directory_path(), workspace_key, &task_cwd, + &task.issue_url, + task.backing_pr_url.as_deref(), Some(&task_session_id), ) .await @@ -2291,7 +2309,16 @@ fn recover_latest_codex_thread_candidate_for_cwd( let log_candidate = recover_codex_thread_candidates_from_session_logs(codex_home, &[cwd.to_string()]) - .remove(cwd); + .remove(cwd) + .and_then(|candidates| { + candidates.into_iter().reduce(|current, next| { + if should_prefer_recovered_candidate(Some(&next), Some(¤t)) { + next + } else { + current + } + }) + }); if should_prefer_recovered_candidate(log_candidate.as_ref(), candidate.as_ref()) { candidate = log_candidate; } @@ -2299,28 +2326,52 @@ fn recover_latest_codex_thread_candidate_for_cwd( candidate } -fn recover_codex_thread_ids_from_state_db( +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexTaskRecoveryDescriptor { + task_id: String, + cwd: String, + issue_url: String, + backing_pr_url: Option, +} + +fn recover_codex_thread_ids_for_tasks( codex_home: &Path, - task_cwds: &[(String, String)], + tasks: &[CodexTaskRecoveryDescriptor], ) -> Result, String> { - let cwd_candidates = recover_codex_thread_candidates_from_state_db( - codex_home, - &task_cwds - .iter() - .map(|(_, cwd)| cwd.clone()) - .collect::>(), - )?; - - Ok(task_cwds + Ok(tasks .iter() - .filter_map(|(task_id, cwd)| { - cwd_candidates - .get(cwd) - .map(|candidate| (task_id.clone(), candidate.id.clone())) + .filter_map(|task| { + recover_latest_codex_task_thread_candidate(codex_home, task) + .map(|candidate| (task.task_id.clone(), candidate.id)) }) .collect()) } +fn recover_latest_codex_task_thread_candidate( + codex_home: &Path, + task: &CodexTaskRecoveryDescriptor, +) -> Option { + let mut owned_candidate = None; + let log_candidates = recover_codex_thread_candidates_from_session_logs( + codex_home, + std::slice::from_ref(&task.cwd), + ); + if let Some(candidates) = log_candidates.get(task.cwd.as_str()) { + for candidate in candidates { + if recovered_candidate_matches_task(candidate, task) + && should_prefer_recovered_candidate(Some(candidate), owned_candidate.as_ref()) + { + owned_candidate = Some(candidate.clone()); + } + } + } + if owned_candidate.is_some() { + return owned_candidate; + } + + recover_latest_codex_thread_candidate_for_cwd(codex_home, &task.cwd) +} + fn recover_codex_thread_candidates_from_state_db( codex_home: &Path, task_cwds: &[String], @@ -2354,6 +2405,8 @@ fn recover_codex_thread_candidates_from_state_db( cwd: row.cwd.clone(), sort_key: format!("{:020}", row.updated_at), has_user_event: row.has_user_event != 0, + autonomous_issue_urls: Vec::new(), + autonomous_pr_urls: Vec::new(), }; let prefer_row = match threads_by_cwd.get(&row.cwd) { None => true, @@ -2372,9 +2425,9 @@ fn recover_codex_thread_candidates_from_state_db( fn recover_codex_thread_candidates_from_session_logs( codex_home: &Path, task_cwds: &[String], -) -> HashMap { +) -> HashMap> { let sessions_dir = latest_codex_sessions_dir(codex_home); - let mut results = HashMap::::new(); + let mut results = HashMap::>::new(); let mut stack = vec![sessions_dir]; while let Some(directory) = stack.pop() { @@ -2401,12 +2454,10 @@ fn recover_codex_thread_candidates_from_session_logs( if !task_cwds.iter().any(|cwd| cwd == &candidate.cwd) { continue; } - if should_prefer_recovered_candidate( - Some(&candidate), - results.get(candidate.cwd.as_str()), - ) { - results.insert(candidate.cwd.clone(), candidate); - } + results + .entry(candidate.cwd.clone()) + .or_default() + .push(candidate); } } @@ -2436,14 +2487,144 @@ fn recover_codex_thread_candidate_from_session_log( .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string(); + let mut has_user_event = false; + let mut autonomous_issue_urls = Vec::new(); + let mut autonomous_pr_urls = Vec::new(); + for line in contents.lines().skip(1) { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + let Some(text) = codex_session_log_user_text(&value) else { + continue; + }; + has_user_event = true; + if !is_autonomous_multicode_task_prompt(text) { + continue; + } + let (issue_urls, pr_urls) = extract_github_issue_and_pr_urls(text); + autonomous_issue_urls.extend(issue_urls); + autonomous_pr_urls.extend(pr_urls); + } Some(CodexRecoveredThreadCandidate { id, cwd, sort_key: timestamp, - has_user_event: true, + has_user_event, + autonomous_issue_urls, + autonomous_pr_urls, }) } +fn codex_session_log_user_text<'a>(value: &'a serde_json::Value) -> Option<&'a str> { + if value.get("type").and_then(serde_json::Value::as_str) == Some("event_msg") { + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) == Some("user_message") { + return payload.get("message").and_then(serde_json::Value::as_str); + } + } + + if value.get("type").and_then(serde_json::Value::as_str) == Some("response_item") { + let payload = value.get("payload")?; + if payload.get("type").and_then(serde_json::Value::as_str) != Some("message") + || payload.get("role").and_then(serde_json::Value::as_str) != Some("user") + { + return None; + } + let content = payload.get("content")?.as_array()?; + return content.iter().find_map(|item| { + (item.get("type").and_then(serde_json::Value::as_str) == Some("input_text")) + .then(|| item.get("text").and_then(serde_json::Value::as_str)) + .flatten() + }); + } + + None +} + +fn is_autonomous_multicode_task_prompt(text: &str) -> bool { + text.contains("You are operating in an autonomous multicode workspace") + && text.contains("Start work on GitHub issue ") +} + +fn extract_github_issue_and_pr_urls(text: &str) -> (Vec, Vec) { + let mut issues = HashSet::new(); + let mut prs = HashSet::new(); + for token in text.split_whitespace() { + let token = token.trim_matches(|ch: char| { + matches!( + ch, + '"' | '\'' | '`' | ',' | '.' | ';' | ':' | '(' | ')' | '[' | ']' + ) + }); + if let Some(issue_url) = normalize_github_issue_url(token) { + issues.insert(issue_url); + } else if let Some(pr_url) = normalize_github_pull_request_url(token) { + prs.insert(pr_url); + } + } + + (issues.into_iter().collect(), prs.into_iter().collect()) +} + +fn normalize_github_issue_url(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + let rest = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/"))?; + let segments = rest + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + let [owner, repo, kind, number, ..] = segments.as_slice() else { + return None; + }; + if *kind != "issues" { + return None; + } + let repository = normalize_github_repository_spec(&format!("{owner}/{repo}"))?; + let number = number.parse::().ok()?; + Some(format!("https://github.com/{repository}/issues/{number}")) +} + +fn normalize_github_pull_request_url(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + let rest = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/"))?; + let segments = rest + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .map(|segment| segment.trim()) + .collect::>(); + let [owner, repo, kind, number, ..] = segments.as_slice() else { + return None; + }; + if *kind != "pull" { + return None; + } + let repository = normalize_github_repository_spec(&format!("{owner}/{repo}"))?; + let number = number.parse::().ok()?; + Some(format!("https://github.com/{repository}/pull/{number}")) +} + +fn recovered_candidate_matches_task( + candidate: &CodexRecoveredThreadCandidate, + task: &CodexTaskRecoveryDescriptor, +) -> bool { + candidate.cwd == task.cwd + && (candidate + .autonomous_issue_urls + .iter() + .any(|issue_url| issue_url == &task.issue_url) + || task.backing_pr_url.as_ref().is_some_and(|backing_pr_url| { + candidate + .autonomous_pr_urls + .iter() + .any(|pr_url| pr_url == backing_pr_url) + })) +} + fn should_prefer_recovered_candidate( next: Option<&CodexRecoveredThreadCandidate>, current: Option<&CodexRecoveredThreadCandidate>, @@ -2463,13 +2644,20 @@ async fn recover_latest_codex_task_session_id( workspace_directory_path: &Path, workspace_key: &str, task_cwd: &Path, + task_issue_url: &str, + backing_pr_url: Option<&str>, current_session_id: Option<&str>, ) -> Option { let codex_home = synthetic_codex_home_source(workspace_directory_path, workspace_key); - let cwd = task_cwd.to_string_lossy().into_owned(); + let task = CodexTaskRecoveryDescriptor { + task_id: String::new(), + cwd: task_cwd.to_string_lossy().into_owned(), + issue_url: task_issue_url.to_string(), + backing_pr_url: backing_pr_url.map(ToOwned::to_owned), + }; let current_session_id = current_session_id.map(ToOwned::to_owned); spawn_blocking(move || { - recover_latest_codex_thread_candidate_for_cwd(&codex_home, &cwd).and_then(|candidate| { + recover_latest_codex_task_thread_candidate(&codex_home, &task).and_then(|candidate| { (current_session_id.as_deref() != Some(candidate.id.as_str())).then_some(candidate.id) }) }) @@ -4782,15 +4970,85 @@ mod tests { let recovered = recover_codex_thread_candidates_from_session_logs(&codex_home, &[cwd.to_string()]); + let recovered = recovered + .get(cwd) + .expect("matching cwd should be present") + .iter() + .cloned() + .reduce(|current, next| { + if should_prefer_recovered_candidate(Some(&next), Some(¤t)) { + next + } else { + current + } + }); assert_eq!( - recovered.get(cwd).map(|candidate| candidate.id.as_str()), + recovered.as_ref().map(|candidate| candidate.id.as_str()), Some("019d85ce") ); let _ = fs::remove_dir_all(&codex_home); } + #[test] + fn recover_latest_codex_task_thread_candidate_prefers_autonomous_worker_over_newer_observer() { + let codex_home = unique_test_dir("codex-task-session-recovery"); + let worker_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/16"); + let observer_dir = latest_codex_sessions_dir(&codex_home).join("2026/04/17"); + fs::create_dir_all(&worker_dir).expect("worker session dir should be created"); + fs::create_dir_all(&observer_dir).expect("observer session dir should be created"); + + let cwd = "/Users/graemerocher/dev/multicode-workspaces/sql/work/micronaut-sql-815"; + let issue_url = "https://github.com/micronaut-projects/micronaut-sql/issues/815"; + let pr_url = "https://github.com/micronaut-projects/micronaut-sql/pull/1921"; + let worker = worker_dir.join("rollout-2026-04-16T18-45-42-019d979d.jsonl"); + let observer = observer_dir.join("rollout-2026-04-17T07-28-28-019d9a57.jsonl"); + + fs::write( + &worker, + format!( + concat!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d979d\",\"cwd\":\"{}\",\"timestamp\":\"2026-04-16T18:45:42.032Z\"}}}}\n", + "{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"You are operating in an autonomous multicode workspace for repository micronaut-projects/micronaut-sql.\\nStart work on GitHub issue {}.\\nFor this task, write autonomous state updates to `/Users/graemerocher/dev/multicode-workspaces/.multicode/automation/sql/tasks/task-815.state`.\\nThis issue already has a PR associated {}.\"}}]}}}}\n" + ), + cwd, + issue_url, + pr_url, + ), + ) + .expect("worker session log should be written"); + fs::write( + &observer, + format!( + concat!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d9a57\",\"cwd\":\"{}\",\"timestamp\":\"2026-04-17T07:28:28.902Z\"}}}}\n", + "{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"this issue already has a PR associated {} ensure that is communicated to multicode UI\"}}]}}}}\n" + ), + cwd, + pr_url, + ), + ) + .expect("observer session log should be written"); + + let recovered = recover_latest_codex_task_thread_candidate( + &codex_home, + &CodexTaskRecoveryDescriptor { + task_id: "task-815".to_string(), + cwd: cwd.to_string(), + issue_url: issue_url.to_string(), + backing_pr_url: Some(pr_url.to_string()), + }, + ); + + assert_eq!( + recovered.as_ref().map(|candidate| candidate.id.as_str()), + Some("019d979d") + ); + + let _ = fs::remove_dir_all(&codex_home); + } + #[test] fn recover_codex_thread_candidates_from_state_db_reads_wal_snapshot() { let codex_home = unique_test_dir("codex-state-recovery"); diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 20831d5..bd07979 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -1063,13 +1063,22 @@ impl CombinedService { let task = snapshot .task_persistent_snapshot(task_id) .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; - let assigned_repository = snapshot - .persistent - .assigned_repository - .as_deref() - .ok_or_else(|| { - format!("workspace '{workspace_key}' does not have an assigned repository") - })?; + let assigned_repository = resolve_task_repository(snapshot, task).ok_or_else(|| { + format!("workspace '{workspace_key}' does not have a repository for task '{task_id}'") + })?; + if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository.as_str()) + { + workspace.update(|next| { + if next.persistent.assigned_repository.as_deref() + == Some(assigned_repository.as_str()) + { + false + } else { + next.persistent.assigned_repository = Some(assigned_repository.clone()); + true + } + }); + } let uri = snapshot .transient .as_ref() @@ -1077,7 +1086,7 @@ impl CombinedService { .ok_or_else(|| { format!("workspace '{workspace_key}' does not have an active runtime") })?; - self.ensure_workspace_task_checkout(workspace_key, assigned_repository, &task.issue_url) + self.ensure_workspace_task_checkout(workspace_key, &assigned_repository, &task.issue_url) .await .map_err(|err| err.summary())?; let existing_session_id = snapshot @@ -1176,13 +1185,22 @@ impl CombinedService { let task = snapshot .task_persistent_snapshot(task_id) .ok_or_else(|| format!("workspace task '{task_id}' no longer exists"))?; - let assigned_repository = snapshot - .persistent - .assigned_repository - .as_deref() - .ok_or_else(|| { - format!("workspace '{workspace_key}' does not have an assigned repository") - })?; + let assigned_repository = resolve_task_repository(snapshot, task).ok_or_else(|| { + format!("workspace '{workspace_key}' does not have a repository for task '{task_id}'") + })?; + if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository.as_str()) + { + workspace.update(|next| { + if next.persistent.assigned_repository.as_deref() + == Some(assigned_repository.as_str()) + { + false + } else { + next.persistent.assigned_repository = Some(assigned_repository.clone()); + true + } + }); + } let uri = snapshot .transient .as_ref() @@ -1191,7 +1209,7 @@ impl CombinedService { format!("workspace '{workspace_key}' does not have an active runtime") })?; let cwd = self - .ensure_workspace_task_checkout(workspace_key, assigned_repository, &task.issue_url) + .ensure_workspace_task_checkout(workspace_key, &assigned_repository, &task.issue_url) .await .map_err(|err| err.summary())?; let client = CodexAppServerClient::new(uri.clone()); @@ -1360,7 +1378,10 @@ impl CombinedService { } fn is_codex_thread_materialization_error(error: &str) -> bool { - error.contains("thread not found") || error.contains("thread not loaded") + error.contains("thread not found") + || error.contains("thread not loaded") + || error.contains("not ready yet") + || error.contains("timed out waiting for codex thread") } #[cfg_attr(not(test), allow(dead_code))] @@ -2154,6 +2175,25 @@ fn normalize_repository_spec(repository: &str) -> Result Option { + snapshot + .persistent + .assigned_repository + .as_deref() + .and_then(super::autonomous_workspace_service::normalize_github_repository_spec) + .or_else(|| { + super::autonomous_workspace_service::normalize_github_repository_spec(&task.issue_url) + }) + .or_else(|| { + task.backing_pr_url + .as_deref() + .and_then(super::autonomous_workspace_service::normalize_github_repository_spec) + }) +} + fn normalize_issue_spec( assigned_repository: &str, issue: &str, @@ -2364,6 +2404,7 @@ fn spawn_autonomous_workspace_service(service: CombinedService) { #[cfg(test)] mod tests { use super::*; + use crate::WorkspaceSnapshot; use crate::services::{ CompareTool, GithubTokenConfig, ToolType, config::{CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode}, @@ -2586,6 +2627,25 @@ command = "~/Library/Application Support/JetBrains/Toolbox/scripts/idea" ); } + #[test] + fn codex_thread_materialization_errors_include_not_ready_variants() { + assert!(CombinedService::is_codex_thread_materialization_error( + "thread not found: 123" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "thread not loaded: 123" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "thread '123' read succeeded but is not ready yet" + )); + assert!(CombinedService::is_codex_thread_materialization_error( + "timed out waiting for codex thread '123' to materialize" + )); + assert!(!CombinedService::is_codex_thread_materialization_error( + "permission denied" + )); + } + #[test] fn config_parses_autonomous_scan_on_startup_flag() { let config: Config = toml::from_str( @@ -6362,6 +6422,40 @@ isolated = ["~/.config/opencode"] assert_eq!(rewritten, prompt); } + #[test] + fn resolve_task_repository_falls_back_to_task_issue_or_pr_when_workspace_repo_missing() { + let mut snapshot = WorkspaceSnapshot::default(); + let issue_task = WorkspaceTaskPersistentSnapshot::new( + "task-1".to_string(), + "https://github.com/micronaut-projects/micronaut-kafka/issues/873".to_string(), + WorkspaceTaskSource::Manual, + ); + assert_eq!( + resolve_task_repository(&snapshot, &issue_task).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + + let pr_task = WorkspaceTaskPersistentSnapshot::new( + "task-2".to_string(), + "not-a-github-url".to_string(), + WorkspaceTaskSource::Manual, + ) + .with_backing_pr_url(Some( + "https://github.com/micronaut-projects/micronaut-kafka/pull/1308".to_string(), + )); + assert_eq!( + resolve_task_repository(&snapshot, &pr_task).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + + snapshot.persistent.assigned_repository = + Some("https://github.com/example/repo.git".to_string()); + assert_eq!( + resolve_task_repository(&snapshot, &issue_task).as_deref(), + Some("example/repo") + ); + } + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { args.windows(sequence.len()).any(|window| { window diff --git a/tui/src/app.rs b/tui/src/app.rs index b1d4515..c7db20d 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -269,6 +269,29 @@ pub(crate) fn should_restart_codex_task_for_pr_request( matches!(task_state.status.as_deref(), Some("NotLoaded")) } +pub(crate) fn should_restart_codex_task_for_ci_fix( + task_state: Option<&WorkspaceTaskRuntimeSnapshot>, +) -> bool { + let Some(task_state) = task_state else { + return false; + }; + + if should_restart_codex_task_for_pr_request(Some(task_state)) { + return true; + } + + if task_state.session_id.is_none() { + return false; + } + + match task_state.session_status { + Some(RootSessionStatus::Busy) => false, + Some(RootSessionStatus::Idle) | Some(RootSessionStatus::Question) | None => { + task_state.agent_state != Some(AutomationAgentState::Working) + } + } +} + pub(crate) fn should_offer_codex_ci_fix( has_pr_link: bool, pr_status: Option, @@ -306,6 +329,11 @@ pub(crate) fn compact_github_tooltip_target(target: &str) -> Option { } pub(crate) fn github_repository_url(repository: &str) -> Option { + let repository = github_repository_spec(repository)?; + Some(format!("https://github.com/{repository}")) +} + +pub(crate) fn github_repository_spec(repository: &str) -> Option { let trimmed = repository.trim(); if trimmed.is_empty() { return None; @@ -318,7 +346,7 @@ pub(crate) fn github_repository_url(repository: &str) -> Option { let mut segments = url.path_segments()?.filter(|segment| !segment.is_empty()); let owner = segments.next()?; let repo = segments.next()?; - return Some(format!("https://github.com/{owner}/{repo}")); + return Some(format!("{owner}/{repo}")); } let mut segments = trimmed.split('/').filter(|segment| !segment.is_empty()); @@ -328,7 +356,19 @@ pub(crate) fn github_repository_url(repository: &str) -> Option { return None; } - Some(format!("https://github.com/{owner}/{repo}")) + Some(format!("{owner}/{repo}")) +} + +pub(crate) fn task_repository_spec(snapshot: &WorkspaceSnapshot, task_id: &str) -> Option { + if let Some(repository) = snapshot.persistent.assigned_repository.as_deref() { + return github_repository_spec(repository); + } + + let task = task_persistent_snapshot(snapshot, task_id)?; + github_repository_spec(&task.issue_url).or_else(|| { + task_pr_link(task, task_runtime_snapshot(snapshot, task_id)) + .and_then(github_repository_spec) + }) } pub(crate) fn has_available_task_slot( @@ -1696,6 +1736,7 @@ impl TuiState { self.selected_task_pr_link().is_some(), self.selected_task_pr_status(), ) + && self.selected_task_ci_fix_prompt().is_some() } fn selected_task_ci_fix_prompt(&self) -> Option { @@ -1703,10 +1744,12 @@ impl TuiState { let snapshot = self.selected_workspace_snapshot()?; let task_id = self.selected_task_id()?; let task = task_persistent_snapshot(snapshot, task_id)?; - let assigned_repository = snapshot.persistent.assigned_repository.as_deref()?; - let cwd = - self.service - .workspace_task_checkout_path(workspace_key, assigned_repository, &task.issue_url); + let assigned_repository = task_repository_spec(snapshot, task_id)?; + let cwd = self.service.workspace_task_checkout_path( + workspace_key, + &assigned_repository, + &task.issue_url, + ); let task_state_path = self .service .workspace_directory_path() @@ -1716,7 +1759,7 @@ impl TuiState { .join("tasks") .join(format!("{task_id}.state")); Some(build_codex_fix_ci_prompt( - assigned_repository, + &assigned_repository, &task.issue_url, task_pr_link(task, task_runtime_snapshot(snapshot, task_id)) .or(task.backing_pr_url.as_deref()), @@ -1749,7 +1792,7 @@ impl TuiState { }; let previous_snapshot = snapshot.clone(); let should_restart = - should_restart_codex_task_for_pr_request(task_runtime_snapshot(&snapshot, &task_id)); + should_restart_codex_task_for_ci_fix(task_runtime_snapshot(&snapshot, &task_id)); let (progress_tx, progress_rx) = watch::channel("Preparing PR approval request...".to_string()); let (result_tx, result_rx) = oneshot::channel(); @@ -1786,14 +1829,16 @@ impl TuiState { }; let result = match result { - Ok(()) => service - .prompt_task_session( - &workspace_key_for_task, - &snapshot, - &task_id_for_task, - CODEX_AUTO_RESUME_PROMPT, - ) - .await, + Ok(()) => { + service + .prompt_task_session( + &workspace_key_for_task, + &snapshot, + &task_id_for_task, + CODEX_AUTO_RESUME_PROMPT, + ) + .await + } Err(err) => Err(err), }; @@ -1870,24 +1915,36 @@ impl TuiState { } async fn fix_selected_task_ci(&mut self) { - if !self.selected_task_can_request_pr_creation() { + if !self.selected_task_can_request_ci_fix() { + self.status = "Fix CI is unavailable for the selected task".to_string(); return; } let Some(workspace_key) = self.selected_workspace_key().map(str::to_string) else { + self.status = + "Fix CI is unavailable because the selected workspace could not be resolved" + .to_string(); return; }; let Some(task_id) = self.selected_task_id().map(str::to_string) else { + self.status = + "Fix CI is unavailable because the selected task could not be resolved".to_string(); return; }; let Some(prompt) = self.selected_task_ci_fix_prompt() else { + self.status = format!( + "Fix CI is unavailable for '{task_id}' in workspace '{workspace_key}' because its repository or checkout context could not be resolved" + ); return; }; let Some(snapshot) = self.snapshots.get(&workspace_key).cloned() else { + self.status = format!( + "Fix CI is unavailable for '{task_id}' in workspace '{workspace_key}' because the latest workspace snapshot is missing" + ); return; }; let previous_snapshot = snapshot.clone(); let should_restart = - should_restart_codex_task_for_pr_request(task_runtime_snapshot(&snapshot, &task_id)); + should_restart_codex_task_for_ci_fix(task_runtime_snapshot(&snapshot, &task_id)); let (progress_tx, progress_rx) = watch::channel("Preparing CI fix request...".to_string()); let (result_tx, result_rx) = oneshot::channel(); let service = self.service.clone(); @@ -2483,9 +2540,9 @@ impl TuiState { } }); } - if should_restart_codex_task_for_pr_request( - task_runtime_snapshot(&snapshot, &task_id), - ) { + if should_restart_codex_task_for_pr_request(task_runtime_snapshot( + &snapshot, &task_id, + )) { service .restart_task_session( &workspace_key, @@ -3707,6 +3764,10 @@ impl TuiState { if self.selected_task_id().is_none() { return; } + if !self.selected_task_can_request_ci_fix() { + self.status = "Fix CI is unavailable for the selected task".to_string(); + return; + } self.fix_selected_task_ci().await; } KeyCode::Char('c') => { diff --git a/tui/src/tests.rs b/tui/src/tests.rs index df76543..073b365 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -11,19 +11,19 @@ mod tests { use super::*; use crate::app::{ - build_codex_fix_ci_prompt, - compact_github_tooltip_target, count_codex_session_turn_metrics, github_repository_url, + build_codex_fix_ci_prompt, compact_github_tooltip_target, count_codex_session_turn_metrics, + github_repository_spec, github_repository_url, last_user_message_from_codex_session_log_contents, repository_diff_shell_command, restored_selected_row, shell_command_in_repo, should_auto_resume_autonomous_codex_after_attach, should_auto_resume_task_codex_after_attach, should_offer_codex_ci_fix, - should_queue_task_codex_resume_until_vm_available, + should_queue_task_codex_resume_until_vm_available, should_restart_codex_task_for_ci_fix, should_restart_codex_task_for_pr_request, should_restart_task_codex_after_attach, should_resume_codex_task_after_incomplete_attached_turn, should_retry_codex_task_attach_with_last_thread, should_start_fresh_codex_task_session_after_failed_attach, snapshot_attach_cwd_for_selection, snapshot_attach_target_for_selection, - starting_modal_failure_status, working_codex_task_attach_target, + starting_modal_failure_status, task_repository_spec, working_codex_task_attach_target, }; use crate::icons::{ icon_glyph, issue_icon_kind_and_color, pr_build_icon_color, pr_icon_kind_and_color, @@ -2885,34 +2885,46 @@ mod tests { #[test] fn should_offer_codex_ci_fix_requires_pr_link_and_open_non_green_pr() { - assert!(should_offer_codex_ci_fix(true, Some(GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Failed, - review: GithubPrReviewState::Outstanding, - is_draft: false, - fetched_at: UNIX_EPOCH, - }))); - assert!(should_offer_codex_ci_fix(true, Some(GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Building, - review: GithubPrReviewState::Outstanding, - is_draft: false, - fetched_at: UNIX_EPOCH, - }))); - assert!(!should_offer_codex_ci_fix(true, Some(GithubPrStatus { - state: GithubPrState::Open, - build: GithubPrBuildState::Succeeded, - review: GithubPrReviewState::Outstanding, - is_draft: false, - fetched_at: UNIX_EPOCH, - }))); - assert!(!should_offer_codex_ci_fix(true, Some(GithubPrStatus { - state: GithubPrState::Merged, - build: GithubPrBuildState::Failed, - review: GithubPrReviewState::Outstanding, - is_draft: false, - fetched_at: UNIX_EPOCH, - }))); + assert!(should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Building, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(!should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Open, + build: GithubPrBuildState::Succeeded, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); + assert!(!should_offer_codex_ci_fix( + true, + Some(GithubPrStatus { + state: GithubPrState::Merged, + build: GithubPrBuildState::Failed, + review: GithubPrReviewState::Outstanding, + is_draft: false, + fetched_at: UNIX_EPOCH, + }) + )); assert!(should_offer_codex_ci_fix(true, None)); assert!(!should_offer_codex_ci_fix(false, None)); } @@ -2936,12 +2948,11 @@ mod tests { "Start from the existing checkout for GitHub issue https://github.com/micronaut-projects/micronaut-kafka/issues/873." )); assert!(prompt.contains("Primary checkout for this task: /tmp/work/micronaut-kafka-873")); - assert!(prompt.contains( - "write autonomous state updates to `/tmp/state/task-873.state`" - )); - assert!(prompt.contains( - "write autonomous state updates in the format `:thread-task-873`" - )); + assert!(prompt.contains("write autonomous state updates to `/tmp/state/task-873.state`")); + assert!( + prompt + .contains("write autonomous state updates in the format `:thread-task-873`") + ); assert!(prompt.contains("Use the existing pull request https://github.com/micronaut-projects/micronaut-kafka/pull/1308.")); assert!(prompt.contains("`machine-readable-pr`")); assert!(prompt.contains("`autonomous-state`")); @@ -2955,6 +2966,56 @@ mod tests { assert!(prompt.contains("Do not merge the pull request.")); } + #[test] + fn github_repository_spec_accepts_repo_and_github_urls() { + assert_eq!( + github_repository_spec("micronaut-projects/micronaut-kafka").as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec("https://github.com/micronaut-projects/micronaut-kafka") + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec( + "https://github.com/micronaut-projects/micronaut-kafka/issues/873" + ) + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert_eq!( + github_repository_spec( + "https://github.com/micronaut-projects/micronaut-kafka/pull/1308" + ) + .as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + assert!(github_repository_spec("https://example.com/not-github/repo").is_none()); + } + + #[test] + fn task_repository_spec_falls_back_to_task_issue_when_workspace_repo_missing() { + let mut started = snapshot(true, Some("ws://127.0.0.1:3456/")); + started.root_session_id = Some("thread-root".to_string()); + let issue_url = "https://github.com/micronaut-projects/micronaut-kafka/issues/873"; + let task_id = "task-873".to_string(); + started + .persistent + .tasks + .push(multicode_lib::WorkspaceTaskPersistentSnapshot::new( + task_id.clone(), + issue_url.to_string(), + multicode_lib::WorkspaceTaskSource::Manual, + )); + started.active_task_id = Some(task_id.clone()); + + assert_eq!( + task_repository_spec(&started, &task_id).as_deref(), + Some("micronaut-projects/micronaut-kafka") + ); + } + #[test] fn help_line_shows_open_github_for_selected_issue_on_task_row() { let started = snapshot(true, Some("http://example")); @@ -3994,6 +4055,45 @@ mod tests { assert!(!should_restart_codex_task_for_pr_request(Some(&task_state))); } + #[test] + fn ci_fix_restarts_idle_review_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Idle), + status: Some("Idle".to_string()), + ..Default::default() + }; + + assert!(should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + + #[test] + fn ci_fix_keeps_busy_working_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Working), + session_status: Some(RootSessionStatus::Busy), + status: Some("Active".to_string()), + ..Default::default() + }; + + assert!(!should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + + #[test] + fn ci_fix_restarts_question_task_session() { + let task_state = WorkspaceTaskRuntimeSnapshot { + session_id: Some("session-1".to_string()), + agent_state: Some(AutomationAgentState::Review), + session_status: Some(RootSessionStatus::Question), + status: Some("Idle".to_string()), + ..Default::default() + }; + + assert!(should_restart_codex_task_for_ci_fix(Some(&task_state))); + } + #[test] fn workspace_ordering_keeps_archived_last_and_newest_first() { let mut snapshots = HashMap::new(); From aaabe17dc5b7fcfd2d4621e3aaa12754f69b7ee8 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 15:20:47 +0200 Subject: [PATCH 71/75] Add awaiting-validation issue triage flow Co-Authored-By: OpenAI Codex --- .../services/autonomous_workspace_service.rs | 202 +++++++++++++++++- 1 file changed, 194 insertions(+), 8 deletions(-) diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 87125d5..1e71626 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -51,6 +51,7 @@ const DEPENDENCY_UPGRADE_PR_MARKER_PREFIX: &str = "", marker_prefix = DEPENDENCY_UPGRADE_PR_BATCH_MARKER_PREFIX ) } +fn single_dependency_upgrade_issue_body(pr: &SelectedPullRequest) -> String { + format!( + "Track dependency-upgrade automation for Renovate pull request {pr_url}.\n\n\ +Current Renovate candidate:\n\ +- {pr_url} ({pr_title})\n\n\ +This issue was created automatically by multicode to prrocess the PR.\n\ +If the update is still a non-major version bump and CI is passing, rebase and merge the PR without waiting for human review, then close this issue.\n\n\ +{marker_prefix}{pr_url} -->", + pr_url = pr.url, + pr_title = pr.title, + marker_prefix = DEPENDENCY_UPGRADE_PR_MARKER_PREFIX + ) +} + fn dependency_upgrade_pr_urls_from_pull_requests(prs: &[SelectedPullRequest]) -> Vec { prs.iter().map(|pr| pr.url.clone()).collect() } @@ -7728,6 +7906,12 @@ mod tests { assert!(prompt.contains( "Treat https://github.com/example/repo/pull/999 as the single combined dependency-upgrade PR" )); + assert!(prompt.contains( + "Monitor CI for https://github.com/example/repo/pull/999 until it completes." + )); + assert!(prompt.contains( + "push follow-up fixes to the combined PR, and keep monitoring until all required checks are green" + )); assert!(prompt.contains("Do not merge the individual Renovate PRs directly.")); assert!(prompt.contains("close GitHub issue https://github.com/example/repo/issues/981")); assert!(prompt.contains( @@ -7736,6 +7920,40 @@ mod tests { assert!(!prompt.contains("explicitly approves publishing")); } + #[test] + fn build_issue_prompt_for_single_dependency_upgrade_merges_direct_pr() { + let mut issue = test_issue( + 982, + "single dependency upgrade", + "https://github.com/example/repo/issues/982", + "2026-04-09T10:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + issue.dependency_upgrade_pr_urls = + vec!["https://github.com/example/repo/pull/999".to_string()]; + + let prompt = build_issue_prompt( + "example/repo", + &issue, + Some("https://github.com/example/repo/pull/999"), + "thread-task-982", + std::path::Path::new("/tmp/work/example-repo-982"), + std::path::Path::new("/tmp/state/task-982.state"), + ); + + assert!(prompt.contains("tracks the following Renovate pull request")); + assert!(prompt.contains( + "Confirm https://github.com/example/repo/pull/999 is still a safe non-major update." + )); + assert!( + prompt.contains("rebase and merge https://github.com/example/repo/pull/999 directly") + ); + assert!(prompt.contains("Do not create a combined dependency-upgrade pull request")); + assert!(!prompt.contains("Treat https://github.com/example/repo/pull/999 as the single combined dependency-upgrade PR")); + } + #[test] fn dependency_upgrade_issue_body_uses_updated_queue_text() { let body = dependency_upgrade_issue_body(&[ @@ -7763,11 +7981,38 @@ mod tests { assert!(body.contains("https://github.com/example/repo/pull/91")); assert!(body.contains("https://github.com/example/repo/pull/92")); assert!(body.contains( - "Create or update a single combined dependency-upgrade pull request associated with this issue" + "Create or update a single combined dependency-upgrade pull request associated with this issue." + )); + assert!(body.contains( + "Monitor CI for that combined PR until it completes; if any required check fails, investigate it, push follow-up fixes, and keep monitoring until all required checks are green." )); assert!(body.contains("Do not merge the individual Renovate PRs directly.")); } + #[test] + fn single_dependency_upgrade_issue_body_uses_direct_merge_text() { + let body = single_dependency_upgrade_issue_body(&test_pull_request( + 734, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/example/repo/pull/734", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert!(body.contains("Track dependency-upgrade automation for Renovate pull request https://github.com/example/repo/pull/734.")); + assert!( + body.contains("This issue was created automatically by multicode to prrocess the PR.") + ); + assert!(body.contains("rebase and merge the PR without waiting for human review")); + assert!(!body.contains("single combined dependency-upgrade pull request")); + assert_eq!( + extract_dependency_upgrade_pr_urls(&body), + vec!["https://github.com/example/repo/pull/734".to_string()] + ); + } + #[test] fn merged_dependency_upgrade_issue_urls_ignores_non_dependency_tasks() { let mut snapshot = WorkspaceSnapshot::default(); @@ -7928,6 +8173,21 @@ mod tests { ); } + #[test] + fn single_dependency_upgrade_issue_title_uses_pull_request_number() { + let title = single_dependency_upgrade_issue_title(&test_pull_request( + 728, + "fix(deps): update dependency io.lettuce:lettuce-core to v7.5.1.release", + "https://github.com/example/repo/pull/728", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + None, + )); + + assert_eq!(title, "Dependency upgrade follow-up for Renovate PR #728"); + } + #[test] fn dependency_upgrade_versions_from_arrow_text_parses_renovate_table_rows() { let body = "This PR contains the following updates:\n\n| Package | Change |\n|---|---|\n| io.micronaut.security:micronaut-security-bom | `4.16.1` β†’ `4.17.1` |\n"; From fa5105848835ff5c56465fdfb4231e6b866f0d8c Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 19:18:16 +0200 Subject: [PATCH 73/75] Add issue type column to TUI Co-Authored-By: Codex --- lib/src/lib.rs | 19 ++ .../services/autonomous_workspace_service.rs | 263 +++++++++++++++++- tui/src/main.rs | 43 ++- tui/src/render.rs | 13 +- tui/src/tests.rs | 27 +- 5 files changed, 344 insertions(+), 21 deletions(-) diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 20ee16d..b422bcb 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -69,6 +69,17 @@ pub enum WorkspaceTaskSource { Scan, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WorkspaceIssueType { + Bug, + Docs, + Enhancement, + Improvement, + Regression, + DependencyUpgrade, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceTaskPersistentSnapshot { pub id: String, @@ -78,6 +89,8 @@ pub struct WorkspaceTaskPersistentSnapshot { #[serde(default)] pub dependency_upgrade_backing_pr: bool, #[serde(default)] + pub issue_type: Option, + #[serde(default)] pub source: WorkspaceTaskSource, #[serde(default)] pub created_at: Option, @@ -90,6 +103,7 @@ impl WorkspaceTaskPersistentSnapshot { issue_url, backing_pr_url: None, dependency_upgrade_backing_pr: false, + issue_type: None, source, created_at: Some(SystemTime::now()), } @@ -107,6 +121,11 @@ impl WorkspaceTaskPersistentSnapshot { self.dependency_upgrade_backing_pr = dependency_upgrade_backing_pr; self } + + pub fn with_issue_type(mut self, issue_type: Option) -> Self { + self.issue_type = issue_type; + self + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index 23dc480..be98e61 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -29,9 +29,9 @@ use super::{ workspace_watch::monitor_workspace_snapshots, }; use crate::{ - AutomationAgentState, RootSessionStatus, WorkspaceManagerError, WorkspaceSnapshot, - WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, manager::Workspace, opencode, - services::config::AgentProvider, + AutomationAgentState, RootSessionStatus, WorkspaceIssueType, WorkspaceManagerError, + WorkspaceSnapshot, WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, manager::Workspace, + opencode, services::config::AgentProvider, }; const ISSUE_PRIORITY_LABELS: [&str; 4] = [ @@ -55,6 +55,16 @@ const IN_PROGRESS_LABEL: &str = "status: in progress"; const IN_PROGRESS_LABEL_ALIASES: [&str; 2] = [IN_PROGRESS_LABEL, "status: in-progress"]; const AWAITING_VALIDATION_LABEL: &str = "status: awaiting validation"; const ISSUE_SCAN_RETRY_DELAY: Duration = Duration::from_secs(60); +const DOC_ISSUE_LABELS: [&str; 4] = [ + "type: docs", + "type:docs", + "status: docs", + "status: documentation", +]; +const BUG_ISSUE_LABELS: [&str; 2] = ["type: bug", "status: bug"]; +const REGRESSION_ISSUE_LABELS: [&str; 2] = ["type: regression", "status: regression"]; +const IMPROVEMENT_ISSUE_LABELS: [&str; 1] = ["type: improvement"]; +const ENHANCEMENT_ISSUE_LABELS: [&str; 1] = ["type: enhancement"]; #[derive(Debug, Clone)] struct QueuedIssueCandidate { @@ -1033,10 +1043,15 @@ async fn refresh_existing_task_backing_pr_urls( let mut updates = Vec::new(); for task in &snapshot.persistent.tasks { + let fetched_issue = if task.issue_type.is_none() { + fetch_issue(assigned_repository, &task.issue_url, token).await? + } else { + None + }; let Some(issue_number) = issue_number_from_url(&task.issue_url) else { continue; }; - let issue = SelectedIssue { + let issue = fetched_issue.unwrap_or(SelectedIssue { number: issue_number, title: String::new(), url: task.issue_url.clone(), @@ -1046,11 +1061,13 @@ async fn refresh_existing_task_backing_pr_urls( body: None, labels: Vec::new(), dependency_upgrade_pr_urls: Vec::new(), - }; + }); let discovered = discover_issue_backing_pr_url(assigned_repository, &issue, &open_pull_requests); - if discovered.as_deref() != task.backing_pr_url.as_deref() { - updates.push((task.id.clone(), discovered)); + let issue_type = issue.issue_type(); + if discovered.as_deref() != task.backing_pr_url.as_deref() || issue_type != task.issue_type + { + updates.push((task.id.clone(), discovered, issue_type)); } } @@ -1060,16 +1077,21 @@ async fn refresh_existing_task_backing_pr_urls( workspace.update(|next| { let mut changed = false; - for (task_id, backing_pr_url) in &updates { + for (task_id, backing_pr_url, issue_type) in &updates { if let Some(task) = next .persistent .tasks .iter_mut() .find(|task| &task.id == task_id) - && task.backing_pr_url != *backing_pr_url { - task.backing_pr_url = backing_pr_url.clone(); - changed = true; + if task.backing_pr_url != *backing_pr_url { + task.backing_pr_url = backing_pr_url.clone(); + changed = true; + } + if task.issue_type != *issue_type { + task.issue_type = *issue_type; + changed = true; + } } } changed @@ -1086,6 +1108,7 @@ fn ensure_workspace_task_claim( dependency_upgrade_backing_pr: bool, source: WorkspaceTaskSource, ) { + let issue_type = issue.issue_type(); workspace.update(|snapshot| { let mut changed = false; if snapshot.persistent.assigned_repository.as_deref() != Some(assigned_repository) { @@ -1099,6 +1122,7 @@ fn ensure_workspace_task_claim( source, ) .with_backing_pr_url(backing_pr_url.map(ToOwned::to_owned)) + .with_issue_type(issue_type) .with_dependency_upgrade_backing_pr(dependency_upgrade_backing_pr); let task_id = task.id.clone(); snapshot.persistent.tasks.push(task); @@ -1118,6 +1142,10 @@ fn ensure_workspace_task_claim( task.dependency_upgrade_backing_pr = dependency_upgrade_backing_pr; changed = true; } + if task.issue_type != issue_type { + task.issue_type = issue_type; + changed = true; + } } if snapshot.active_task_id.as_deref() != Some(task_id.as_str()) { snapshot.active_task_id = Some(task_id); @@ -1134,6 +1162,7 @@ fn queue_issue_task( dependency_upgrade_backing_pr: bool, source: WorkspaceTaskSource, ) { + let issue_type = issue.issue_type(); workspace.update(|snapshot| { if let Some(existing) = snapshot .persistent @@ -1157,6 +1186,10 @@ fn queue_issue_task( task.dependency_upgrade_backing_pr = dependency_upgrade_backing_pr; changed = true; } + if task.issue_type != issue_type { + task.issue_type = issue_type; + changed = true; + } return changed; } return false; @@ -1168,6 +1201,7 @@ fn queue_issue_task( source, ) .with_backing_pr_url(backing_pr_url.map(ToOwned::to_owned)) + .with_issue_type(issue_type) .with_dependency_upgrade_backing_pr(dependency_upgrade_backing_pr), ); true @@ -4950,6 +4984,37 @@ impl SelectedIssue { .as_deref() .and_then(extract_dependency_upgrade_pr_marker) } + + fn issue_type(&self) -> Option { + if self.has_label(DEPENDENCY_UPGRADE_LABEL) { + return Some(WorkspaceIssueType::DependencyUpgrade); + } + if REGRESSION_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Regression); + } + if BUG_ISSUE_LABELS.iter().any(|label| self.has_label(label)) { + return Some(WorkspaceIssueType::Bug); + } + if DOC_ISSUE_LABELS.iter().any(|label| self.has_label(label)) { + return Some(WorkspaceIssueType::Docs); + } + if IMPROVEMENT_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Improvement); + } + if ENHANCEMENT_ISSUE_LABELS + .iter() + .any(|label| self.has_label(label)) + { + return Some(WorkspaceIssueType::Enhancement); + } + None + } } fn issue_priority_cmp(left: &SelectedIssue, right: &SelectedIssue) -> std::cmp::Ordering { @@ -5133,14 +5198,85 @@ mod tests { use crate::services::github_status_service::{ GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, }; + use crate::test_support::ENV_VAR_LOCK; use std::{ collections::HashMap, fs, + os::unix::fs::PermissionsExt, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; use tokio::sync::watch; + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("multicode-autonomous-tests-{unique}")); + fs::create_dir_all(&path).expect("temp dir should be created"); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, value: &std::path::Path) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(original) = &self.original { + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::set_var(&self.key, original); + } + } else { + // SAFETY: tests in this module serialize env-var mutation with ENV_VAR_LOCK. + unsafe { + std::env::remove_var(&self.key); + } + } + } + } + + fn make_executable(path: &std::path::Path) { + let mut permissions = fs::metadata(path) + .expect("metadata should exist") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions should be set"); + } + #[test] fn normalize_github_repository_spec_accepts_owner_repo_and_urls() { assert_eq!( @@ -5654,6 +5790,47 @@ mod tests { assert!(!should_assign_issue_to_current_user(&issue)); } + #[test] + fn selected_issue_derives_issue_type_from_labels() { + let regression = test_issue( + 14, + "regression", + "https://github.com/example/repo/issues/14", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: "type: regression".to_string(), + }], + ); + let docs = test_issue( + 15, + "docs", + "https://github.com/example/repo/issues/15", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: "type: docs".to_string(), + }], + ); + let dependency = test_issue( + 16, + "deps", + "https://github.com/example/repo/issues/16", + "2026-04-09T12:00:00Z", + vec![SelectedIssueLabel { + name: DEPENDENCY_UPGRADE_LABEL.to_string(), + }], + ); + + assert_eq!( + regression.issue_type(), + Some(WorkspaceIssueType::Regression) + ); + assert_eq!(docs.issue_type(), Some(WorkspaceIssueType::Docs)); + assert_eq!( + dependency.issue_type(), + Some(WorkspaceIssueType::DependencyUpgrade) + ); + } + #[test] fn select_validation_issue_candidate_skips_excluded_and_claimed_issues() { let excluded = HashSet::from(["https://github.com/example/repo/issues/13".to_string()]); @@ -5939,7 +6116,9 @@ mod tests { "candidate", "https://github.com/example/repo/issues/810", "2026-04-09T10:00:00Z", - vec![], + vec![SelectedIssueLabel { + name: "type: bug".to_string(), + }], ); ensure_workspace_task_claim( @@ -5963,9 +6142,69 @@ mod tests { snapshot.persistent.tasks[0].source, WorkspaceTaskSource::Scan ); + assert_eq!( + snapshot.persistent.tasks[0].issue_type, + Some(WorkspaceIssueType::Bug) + ); assert_eq!(snapshot.active_task_id.as_deref(), Some("task-810")); } + #[test] + fn refresh_existing_task_backing_pr_urls_backfills_missing_issue_type() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' '[]'\n exit 0\nfi\nif [ \"$1\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' '{\"number\":42,\"title\":\"Investigate redis issue\",\"url\":\"https://github.com/example/repo/issues/42\",\"createdAt\":\"2026-04-09T10:00:00Z\",\"state\":\"OPEN\",\"body\":null,\"labels\":[{\"name\":\"type: bug\"}]}'\n exit 0\nfi\nexit 1\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + snapshot + .persistent + .tasks + .push(WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + )); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + let updated = refresh_existing_task_backing_pr_urls( + &workspace, + &snapshot, + "example/repo", + "test-token", + ) + .await + .expect("refresh should succeed"); + + assert_eq!(updated, 1); + let next = workspace.subscribe().borrow().clone(); + assert_eq!( + next.persistent.tasks[0].issue_type, + Some(WorkspaceIssueType::Bug) + ); + }); + } + #[test] fn sync_task_runtime_state_prunes_stale_entries_and_derives_active_task() { let workspace = Workspace::new(WorkspaceSnapshot::default()); diff --git a/tui/src/main.rs b/tui/src/main.rs index efefdd3..671812b 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -14,8 +14,8 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use multicode_lib::{ - AutomationAgentState, RootSessionStatus, WorkspaceSnapshot, WorkspaceTaskPersistentSnapshot, - WorkspaceTaskRuntimeSnapshot, logging, opencode, + AutomationAgentState, RootSessionStatus, WorkspaceIssueType, WorkspaceSnapshot, + WorkspaceTaskPersistentSnapshot, WorkspaceTaskRuntimeSnapshot, logging, opencode, services::{ CombinedService, GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, GithubPrStatus, GithubStatus, ToolConfig, ToolType, @@ -59,6 +59,7 @@ const OOM_COLOR: Color = Color::Red; const RAM_LIMIT_WARNING_HEADROOM_BYTES: u64 = 512 * 1024 * 1024; const RAM_COLUMN_WIDTH: u16 = 10; const LINK_COLUMN_WIDTH: u16 = 2; +const TYPE_COLUMN_WIDTH: u16 = 2; const STATUS_COLUMN_WIDTH: u16 = 2; const REVIEW_STATUS_COLUMN_WIDTH: u16 = 2; const CPU_COLUMN_MIN_WIDTH: u16 = 5; @@ -470,6 +471,40 @@ fn task_pr_created_status( .map(|reference| format!("PR created {reference}")) } +fn workspace_active_task<'a>( + snapshot: &'a WorkspaceSnapshot, +) -> Option<&'a WorkspaceTaskPersistentSnapshot> { + snapshot + .active_task_id + .clone() + .or_else(|| snapshot.resolved_active_task_id()) + .and_then(|task_id| snapshot.task_persistent_snapshot(&task_id)) +} + +fn workspace_issue_type(snapshot: &WorkspaceSnapshot) -> Option { + workspace_active_task(snapshot).and_then(|task| task.issue_type) +} + +fn issue_type_emoji(issue_type: Option) -> &'static str { + match issue_type { + Some(WorkspaceIssueType::Bug) => "🐞", + Some(WorkspaceIssueType::Docs) => "πŸ“", + Some(WorkspaceIssueType::Enhancement) => "✨", + Some(WorkspaceIssueType::Improvement) => "πŸ”§", + Some(WorkspaceIssueType::Regression) => "πŸ”", + Some(WorkspaceIssueType::DependencyUpgrade) => "πŸ“¦", + None => "", + } +} + +fn issue_type_cell(issue_type: Option, archived: bool) -> Cell<'static> { + let mut cell = Cell::from(issue_type_emoji(issue_type)); + if archived && issue_type.is_some() { + cell = cell.style(Style::default().fg(Color::DarkGray)); + } + cell +} + fn is_generic_review_task_status(status: &str) -> bool { let status = status.trim(); status.starts_with("Review ") @@ -1334,7 +1369,7 @@ fn table_column_widths( create_row_server: &str, create_row_cpu: &str, create_row_ram: &str, -) -> (u16, u16, u16, u16, u16, u16, u16, u16, u16, u16) { +) -> (u16, u16, u16, u16, u16, u16, u16, u16, u16, u16, u16) { let mut workspace_width = content_width("Workspace").max(content_width(CREATE_ROW_LABEL)); let mut server_width = content_width("Server").max(content_width(create_row_server)); let mut cpu_width = content_width("CPU") @@ -1348,6 +1383,7 @@ fn table_column_widths( let mut cost_width = content_width("Cost"); let re_width = content_width("RE").max(LINK_COLUMN_WIDTH); let is_width = content_width("IS").max(LINK_COLUMN_WIDTH); + let t_width = content_width("T").max(TYPE_COLUMN_WIDTH); let pr_width = content_width("PR").max(LINK_COLUMN_WIDTH); let build_width = content_width("B").max(STATUS_COLUMN_WIDTH); let review_width = content_width("R").max(REVIEW_STATUS_COLUMN_WIDTH); @@ -1376,6 +1412,7 @@ fn table_column_widths( cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, diff --git a/tui/src/render.rs b/tui/src/render.rs index bb175ad..f371bf2 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -27,6 +27,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -75,6 +76,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(""), Cell::from(""), Cell::from(""), + Cell::from(""), Cell::from(create_row_description), ]) .style(Style::default().fg(CREATE_ROW_COLOR)), @@ -154,6 +156,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } else { Cell::default() }; + let task_type_cell = issue_type_cell(workspace_issue_type(snapshot), archived); let (pr_cell, build_cell, review_status_cell) = if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { let (kind, color) = pr_icon_kind_and_color(*pr_status); @@ -224,6 +227,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(cost), review_cell, issue_cell, + task_type_cell, pr_cell, build_cell, review_status_cell, @@ -350,6 +354,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } else { (Cell::default(), Cell::default()) }; + let task_type_cell = issue_type_cell(task.issue_type, archived); rows.push( Row::new(vec![ Cell::from(task_row_label(task)), @@ -360,6 +365,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(cost), Cell::from(""), issue_cell, + task_type_cell, pr_cell, build_cell, review_status_cell, @@ -382,6 +388,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Constraint::Length(cost_width), Constraint::Length(re_width), Constraint::Length(is_width), + Constraint::Length(t_width), Constraint::Length(pr_width), Constraint::Length(build_width), Constraint::Length(review_width), @@ -398,6 +405,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { Cell::from(right_align_cell_text("Cost", cost_width)), Cell::from("RE"), Cell::from("IS"), + Cell::from("T"), Cell::from("PR"), Cell::from("B"), Cell::from("R"), @@ -542,6 +550,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -611,7 +620,7 @@ pub(crate) fn selected_link_tooltip_area( selected_row: usize, selected_link_kind: WorkspaceLinkKind, targets: &[(String, bool)], - column_widths: [u16; 10], + column_widths: [u16; 11], ) -> Option { if selected_row == 0 || targets.is_empty() { return None; @@ -627,7 +636,7 @@ pub(crate) fn selected_link_tooltip_area( let tooltip_column_index = match selected_link_kind { WorkspaceLinkKind::Review => 5, WorkspaceLinkKind::Issue => 6, - WorkspaceLinkKind::Pr => 7, + WorkspaceLinkKind::Pr => 8, }; let mut x = table_inner.x; for width in column_widths.iter().take(tooltip_column_index) { diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 073b365..2cd3956 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -437,6 +437,23 @@ mod tests { ); } + #[test] + fn issue_type_emoji_maps_known_issue_types() { + assert_eq!( + crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::Bug)), + "🐞" + ); + assert_eq!( + crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::Docs)), + "πŸ“" + ); + assert_eq!( + crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::DependencyUpgrade)), + "πŸ“¦" + ); + assert_eq!(crate::issue_type_emoji(None), ""); + } + #[test] fn workspace_links_prefer_active_task_issue_and_pr_when_tasks_exist() { let mut started = snapshot(true, Some("http://example")); @@ -2128,7 +2145,7 @@ mod tests { 2, WorkspaceLinkKind::Review, &targets, - [10, 10, 5, 5, 5, 2, 2, 2, 2, 2], + [10, 10, 5, 5, 5, 2, 2, 2, 2, 2, 2], ) .expect("tooltip area should exist"); @@ -2149,11 +2166,11 @@ mod tests { 4, WorkspaceLinkKind::Pr, &targets, - [10, 10, 5, 5, 5, 2, 2, 2, 2, 2], + [10, 10, 5, 5, 5, 2, 2, 2, 2, 2, 2], ) .expect("tooltip area should exist"); - assert_eq!(area.x, 47); + assert_eq!(area.x, 50); assert_eq!(area.y, 1); assert_eq!(area.height, 5); } @@ -3958,6 +3975,7 @@ mod tests { cost_width, re_width, is_width, + t_width, pr_width, build_width, review_width, @@ -3986,6 +4004,7 @@ mod tests { assert!(cost_width >= content_width("$12.34")); assert_eq!(re_width, content_width("RE").max(LINK_COLUMN_WIDTH)); assert_eq!(is_width, content_width("IS").max(LINK_COLUMN_WIDTH)); + assert_eq!(t_width, content_width("T").max(TYPE_COLUMN_WIDTH)); assert_eq!(pr_width, content_width("PR").max(LINK_COLUMN_WIDTH)); assert_eq!(build_width, content_width("B").max(STATUS_COLUMN_WIDTH)); assert_eq!( @@ -4017,7 +4036,7 @@ mod tests { snapshots.insert("e2e-test".to_string(), workspace); let ordered_keys = vec!["e2e-test".to_string()]; - let (_, server_width, _, _, cost_width, _, _, _, _, _) = table_column_widths( + let (_, server_width, _, _, cost_width, _, _, _, _, _, _) = table_column_widths( &ordered_keys, &snapshots, "Machine:", From 04979cf20e9b045e1f4f5627870aa032e3f812c0 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 19:37:51 +0200 Subject: [PATCH 74/75] Use Nerd Font icons for issue type column Co-Authored-By: Codex --- Cargo.lock | 1 + tui/Cargo.toml | 1 + tui/src/icons.rs | 19 +++++++++++++++++++ tui/src/main.rs | 38 +++++++++++++++++++------------------- tui/src/render.rs | 1 + tui/src/tests.rs | 30 ++++++++++++++++++++++-------- 6 files changed, 63 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20d6729..5ac1af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1817,6 +1817,7 @@ dependencies = [ "tokio", "toml 1.0.6+spec-1.1.0", "tracing", + "unicode-width", "url", ] diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 9f4f9af..22b0c85 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -16,3 +16,4 @@ size = "0" toml = "1" rustix = { version = "1", features = ["fs"] } serde_json = "1" +unicode-width = "0.2" diff --git a/tui/src/icons.rs b/tui/src/icons.rs index f25555e..8342afb 100644 --- a/tui/src/icons.rs +++ b/tui/src/icons.rs @@ -1,5 +1,18 @@ use crate::*; +pub(crate) fn issue_type_icon_kind_and_color( + issue_type: WorkspaceIssueType, +) -> (StatusIconKind, Color) { + match issue_type { + WorkspaceIssueType::Bug => (StatusIconKind::Bug, Color::Red), + WorkspaceIssueType::Docs => (StatusIconKind::Docs, Color::LightBlue), + WorkspaceIssueType::Enhancement => (StatusIconKind::Enhancement, Color::Green), + WorkspaceIssueType::Improvement => (StatusIconKind::Improvement, Color::Yellow), + WorkspaceIssueType::Regression => (StatusIconKind::Regression, Color::Magenta), + WorkspaceIssueType::DependencyUpgrade => (StatusIconKind::DependencyUpgrade, Color::Cyan), + } +} + pub(crate) fn issue_icon_kind_and_color(state: GithubIssueState) -> (StatusIconKind, Color) { match state { GithubIssueState::Open => (StatusIconKind::IssueOpened, Color::Green), @@ -49,6 +62,12 @@ pub(crate) fn icon_glyph(kind: StatusIconKind) -> &'static str { StatusIconKind::Eye => "\u{f441}", StatusIconKind::Server => "\u{f473}", StatusIconKind::FileDiff => "\u{f4d2}", + StatusIconKind::Bug => "\u{f188}", + StatusIconKind::Docs => "\u{f02d}", + StatusIconKind::Enhancement => "\u{f135}", + StatusIconKind::Improvement => "\u{f0ad}", + StatusIconKind::Regression => "\u{f1da}", + StatusIconKind::DependencyUpgrade => "\u{f1b2}", StatusIconKind::GitPullRequest => "\u{f407}", StatusIconKind::GitPullRequestDraft => "\u{f4dd}", StatusIconKind::GitPullRequestClosed => "\u{f4dc}", diff --git a/tui/src/main.rs b/tui/src/main.rs index 671812b..ac32bfb 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -35,6 +35,7 @@ use tokio::{ process::Command, sync::{oneshot, watch}, }; +use unicode_width::UnicodeWidthStr; use url::Url; use crate::render::draw_ui; @@ -47,6 +48,8 @@ mod system; #[cfg(test)] mod tests; +use crate::icons::{icon_glyph, issue_type_icon_kind_and_color}; + const CREATE_ROW_LABEL: &str = "Create new workspace…"; const SECONDARY_ROW_COLOR: Color = Color::DarkGray; const CREATE_ROW_COLOR: Color = Color::LightBlue; @@ -59,7 +62,7 @@ const OOM_COLOR: Color = Color::Red; const RAM_LIMIT_WARNING_HEADROOM_BYTES: u64 = 512 * 1024 * 1024; const RAM_COLUMN_WIDTH: u16 = 10; const LINK_COLUMN_WIDTH: u16 = 2; -const TYPE_COLUMN_WIDTH: u16 = 2; +const TYPE_COLUMN_WIDTH: u16 = 1; const STATUS_COLUMN_WIDTH: u16 = 2; const REVIEW_STATUS_COLUMN_WIDTH: u16 = 2; const CPU_COLUMN_MIN_WIDTH: u16 = 5; @@ -302,6 +305,12 @@ enum StatusIconKind { Eye, Server, FileDiff, + Bug, + Docs, + Enhancement, + Improvement, + Regression, + DependencyUpgrade, GitPullRequest, GitPullRequestDraft, GitPullRequestClosed, @@ -485,24 +494,15 @@ fn workspace_issue_type(snapshot: &WorkspaceSnapshot) -> Option) -> &'static str { - match issue_type { - Some(WorkspaceIssueType::Bug) => "🐞", - Some(WorkspaceIssueType::Docs) => "πŸ“", - Some(WorkspaceIssueType::Enhancement) => "✨", - Some(WorkspaceIssueType::Improvement) => "πŸ”§", - Some(WorkspaceIssueType::Regression) => "πŸ”", - Some(WorkspaceIssueType::DependencyUpgrade) => "πŸ“¦", - None => "", - } -} - fn issue_type_cell(issue_type: Option, archived: bool) -> Cell<'static> { - let mut cell = Cell::from(issue_type_emoji(issue_type)); - if archived && issue_type.is_some() { - cell = cell.style(Style::default().fg(Color::DarkGray)); - } - cell + issue_type.map_or_else(Cell::default, |issue_type| { + let (kind, color) = issue_type_icon_kind_and_color(issue_type); + Cell::from(icon_glyph(kind)).style( + Style::default() + .fg(if archived { Color::DarkGray } else { color }) + .bg(Color::Reset), + ) + }) } fn is_generic_review_task_status(status: &str) -> bool { @@ -1351,7 +1351,7 @@ fn next_link_selection_right(current: Option, link_count: usize) -> Optio } fn content_width(text: &str) -> u16 { - text.chars().count().min(u16::MAX as usize) as u16 + UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16 } fn right_align_cell_text(text: &str, width: u16) -> String { diff --git a/tui/src/render.rs b/tui/src/render.rs index f371bf2..2aeacc7 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -422,6 +422,7 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { let mut table_state = TableState::default(); table_state.select(Some(app.selected_row)); + frame.render_widget(Clear, chunks[0]); frame.render_stateful_widget(table, chunks[0], &mut table_state); let help = help_line( diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 2cd3956..128f6cc 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -438,20 +438,28 @@ mod tests { } #[test] - fn issue_type_emoji_maps_known_issue_types() { + fn issue_type_icon_mappings_use_expected_glyph_kinds() { assert_eq!( - crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::Bug)), - "🐞" + crate::icons::issue_type_icon_kind_and_color(multicode_lib::WorkspaceIssueType::Bug), + (StatusIconKind::Bug, Color::Red) ); assert_eq!( - crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::Docs)), - "πŸ“" + crate::icons::issue_type_icon_kind_and_color(multicode_lib::WorkspaceIssueType::Docs), + (StatusIconKind::Docs, Color::LightBlue) ); assert_eq!( - crate::issue_type_emoji(Some(multicode_lib::WorkspaceIssueType::DependencyUpgrade)), - "πŸ“¦" + crate::icons::issue_type_icon_kind_and_color( + multicode_lib::WorkspaceIssueType::DependencyUpgrade + ), + (StatusIconKind::DependencyUpgrade, Color::Cyan) ); - assert_eq!(crate::issue_type_emoji(None), ""); + } + + #[test] + fn content_width_uses_terminal_display_width() { + assert_eq!(crate::content_width("A"), 1); + assert_eq!(crate::content_width(icon_glyph(StatusIconKind::Bug)), 1); + assert_eq!(crate::content_width(icon_glyph(StatusIconKind::Docs)), 1); } #[test] @@ -2211,6 +2219,12 @@ mod tests { StatusIconKind::Eye, StatusIconKind::Server, StatusIconKind::FileDiff, + StatusIconKind::Bug, + StatusIconKind::Docs, + StatusIconKind::Enhancement, + StatusIconKind::Improvement, + StatusIconKind::Regression, + StatusIconKind::DependencyUpgrade, StatusIconKind::GitPullRequest, StatusIconKind::GitPullRequestDraft, StatusIconKind::GitPullRequestClosed, From c0674a7483a23be6f23ade7e09e4356682472e39 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Fri, 17 Apr 2026 19:46:02 +0200 Subject: [PATCH 75/75] Persist issue type glyph metadata Co-Authored-By: Codex --- lib/src/lib.rs | 21 +++- .../services/autonomous_workspace_service.rs | 96 +++++++++++++++++-- tui/src/main.rs | 9 +- tui/src/render.rs | 10 +- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/lib/src/lib.rs b/lib/src/lib.rs index b422bcb..e8ce221 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -80,6 +80,17 @@ pub enum WorkspaceIssueType { DependencyUpgrade, } +pub fn workspace_issue_type_glyph(issue_type: WorkspaceIssueType) -> &'static str { + match issue_type { + WorkspaceIssueType::Bug => "\u{f188}", + WorkspaceIssueType::Docs => "\u{f02d}", + WorkspaceIssueType::Enhancement => "\u{f135}", + WorkspaceIssueType::Improvement => "\u{f0ad}", + WorkspaceIssueType::Regression => "\u{f1da}", + WorkspaceIssueType::DependencyUpgrade => "\u{f1b2}", + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceTaskPersistentSnapshot { pub id: String, @@ -91,6 +102,8 @@ pub struct WorkspaceTaskPersistentSnapshot { #[serde(default)] pub issue_type: Option, #[serde(default)] + pub issue_type_glyph: Option, + #[serde(default)] pub source: WorkspaceTaskSource, #[serde(default)] pub created_at: Option, @@ -104,6 +117,7 @@ impl WorkspaceTaskPersistentSnapshot { backing_pr_url: None, dependency_upgrade_backing_pr: false, issue_type: None, + issue_type_glyph: None, source, created_at: Some(SystemTime::now()), } @@ -123,9 +137,14 @@ impl WorkspaceTaskPersistentSnapshot { } pub fn with_issue_type(mut self, issue_type: Option) -> Self { - self.issue_type = issue_type; + self.set_issue_type(issue_type); self } + + pub fn set_issue_type(&mut self, issue_type: Option) { + self.issue_type = issue_type; + self.issue_type_glyph = issue_type.map(|kind| workspace_issue_type_glyph(kind).to_string()); + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] diff --git a/lib/src/services/autonomous_workspace_service.rs b/lib/src/services/autonomous_workspace_service.rs index be98e61..9d44292 100644 --- a/lib/src/services/autonomous_workspace_service.rs +++ b/lib/src/services/autonomous_workspace_service.rs @@ -31,7 +31,7 @@ use super::{ use crate::{ AutomationAgentState, RootSessionStatus, WorkspaceIssueType, WorkspaceManagerError, WorkspaceSnapshot, WorkspaceTaskPersistentSnapshot, WorkspaceTaskSource, manager::Workspace, - opencode, services::config::AgentProvider, + opencode, services::config::AgentProvider, workspace_issue_type_glyph, }; const ISSUE_PRIORITY_LABELS: [&str; 4] = [ @@ -1064,10 +1064,13 @@ async fn refresh_existing_task_backing_pr_urls( }); let discovered = discover_issue_backing_pr_url(assigned_repository, &issue, &open_pull_requests); - let issue_type = issue.issue_type(); - if discovered.as_deref() != task.backing_pr_url.as_deref() || issue_type != task.issue_type + let issue_type = issue.issue_type().or(task.issue_type); + let issue_type_glyph = issue_type.map(|kind| workspace_issue_type_glyph(kind).to_string()); + if discovered.as_deref() != task.backing_pr_url.as_deref() + || issue_type != task.issue_type + || issue_type_glyph != task.issue_type_glyph { - updates.push((task.id.clone(), discovered, issue_type)); + updates.push((task.id.clone(), discovered, issue_type, issue_type_glyph)); } } @@ -1077,7 +1080,7 @@ async fn refresh_existing_task_backing_pr_urls( workspace.update(|next| { let mut changed = false; - for (task_id, backing_pr_url, issue_type) in &updates { + for (task_id, backing_pr_url, issue_type, issue_type_glyph) in &updates { if let Some(task) = next .persistent .tasks @@ -1089,7 +1092,10 @@ async fn refresh_existing_task_backing_pr_urls( changed = true; } if task.issue_type != *issue_type { - task.issue_type = *issue_type; + task.set_issue_type(*issue_type); + changed = true; + } else if task.issue_type_glyph != *issue_type_glyph { + task.issue_type_glyph = issue_type_glyph.clone(); changed = true; } } @@ -1143,8 +1149,15 @@ fn ensure_workspace_task_claim( changed = true; } if task.issue_type != issue_type { - task.issue_type = issue_type; + task.set_issue_type(issue_type); changed = true; + } else { + let issue_type_glyph = + issue_type.map(|kind| workspace_issue_type_glyph(kind).to_string()); + if task.issue_type_glyph != issue_type_glyph { + task.issue_type_glyph = issue_type_glyph; + changed = true; + } } } if snapshot.active_task_id.as_deref() != Some(task_id.as_str()) { @@ -1187,8 +1200,15 @@ fn queue_issue_task( changed = true; } if task.issue_type != issue_type { - task.issue_type = issue_type; + task.set_issue_type(issue_type); changed = true; + } else { + let issue_type_glyph = + issue_type.map(|kind| workspace_issue_type_glyph(kind).to_string()); + if task.issue_type_glyph != issue_type_glyph { + task.issue_type_glyph = issue_type_glyph; + changed = true; + } } return changed; } @@ -6202,6 +6222,66 @@ mod tests { next.persistent.tasks[0].issue_type, Some(WorkspaceIssueType::Bug) ); + assert_eq!( + next.persistent.tasks[0].issue_type_glyph.as_deref(), + Some(workspace_issue_type_glyph(WorkspaceIssueType::Bug)) + ); + }); + } + + #[test] + fn refresh_existing_task_backing_pr_urls_backfills_missing_issue_glyph_without_fetching_issue() + { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + + let fake_gh = bin_dir.join("gh"); + fs::write( + &fake_gh, + "#!/bin/sh\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' '[]'\n exit 0\nfi\nprintf '%s\\n' \"unexpected gh invocation: $*\" >&2\nexit 1\n", + ) + .expect("fake gh should be written"); + make_executable(&fake_gh); + let _gh_guard = EnvVarGuard::set("MULTICODE_GH_COMMAND", &fake_gh); + + let workspace = Workspace::new(WorkspaceSnapshot::default()); + workspace.update(|snapshot| { + let mut task = WorkspaceTaskPersistentSnapshot::new( + "task-42".to_string(), + "https://github.com/example/repo/issues/42".to_string(), + WorkspaceTaskSource::Scan, + ); + task.issue_type = Some(WorkspaceIssueType::Bug); + snapshot.persistent.tasks.push(task); + true + }); + + let snapshot = workspace.subscribe().borrow().clone(); + let updated = refresh_existing_task_backing_pr_urls( + &workspace, + &snapshot, + "example/repo", + "test-token", + ) + .await + .expect("refresh should succeed"); + + assert_eq!(updated, 1); + let next = workspace.subscribe().borrow().clone(); + assert_eq!( + next.persistent.tasks[0].issue_type_glyph.as_deref(), + Some(workspace_issue_type_glyph(WorkspaceIssueType::Bug)) + ); }); } diff --git a/tui/src/main.rs b/tui/src/main.rs index ac32bfb..236ce09 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -494,10 +494,15 @@ fn workspace_issue_type(snapshot: &WorkspaceSnapshot) -> Option, archived: bool) -> Cell<'static> { +fn issue_type_cell( + issue_type: Option, + issue_type_glyph: Option<&str>, + archived: bool, +) -> Cell<'static> { issue_type.map_or_else(Cell::default, |issue_type| { let (kind, color) = issue_type_icon_kind_and_color(issue_type); - Cell::from(icon_glyph(kind)).style( + let glyph = issue_type_glyph.unwrap_or_else(|| icon_glyph(kind)); + Cell::from(glyph.to_string()).style( Style::default() .fg(if archived { Color::DarkGray } else { color }) .bg(Color::Reset), diff --git a/tui/src/render.rs b/tui/src/render.rs index 2aeacc7..abc41b1 100644 --- a/tui/src/render.rs +++ b/tui/src/render.rs @@ -156,7 +156,12 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } else { Cell::default() }; - let task_type_cell = issue_type_cell(workspace_issue_type(snapshot), archived); + let task_type_cell = issue_type_cell( + workspace_issue_type(snapshot), + workspace_active_task(snapshot) + .and_then(|task| task.issue_type_glyph.as_deref()), + archived, + ); let (pr_cell, build_cell, review_status_cell) = if let Some(GithubLinkStatusView::Pr(pr_status)) = pr_status { let (kind, color) = pr_icon_kind_and_color(*pr_status); @@ -354,7 +359,8 @@ pub(crate) fn draw_ui(frame: &mut Frame, app: &mut TuiState) { } else { (Cell::default(), Cell::default()) }; - let task_type_cell = issue_type_cell(task.issue_type, archived); + let task_type_cell = + issue_type_cell(task.issue_type, task.issue_type_glyph.as_deref(), archived); rows.push( Row::new(vec![ Cell::from(task_row_label(task)),