From c97bde9ee0c883b8b62952af35e8d9c3612e4a74 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 18 Aug 2026 22:47:34 +0900 Subject: [PATCH 01/19] fix: isolate workspace profile launch projections --- .../workspace-server/src/profile_settings.rs | 254 ++++++++++++- .../workspace-server/src/prompt_settings.rs | 334 +++++++++++++++++- crates/workspace-server/src/server.rs | 29 +- 3 files changed, 604 insertions(+), 13 deletions(-) diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index f7135959..c0ff7708 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -6,6 +6,8 @@ use std::time::UNIX_EPOCH; use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; use manifest::{ProfileSource, resolve_profile_artifact_value}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use worker::EffectivePromptCatalog; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; @@ -247,6 +249,85 @@ pub fn selector_for_workspace_candidate( .then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string())) } +fn validate_prompt_projection_matches_state( + workspace_id: &str, + state: &WorkspaceConfigState, + projection: &crate::prompt_settings::WorkspacePromptProjection, +) -> Result<()> { + if !projection.matches(workspace_id, state) { + return Err(Error::Config( + "Prompt projection identity does not match Workspace config state".to_string(), + )); + } + let prompt_catalog = projection.catalog(); + let mismatches = [ + ( + prompt_catalog.config_revision != state.snapshot.revision, + "config revision", + ), + ( + prompt_catalog.schema_fingerprint != state.contract.schema_bundle.fingerprint, + "schema fingerprint", + ), + ( + prompt_catalog.toolchain_fingerprint != state.contract.fingerprint, + "toolchain fingerprint", + ), + ] + .into_iter() + .filter_map(|(mismatch, label)| mismatch.then_some(label)) + .collect::>(); + if mismatches.is_empty() { + Ok(()) + } else { + Err(Error::Config(format!( + "Prompt projection does not match Workspace config state: {}", + mismatches.join(", ") + ))) + } +} + +fn virtual_profile_bundle_id( + state: &WorkspaceConfigState, + workspace_id: &str, + profile_selector: &worker_runtime::catalog::ProfileSelector, + prompt_catalog: &EffectivePromptCatalog, + archive: Option<&ProfileSourceArchive>, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"workspace-profile-launch-v1\0"); + hasher.update(workspace_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(state.snapshot.revision.to_le_bytes()); + hasher.update(b"\0"); + hasher.update(state.snapshot.digest.as_bytes()); + hasher.update(b"\0"); + hasher.update(state.projection_digest.as_bytes()); + hasher.update(b"\0"); + hasher.update( + serde_json::to_vec(profile_selector).map_err(|error| Error::Config(error.to_string()))?, + ); + hasher.update(b"\0"); + hasher.update(prompt_catalog.catalog_digest.as_bytes()); + hasher.update(b"\0"); + hasher.update(prompt_catalog.schema_fingerprint.as_bytes()); + hasher.update(b"\0"); + hasher.update(prompt_catalog.toolchain_fingerprint.as_bytes()); + if let Some(archive) = archive { + hasher.update(b"\0"); + hasher.update(archive.reference.digest.as_bytes()); + } + let digest = hasher.finalize(); + let identity = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(format!( + "workspace-config-profile-r{}-{identity}", + state.snapshot.revision + )) +} + pub fn build_virtual_profile_config_bundle( projection: &ProfileConfigProjection, state: &WorkspaceConfigState, @@ -254,6 +335,28 @@ pub fn build_virtual_profile_config_bundle( workspace_created_at: &str, selector: &str, ) -> Result> { + let prompt_projection = + crate::prompt_settings::project_workspace_prompt_projection(workspace_id, state)?; + build_virtual_profile_config_bundle_with_prompt_projection( + projection, + state, + workspace_id, + workspace_created_at, + selector, + &prompt_projection, + ) +} + +pub fn build_virtual_profile_config_bundle_with_prompt_projection( + projection: &ProfileConfigProjection, + state: &WorkspaceConfigState, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, + prompt_projection: &crate::prompt_settings::WorkspacePromptProjection, +) -> Result> { + validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?; + let prompt_catalog = prompt_projection.catalog().clone(); let archive = projection .entries .get(selector) @@ -261,9 +364,16 @@ pub fn build_virtual_profile_config_bundle( .transpose()?; let profile_selector = selector_for_builtin_candidate(selector) .unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string())); + let bundle_id = virtual_profile_bundle_id( + state, + workspace_id, + &profile_selector, + &prompt_catalog, + archive.as_ref(), + )?; let bundle = ConfigBundle { metadata: ConfigBundleMetadata { - id: format!("workspace-config-profile-r{}", state.snapshot.revision), + id: bundle_id, digest: String::new(), revision: state.snapshot.revision.to_string(), workspace_id: workspace_id.to_string(), @@ -281,7 +391,7 @@ pub fn build_virtual_profile_config_bundle( label: Some(selector.to_string()), }], declarations: Vec::new(), - prompt_catalog: Some(crate::prompt_settings::project_prompts_from_workspace_config(state)?), + prompt_catalog: Some(prompt_catalog), profile_source_archive: archive, profile_source_archive_handle: None, } @@ -828,6 +938,146 @@ mod tests { assert_eq!(archive.reference.source_graph.import_count, 1); } + #[test] + fn virtual_config_launch_bundle_identity_is_profile_specific_and_stable() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + + let companion = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:companion", + ) + .unwrap() + .unwrap(); + let companion_retry = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:companion", + ) + .unwrap() + .unwrap(); + let coder = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:coder", + ) + .unwrap() + .unwrap(); + + assert_eq!(companion.metadata.id, companion_retry.metadata.id); + assert_eq!(companion.metadata.digest, companion_retry.metadata.digest); + assert_ne!(companion.metadata.id, coder.metadata.id); + assert_ne!(companion.metadata.digest, coder.metadata.digest); + assert!( + companion + .metadata + .id + .starts_with("workspace-config-profile-r7-") + ); + assert!( + coder + .metadata + .id + .starts_with("workspace-config-profile-r7-") + ); + } + + #[test] + fn virtual_config_launch_bundle_rejects_prompt_projection_from_other_revision() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let prompt_projection = + crate::prompt_settings::project_workspace_prompt_projection("workspace-test", &state) + .unwrap(); + let mut mismatched_state = state.clone(); + mismatched_state.snapshot.revision += 1; + + let error = build_virtual_profile_config_bundle_with_prompt_projection( + &projection, + &mismatched_state, + "workspace-test", + "2026-01-01T00:00:00Z", + "builtin:coder", + &prompt_projection, + ) + .unwrap_err(); + assert!(error.to_string().contains("does not match")); + } + + #[test] + fn virtual_config_launch_bundle_identity_separates_project_profile_archives() { + let state = virtual_state(vec![ + config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + r#"{ profile = { entries = [ + { selector = "project:alpha"; source = "profiles/alpha.dcdl"; }, + { selector = "project:beta"; source = "profiles/beta.dcdl"; }, + ]; }; }"#, + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/alpha.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("alpha"), + ) + .unwrap(), + config_source::ConfigEntry::new( + VirtualPath::parse("profiles/beta.dcdl").unwrap(), + ConfigContentType::Decodal, + valid_decodal("beta"), + ) + .unwrap(), + ]); + let projection = project_profiles_from_workspace_config("workspace-test", &state).unwrap(); + let alpha = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:alpha", + ) + .unwrap() + .unwrap(); + let beta = build_virtual_profile_config_bundle( + &projection, + &state, + "workspace-test", + "2026-01-01T00:00:00Z", + "project:beta", + ) + .unwrap() + .unwrap(); + + assert_ne!(alpha.metadata.id, beta.metadata.id); + assert_ne!(alpha.metadata.digest, beta.metadata.digest); + assert_ne!( + alpha.profile_source_archive.unwrap().reference.digest, + beta.profile_source_archive.unwrap().reference.digest + ); + } + #[test] fn virtual_config_projection_rejects_missing_profile_source() { let state = virtual_state(vec![ diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs index 5e207d81..8f920e61 100644 --- a/crates/workspace-server/src/prompt_settings.rs +++ b/crates/workspace-server/src/prompt_settings.rs @@ -1,3 +1,6 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock}; + use config_source::{ConfigProjectionValidator, ConfigSchemaContribution}; use worker::{EffectivePromptCatalog, prompt_schema_source}; @@ -6,6 +9,168 @@ use crate::config_source::{ }; use crate::{Error, Result}; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PromptProjectionCacheKey { + workspace_id: String, + config_revision: u64, + projection_digest: String, + schema_fingerprint: String, + toolchain_fingerprint: String, +} + +impl PromptProjectionCacheKey { + fn new(workspace_id: &str, state: &WorkspaceConfigState) -> Self { + Self { + workspace_id: workspace_id.to_string(), + config_revision: state.snapshot.revision, + projection_digest: state.projection_digest.clone(), + schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(), + toolchain_fingerprint: state.contract.fingerprint.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct WorkspacePromptProjection { + identity: PromptProjectionCacheKey, + catalog: EffectivePromptCatalog, +} + +impl WorkspacePromptProjection { + pub fn catalog(&self) -> &EffectivePromptCatalog { + &self.catalog + } + + pub fn matches(&self, workspace_id: &str, state: &WorkspaceConfigState) -> bool { + self.identity == PromptProjectionCacheKey::new(workspace_id, state) + } +} + +type PromptProjectionCell = OnceLock, String>>; + +#[derive(Debug, Default)] +struct PromptProjectionCacheState { + entries: BTreeMap>, + active: BTreeMap, +} + +/// WorkspaceApi-shared immutable Prompt projections keyed by authoritative Workspace config +/// identity. +/// +/// This cache is an evaluation optimization only. Callers must load the active +/// [`WorkspaceConfigState`] from Server DB authority before resolving an entry. Advancing a +/// Workspace replaces only its active cache entry; in-flight users retain their immutable `Arc`. +#[derive(Debug, Clone, Default)] +pub struct WorkspacePromptProjectionCache { + inner: Arc>, +} + +impl WorkspacePromptProjectionCache { + pub fn resolve( + &self, + workspace_id: &str, + state: &WorkspaceConfigState, + ) -> Result> { + let key = PromptProjectionCacheKey::new(workspace_id, state); + let (cell, cached) = { + let mut cache = self.lock()?; + if let Some(active) = cache.active.get(workspace_id) { + if key.config_revision == active.config_revision && key != *active { + return Err(Error::RegistryInconsistency(format!( + "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", + key.config_revision + ))); + } + if key.config_revision < active.config_revision { + (Arc::new(PromptProjectionCell::new()), false) + } else { + let cell = cache + .entries + .entry(key.clone()) + .or_insert_with(|| Arc::new(PromptProjectionCell::new())) + .clone(); + (cell, true) + } + } else { + let cell = cache + .entries + .entry(key.clone()) + .or_insert_with(|| Arc::new(PromptProjectionCell::new())) + .clone(); + (cell, true) + } + }; + + let resolved = cell + .get_or_init(|| { + project_workspace_prompt_projection(workspace_id, state) + .map(Arc::new) + .map_err(|error| error.to_string()) + }) + .clone(); + let catalog = match resolved { + Ok(catalog) => catalog, + Err(error) => { + if cached { + self.lock()?.entries.remove(&key); + } + return Err(Error::Config(error)); + } + }; + + if cached { + self.record_resolved(workspace_id, &key, &cell)?; + } + Ok(catalog) + } + + fn record_resolved( + &self, + workspace_id: &str, + key: &PromptProjectionCacheKey, + cell: &Arc, + ) -> Result<()> { + let mut cache = self.lock()?; + let active = cache.active.get(workspace_id).cloned(); + match active { + Some(active) if active.config_revision > key.config_revision => { + cache.entries.remove(key); + } + Some(active) if active.config_revision == key.config_revision => { + if active != *key { + cache.entries.remove(key); + return Err(Error::RegistryInconsistency(format!( + "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", + key.config_revision + ))); + } + cache.entries.entry(key.clone()).or_insert(cell.clone()); + } + _ => { + cache.entries.insert(key.clone(), cell.clone()); + cache.active.insert(workspace_id.to_string(), key.clone()); + cache.entries.retain(|existing, _| { + existing.workspace_id != workspace_id + || existing == key + || existing.config_revision > key.config_revision + }); + } + } + Ok(()) + } + + fn lock(&self) -> Result> { + self.inner.lock().map_err(|_| { + Error::RegistryInconsistency("Prompt projection cache lock was poisoned".to_string()) + }) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.lock().expect("cache lock").entries.len() + } +} + #[derive(Debug, Default)] pub struct PromptConfigSchemaProvider; @@ -73,6 +238,16 @@ pub fn project_prompts_from_workspace_config( .map_err(|error| Error::RegistryInconsistency(error.to_string())) } +pub fn project_workspace_prompt_projection( + workspace_id: &str, + state: &WorkspaceConfigState, +) -> Result { + Ok(WorkspacePromptProjection { + identity: PromptProjectionCacheKey::new(workspace_id, state), + catalog: project_prompts_from_workspace_config(state)?, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -82,12 +257,16 @@ mod tests { }; fn state(source: &str) -> WorkspaceConfigState { + state_at(7, source) + } + + fn state_at(revision: u64, source: &str) -> WorkspaceConfigState { let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider .contribution() .unwrap()]) .unwrap(); let snapshot = ConfigTreeSnapshot::from_entries( - 7, + revision, [ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, @@ -113,6 +292,159 @@ mod tests { } } + #[test] + fn prompt_projection_cache_shares_immutable_entry_and_replaces_workspace_revision() { + let cache = WorkspacePromptProjectionCache::default(); + let initial = state("{}"); + let first = cache.resolve("workspace-a", &initial).unwrap(); + let retry = cache.resolve("workspace-a", &initial).unwrap(); + assert!(Arc::ptr_eq(&first, &retry)); + assert_eq!(cache.len(), 1); + + let updated = state_at( + 8, + r#"{ prompts = { common = { language = "UPDATED"; }; }; }"#, + ); + let replacement = cache.resolve("workspace-a", &updated).unwrap(); + assert!(!Arc::ptr_eq(&first, &replacement)); + assert_eq!( + replacement.catalog().templates["common.language"], + "UPDATED" + ); + assert_eq!(cache.len(), 1); + assert_ne!(first.catalog().templates["common.language"], "UPDATED"); + + let other_workspace = cache.resolve("workspace-b", &updated).unwrap(); + assert!(!Arc::ptr_eq(&replacement, &other_workspace)); + assert_eq!(cache.len(), 2); + } + + #[test] + fn prompt_projection_cache_does_not_let_stale_revision_evict_active_entry() { + let cache = WorkspacePromptProjectionCache::default(); + let current = state_at( + 8, + r#"{ prompts = { common = { language = "CURRENT"; }; }; }"#, + ); + let stale = state_at(7, "{}"); + + let current_catalog = cache.resolve("workspace-a", ¤t).unwrap(); + let stale_catalog = cache.resolve("workspace-a", &stale).unwrap(); + let current_retry = cache.resolve("workspace-a", ¤t).unwrap(); + + assert_eq!(stale_catalog.catalog().config_revision, 7); + assert!(Arc::ptr_eq(¤t_catalog, ¤t_retry)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn prompt_projection_cache_rejects_same_revision_reinterpretation() { + let cache = WorkspacePromptProjectionCache::default(); + let first = state_at(7, "{}"); + let changed = state_at( + 7, + r#"{ prompts = { common = { language = "CHANGED"; }; }; }"#, + ); + + cache.resolve("workspace-a", &first).unwrap(); + let error = cache.resolve("workspace-a", &changed).unwrap_err(); + assert!( + error + .to_string() + .contains("without a config revision transition") + ); + assert_eq!(cache.len(), 1); + } + + #[test] + fn prompt_projection_cache_post_init_rejects_concurrent_same_revision_identity() { + let cache = WorkspacePromptProjectionCache::default(); + let first = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); + let conflicting = PromptProjectionCacheKey::new( + "workspace-a", + &state_at( + 7, + r#"{ prompts = { common = { language = "CONFLICT"; }; }; }"#, + ), + ); + let first_cell = Arc::new(PromptProjectionCell::new()); + let conflicting_cell = Arc::new(PromptProjectionCell::new()); + { + let mut state = cache.lock().unwrap(); + state.entries.insert(first.clone(), first_cell.clone()); + state + .entries + .insert(conflicting.clone(), conflicting_cell.clone()); + } + + cache + .record_resolved("workspace-a", &first, &first_cell) + .unwrap(); + let error = cache + .record_resolved("workspace-a", &conflicting, &conflicting_cell) + .unwrap_err(); + assert!( + error + .to_string() + .contains("without a config revision transition") + ); + let state = cache.lock().unwrap(); + assert_eq!(state.active["workspace-a"], first); + assert!(!state.entries.contains_key(&conflicting)); + } + + #[test] + fn prompt_projection_cache_keeps_newer_inflight_entry_when_older_finishes_first() { + let cache = WorkspacePromptProjectionCache::default(); + let older = PromptProjectionCacheKey::new("workspace-a", &state_at(7, "{}")); + let newer = PromptProjectionCacheKey::new( + "workspace-a", + &state_at(8, r#"{ prompts = { common = { language = "NEW"; }; }; }"#), + ); + let older_cell = Arc::new(PromptProjectionCell::new()); + let newer_cell = Arc::new(PromptProjectionCell::new()); + { + let mut state = cache.lock().unwrap(); + state.entries.insert(older.clone(), older_cell.clone()); + state.entries.insert(newer.clone(), newer_cell.clone()); + } + + cache + .record_resolved("workspace-a", &older, &older_cell) + .unwrap(); + assert!(cache.lock().unwrap().entries.contains_key(&newer)); + + cache + .record_resolved("workspace-a", &newer, &newer_cell) + .unwrap(); + let state = cache.lock().unwrap(); + assert_eq!(state.active["workspace-a"], newer); + assert_eq!(state.entries.len(), 1); + assert!(state.entries.contains_key(&newer)); + } + + #[test] + fn prompt_projection_cache_single_flights_concurrent_resolve() { + let cache = WorkspacePromptProjectionCache::default(); + let state = Arc::new(state("{}")); + let mut threads = Vec::new(); + for _ in 0..8 { + let cache = cache.clone(); + let state = state.clone(); + threads.push(std::thread::spawn(move || { + cache.resolve("workspace-a", &state).unwrap() + })); + } + + let catalogs = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + let first = &catalogs[0]; + assert!(catalogs.iter().all(|catalog| Arc::ptr_eq(first, catalog))); + assert_eq!(cache.len(), 1); + } + #[test] fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() { let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6034bee1..7f44904d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -256,6 +256,7 @@ pub struct WorkspaceApi { pub(crate) store: Arc, config_store: Arc, config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, + prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, runtime: Arc, companion: Arc, @@ -806,6 +807,8 @@ impl WorkspaceApi { let api = Self { config_store, config_schema_registry, + prompt_projection_cache: + crate::prompt_settings::WorkspacePromptProjectionCache::default(), authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -5243,12 +5246,13 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { else { return; }; - let Ok(projection) = - crate::prompt_settings::project_prompts_from_workspace_config(&config_state) + let Ok(projection) = api + .prompt_projection_cache + .resolve(&api.config.workspace_id, &config_state) else { return; }; - let Ok(catalog) = worker::PromptCatalog::from_projection(projection) else { + let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else { return; }; let content = match catalog.render_serializable( @@ -9005,13 +9009,18 @@ async fn create_workspace_worker( "profile must be selected from Backend-published worker profile candidates", ) })?; - let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle( - &profile_projection, - &config_state, - &api.config.workspace_id, - &api.config.workspace_created_at, - &profile, - )?; + let prompt_catalog = api + .prompt_projection_cache + .resolve(&api.config.workspace_id, &config_state)?; + let resolved_config_bundle = + crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( + &profile_projection, + &config_state, + &api.config.workspace_id, + &api.config.workspace_created_at, + &profile, + prompt_catalog.as_ref(), + )?; let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", From b740b2d1e2b1399bae60ad2158ed82723ee7e5ce Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:23:37 +0900 Subject: [PATCH 02/19] fix: remove config bundle restore dependency --- crates/worker-runtime/src/runtime.rs | 32 ++- crates/worker-runtime/src/worker_backend.rs | 190 +++++++++++++++--- crates/worker/src/lib.rs | 3 +- crates/worker/src/prompt/catalog.rs | 97 +++++++++ .../workspace-server/src/profile_settings.rs | 24 ++- .../workspace-server/src/prompt_settings.rs | 32 +-- crates/workspace-server/src/server.rs | 23 +++ 7 files changed, 331 insertions(+), 70 deletions(-) diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 239f1c63..ca54eab1 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -900,8 +900,6 @@ impl Runtime { worker.run_generation.saturating_add(1).max(1), ) }; - let config_bundle = - state.resolve_config_bundle_ref(worker_request.config_bundle.as_ref())?; let backend = state.execution_backend.clone().ok_or_else(|| { RuntimeError::WorkerExecutionUnavailable { worker_id: worker_ref.worker_id.clone(), @@ -924,7 +922,7 @@ impl Runtime { context: self.execution_context(worker_ref.clone()), previous_working_directory, working_directory: None, - config_bundle, + config_bundle: None, }; (backend, request) }; @@ -1580,8 +1578,6 @@ impl Runtime { worker.run_generation.saturating_add(1).max(1), ) }; - let config_bundle = - state.resolve_config_bundle_ref(request.config_bundle.as_ref())?; state .workers .get_mut(&worker_id) @@ -1593,7 +1589,7 @@ impl Runtime { request, run_generation, previous_working_directory, - config_bundle, + config_bundle: None, }); } candidates @@ -3476,12 +3472,12 @@ mod tests { runtime.restore_worker(&detail.worker_ref).unwrap(); assert_eq!( backend.config_bundles.lock().unwrap().as_slice(), - &[Some(bundle.clone()), Some(bundle)] + &[Some(bundle), None] ); } #[test] - fn restore_fails_closed_when_recorded_config_bundle_is_missing_or_mismatched() { + fn restore_does_not_require_recorded_config_bundle() { let (runtime, backend) = runtime_and_backend(); let bundle = test_bundle(); let detail = runtime @@ -3489,11 +3485,11 @@ mod tests { .unwrap(); runtime.stop_worker(&detail.worker_ref, None).unwrap(); runtime.lock().unwrap().config_bundles.clear(); - assert!(matches!( - runtime.restore_worker(&detail.worker_ref), - Err(RuntimeError::ConfigBundleMissing { .. }) - )); - assert_eq!(backend.config_bundles.lock().unwrap().len(), 1); + runtime.restore_worker(&detail.worker_ref).unwrap(); + assert_eq!( + backend.config_bundles.lock().unwrap().as_slice(), + &[Some(bundle), None] + ); let (runtime, backend) = runtime_and_backend(); let bundle = test_bundle(); @@ -3509,11 +3505,11 @@ mod tests { .unwrap() .config_bundles .insert(replacement.metadata.id.clone(), replacement); - assert!(matches!( - runtime.restore_worker(&detail.worker_ref), - Err(RuntimeError::ConfigBundleDigestMismatch { .. }) - )); - assert_eq!(backend.config_bundles.lock().unwrap().len(), 1); + runtime.restore_worker(&detail.worker_ref).unwrap(); + assert_eq!( + backend.config_bundles.lock().unwrap().as_slice(), + &[Some(bundle), None] + ); } #[test] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index bf1f3076..33016bb9 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -210,6 +210,71 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider { } } +#[derive(Debug, Default)] +struct WorkspacePromptProjectionCache { + active: Mutex>>, +} + +impl WorkspacePromptProjectionCache { + fn observe( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result, String> { + projection.validate().map_err(|error| error.to_string())?; + let workspace_id = projection.workspace_id.clone(); + let projection = Arc::new(projection); + let mut active = self + .active + .lock() + .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?; + if let Some(current) = active.get(&workspace_id) { + if current.config_revision > projection.config_revision { + return Ok(current.clone()); + } + if current.config_revision == projection.config_revision + && (current.projection_digest != projection.projection_digest + || current.catalog.catalog_digest != projection.catalog.catalog_digest) + { + return Err(format!( + "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", + projection.config_revision + )); + } + } + active.insert(workspace_id, projection.clone()); + Ok(projection) + } + + fn fetch_current( + &self, + workspace_client: &Arc, + workspace_id: &worker::WorkspaceId, + ) -> Result, String> { + let response = workspace_client + .execute(worker::WorkspaceRequest { + method: worker::WorkspaceRequestMethod::Get, + path: format!( + "/api/w/{}/config/projections/prompts", + workspace_id.as_str() + ), + body: None, + }) + .map_err(|error| error.to_string())?; + if !(200..300).contains(&response.status) { + return Err(format!( + "Workspace Prompt projection request failed with status {}", + response.status + )); + } + let projection: worker::WorkspacePromptProjection = + serde_json::from_str(&response.body).map_err(|error| error.to_string())?; + if projection.workspace_id != workspace_id.as_str() { + return Err("Workspace Prompt projection returned a mismatched workspace".to_string()); + } + self.observe(projection) + } +} + #[derive(Clone)] pub struct ProfileRuntimeWorkerFactory { observation_hub: Arc, @@ -217,6 +282,7 @@ pub struct ProfileRuntimeWorkerFactory { worker_aggregate_root: Option, resource_client: Option>, profile_archive_cache: Arc, + prompt_projection_cache: Arc, runtime_id: Option, worker_mutation_identity: Option, embedded_worker_mutation_dispatcher: Option>, @@ -232,6 +298,7 @@ impl ProfileRuntimeWorkerFactory { worker_aggregate_root: None, resource_client: None, profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()), + prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()), runtime_id: None, worker_mutation_identity: None, embedded_worker_mutation_dispatcher: None, @@ -583,12 +650,30 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )? } }; - if let Some(prompt_catalog) = request - .config_bundle - .as_ref() - .and_then(|bundle| bundle.prompt_catalog.clone()) - { - loader = loader.with_effective_catalog(prompt_catalog); + if let Some(bundle) = request.config_bundle.as_ref() { + if let Some(prompt_catalog) = bundle.prompt_catalog.clone() { + let source_digest = bundle + .metadata + .provenance + .detail + .as_deref() + .and_then(|detail| { + detail + .split(';') + .find_map(|part| part.strip_prefix("source_tree_digest=")) + }) + .unwrap_or(&prompt_catalog.catalog_digest) + .to_string(); + let projection = worker::WorkspacePromptProjection::new( + bundle.metadata.workspace_id.clone(), + source_digest, + prompt_catalog.catalog_digest.clone(), + prompt_catalog, + ) + .map_err(|error| error.to_string())?; + let projection = self.prompt_projection_cache.observe(projection)?; + loader = loader.with_effective_catalog(projection.catalog.clone()); + } } let flow_transition_enabled = manifest.feature.flow.enabled; @@ -726,12 +811,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { self.embedded_worker_mutation_dispatcher.as_ref(), ); let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?; - if let Some(prompt_catalog) = request - .config_bundle - .as_ref() - .and_then(|bundle| bundle.prompt_catalog.clone()) - { - loader = loader.with_effective_catalog(prompt_catalog); + if let Some(workspace_id) = workspace_context.workspace_id() { + let projection = self + .prompt_projection_cache + .fetch_current(&workspace_context.client_handle(), workspace_id)?; + loader = loader.with_effective_catalog(projection.catalog.clone()); } let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; @@ -1859,6 +1943,75 @@ mod tests { use manifest::{Scope, WorkerManifest}; use session_store::{LogEntry, WorkerMetadataStore}; + #[derive(Debug)] + struct PromptProjectionWorkspaceClient { + response: String, + calls: AtomicUsize, + } + + impl worker::WorkspaceClient for PromptProjectionWorkspaceClient { + fn kind(&self) -> &'static str { + "prompt-projection-test" + } + + fn workspace_id(&self) -> Option<&str> { + Some("workspace-a") + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: worker::WorkspaceRequest, + ) -> Result { + assert_eq!( + request.path, + "/api/w/workspace-a/config/projections/prompts" + ); + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(worker::WorkspaceResponse { + status: 200, + body: self.response.clone(), + }) + } + } + + #[test] + fn workspace_prompt_projection_cache_fetches_and_validates_current_projection() { + let catalog = worker::EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "prompt".to_string())]), + 8, + "schema", + "toolchain", + ) + .unwrap(); + let projection = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-digest", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let client = Arc::new(PromptProjectionWorkspaceClient { + response: serde_json::to_string(&projection).unwrap(), + calls: AtomicUsize::new(0), + }); + let cache = WorkspacePromptProjectionCache::default(); + let workspace_id = worker::WorkspaceId::new("workspace-a".to_string()).unwrap(); + + let resolved = cache + .fetch_current( + &(client.clone() as Arc), + &workspace_id, + ) + .unwrap(); + + assert_eq!(resolved.as_ref(), &projection); + assert_eq!(client.calls.load(Ordering::SeqCst), 1); + } + #[test] fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() { let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap(); @@ -2512,23 +2665,14 @@ mod tests { ) .unwrap(); - let mut request = create_request("restore"); - request.workspace_api = Some(crate::catalog::WorkspaceApiRef { - workspace_id: "workspace-restore".to_string(), - base_url: "http://workspace.invalid".to_string(), - }); - let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); + let request = create_request("restore"); let controller = ProfileRuntimeWorkerFactory::new(root.path()) - .with_remote_worker_mutation_identity(identity) .with_runtime_store_dir(&runtime_store_dir) .restore_controller(WorkerExecutionRestoreRequest { worker_ref: worker_ref.clone(), run_generation: 1, request, - workspace_scope: Some(crate::runtime::RuntimeWorkspaceScope::new( - "workspace-restore", - "server-main", - )), + workspace_scope: None, context: test_execution_context(worker_ref), previous_working_directory: None, working_directory: None, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 675efb78..ad4fd207 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -33,7 +33,8 @@ pub use manifest::{ }; pub use model_client::{ProviderError, build_client}; pub use prompt::catalog::{ - CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source, + CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection, + prompt_schema_source, }; pub use prompt::source::PromptCatalogSource; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 42a7ee95..28e748e9 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -171,6 +171,77 @@ pub enum CatalogError { DigestMismatch { expected: String, actual: String }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspacePromptProjection { + pub workspace_id: String, + pub config_revision: u64, + pub source_digest: String, + pub projection_digest: String, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog: EffectivePromptCatalog, +} + +impl WorkspacePromptProjection { + pub fn new( + workspace_id: impl Into, + source_digest: impl Into, + projection_digest: impl Into, + catalog: EffectivePromptCatalog, + ) -> Result { + let workspace_id = workspace_id.into(); + let source_digest = source_digest.into(); + let projection_digest = projection_digest.into(); + catalog.verify_digest()?; + if workspace_id.trim().is_empty() { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection workspace_id must not be empty".to_string(), + )); + } + if source_digest.trim().is_empty() { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection source digest must not be empty".to_string(), + )); + } + if projection_digest.trim().is_empty() { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection digest must not be empty".to_string(), + )); + } + Ok(Self { + workspace_id, + config_revision: catalog.config_revision, + source_digest, + projection_digest, + schema_fingerprint: catalog.schema_fingerprint.clone(), + toolchain_fingerprint: catalog.toolchain_fingerprint.clone(), + catalog, + }) + } + + pub fn catalog(&self) -> &EffectivePromptCatalog { + &self.catalog + } + + pub fn validate(&self) -> Result<(), CatalogError> { + let rebuilt = Self::new( + self.workspace_id.clone(), + self.source_digest.clone(), + self.projection_digest.clone(), + self.catalog.clone(), + )?; + if rebuilt.config_revision != self.config_revision + || rebuilt.schema_fingerprint != self.schema_fingerprint + || rebuilt.toolchain_fingerprint != self.toolchain_fingerprint + { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection metadata does not match its catalog".to_string(), + )); + } + Ok(()) + } +} + pub struct PromptCatalog { env: Environment<'static>, projection: EffectivePromptCatalog, @@ -550,6 +621,32 @@ mod tests { )); } + #[test] + fn workspace_prompt_projection_round_trips_and_rejects_tampered_metadata() { + let catalog = EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "PROMPT".to_string())]), + 8, + "schema", + "toolchain", + ) + .unwrap(); + let projection = WorkspacePromptProjection::new( + "workspace-a", + "source-digest", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let serialized = serde_json::to_string(&projection).unwrap(); + let restored: WorkspacePromptProjection = serde_json::from_str(&serialized).unwrap(); + assert_eq!(restored, projection); + restored.validate().unwrap(); + + let mut tampered = restored; + tampered.config_revision += 1; + assert!(tampered.validate().is_err()); + } + #[test] fn catalog_source_preserves_workspace_projection_for_subworkers() { let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]); diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index c0ff7708..f76aff77 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -252,15 +252,25 @@ pub fn selector_for_workspace_candidate( fn validate_prompt_projection_matches_state( workspace_id: &str, state: &WorkspaceConfigState, - projection: &crate::prompt_settings::WorkspacePromptProjection, + projection: &worker::WorkspacePromptProjection, ) -> Result<()> { - if !projection.matches(workspace_id, state) { - return Err(Error::Config( - "Prompt projection identity does not match Workspace config state".to_string(), - )); - } + projection + .validate() + .map_err(|error| Error::Config(error.to_string()))?; let prompt_catalog = projection.catalog(); let mismatches = [ + ( + projection.workspace_id != workspace_id, + "workspace identity", + ), + ( + projection.source_digest != state.snapshot.digest, + "source digest", + ), + ( + projection.projection_digest != prompt_catalog.catalog_digest, + "projection digest", + ), ( prompt_catalog.config_revision != state.snapshot.revision, "config revision", @@ -353,7 +363,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( workspace_id: &str, workspace_created_at: &str, selector: &str, - prompt_projection: &crate::prompt_settings::WorkspacePromptProjection, + prompt_projection: &worker::WorkspacePromptProjection, ) -> Result> { validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?; let prompt_catalog = prompt_projection.catalog().clone(); diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs index 8f920e61..376e53e1 100644 --- a/crates/workspace-server/src/prompt_settings.rs +++ b/crates/workspace-server/src/prompt_settings.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex, OnceLock}; use config_source::{ConfigProjectionValidator, ConfigSchemaContribution}; -use worker::{EffectivePromptCatalog, prompt_schema_source}; +use worker::{EffectivePromptCatalog, WorkspacePromptProjection, prompt_schema_source}; use crate::config_source::{ WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, @@ -13,6 +13,7 @@ use crate::{Error, Result}; struct PromptProjectionCacheKey { workspace_id: String, config_revision: u64, + source_digest: String, projection_digest: String, schema_fingerprint: String, toolchain_fingerprint: String, @@ -23,6 +24,7 @@ impl PromptProjectionCacheKey { Self { workspace_id: workspace_id.to_string(), config_revision: state.snapshot.revision, + source_digest: state.snapshot.digest.clone(), projection_digest: state.projection_digest.clone(), schema_fingerprint: state.contract.schema_bundle.fingerprint.clone(), toolchain_fingerprint: state.contract.fingerprint.clone(), @@ -30,22 +32,6 @@ impl PromptProjectionCacheKey { } } -#[derive(Debug, Clone)] -pub struct WorkspacePromptProjection { - identity: PromptProjectionCacheKey, - catalog: EffectivePromptCatalog, -} - -impl WorkspacePromptProjection { - pub fn catalog(&self) -> &EffectivePromptCatalog { - &self.catalog - } - - pub fn matches(&self, workspace_id: &str, state: &WorkspaceConfigState) -> bool { - self.identity == PromptProjectionCacheKey::new(workspace_id, state) - } -} - type PromptProjectionCell = OnceLock, String>>; #[derive(Debug, Default)] @@ -242,10 +228,14 @@ pub fn project_workspace_prompt_projection( workspace_id: &str, state: &WorkspaceConfigState, ) -> Result { - Ok(WorkspacePromptProjection { - identity: PromptProjectionCacheKey::new(workspace_id, state), - catalog: project_prompts_from_workspace_config(state)?, - }) + let catalog = project_prompts_from_workspace_config(state)?; + WorkspacePromptProjection::new( + workspace_id, + state.snapshot.digest.clone(), + catalog.catalog_digest.clone(), + catalog, + ) + .map_err(|error| Error::RegistryInconsistency(error.to_string())) } #[cfg(test)] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7f44904d..fc50dae1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1243,6 +1243,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/config/source-tree", get(scoped_get_workspace_config_tree), ) + .route( + "/api/w/{workspace_id}/config/projections/prompts", + get(scoped_get_prompt_projection), + ) .route( "/api/w/{workspace_id}/config/source-tree/commit", post(scoped_commit_workspace_config_tree), @@ -2581,6 +2585,25 @@ struct WorkspaceConfigEntryPath { path: String, } +async fn scoped_get_prompt_projection( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidInput( + "Workspace config is not initialized".to_string(), + )) + })?; + let projection = api + .prompt_projection_cache + .resolve(&path.workspace_id, &state)?; + Ok(Json(projection.as_ref().clone())) +} + #[derive(Debug, Serialize)] struct WorkspaceConfigTreeResponse { snapshot: ConfigTreeSnapshot, From 0d011ea0cd3a579ba2a45d86786add7c44679c41 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:25:13 +0900 Subject: [PATCH 03/19] fix: fence prompt cache source revisions --- crates/worker-runtime/src/worker_backend.rs | 37 ++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 33016bb9..cc53a20c 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -232,7 +232,8 @@ impl WorkspacePromptProjectionCache { return Ok(current.clone()); } if current.config_revision == projection.config_revision - && (current.projection_digest != projection.projection_digest + && (current.source_digest != projection.source_digest + || current.projection_digest != projection.projection_digest || current.catalog.catalog_digest != projection.catalog.catalog_digest) { return Err(format!( @@ -2012,6 +2013,40 @@ mod tests { assert_eq!(client.calls.load(Ordering::SeqCst), 1); } + #[test] + fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() { + let catalog = worker::EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "prompt".to_string())]), + 8, + "schema", + "toolchain", + ) + .unwrap(); + let first = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-a", + catalog.catalog_digest.clone(), + catalog.clone(), + ) + .unwrap(); + let drifted = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-b", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let cache = WorkspacePromptProjectionCache::default(); + + cache.observe(first).unwrap(); + let error = cache.observe(drifted).unwrap_err(); + assert!( + error + .to_string() + .contains("without a config revision transition") + ); + } + #[test] fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() { let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap(); From 382b5e57f2af19fc4670296a3d4b844322ae6f66 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:30:00 +0900 Subject: [PATCH 04/19] fix: carry prompt projection source identity --- crates/worker-runtime/src/worker_backend.rs | 28 +++++++++++-------- crates/worker/src/prompt/catalog.rs | 8 ++++++ .../workspace-server/src/prompt_settings.rs | 6 ++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index cc53a20c..2fdcabe4 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -653,18 +653,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { }; if let Some(bundle) = request.config_bundle.as_ref() { if let Some(prompt_catalog) = bundle.prompt_catalog.clone() { - let source_digest = bundle - .metadata - .provenance - .detail - .as_deref() - .and_then(|detail| { - detail - .split(';') - .find_map(|part| part.strip_prefix("source_tree_digest=")) - }) - .unwrap_or(&prompt_catalog.catalog_digest) - .to_string(); + let source_digest = if prompt_catalog.source_digest.is_empty() { + bundle + .metadata + .provenance + .detail + .as_deref() + .and_then(|detail| { + detail + .split(';') + .find_map(|part| part.strip_prefix("source_tree_digest=")) + }) + .unwrap_or(&prompt_catalog.catalog_digest) + .to_string() + } else { + prompt_catalog.source_digest.clone() + }; let projection = worker::WorkspacePromptProjection::new( bundle.metadata.workspace_id.clone(), source_digest, diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 28e748e9..9c5d187a 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -31,6 +31,8 @@ const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4"; pub struct EffectivePromptCatalog { pub templates: BTreeMap, pub config_revision: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_digest: String, pub schema_fingerprint: String, pub toolchain_fingerprint: String, pub catalog_digest: String, @@ -48,6 +50,7 @@ impl EffectivePromptCatalog { Ok(Self { templates, config_revision, + source_digest: String::new(), schema_fingerprint: schema_fingerprint.into(), toolchain_fingerprint: toolchain_fingerprint.into(), catalog_digest, @@ -208,6 +211,11 @@ impl WorkspacePromptProjection { "Workspace Prompt projection digest must not be empty".to_string(), )); } + if !catalog.source_digest.is_empty() && catalog.source_digest != source_digest { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection source digest does not match its catalog".to_string(), + )); + } Ok(Self { workspace_id, config_revision: catalog.config_revision, diff --git a/crates/workspace-server/src/prompt_settings.rs b/crates/workspace-server/src/prompt_settings.rs index 376e53e1..81f1415b 100644 --- a/crates/workspace-server/src/prompt_settings.rs +++ b/crates/workspace-server/src/prompt_settings.rs @@ -215,13 +215,15 @@ pub fn project_prompts_from_workspace_config( "active Workspace config projection has no prompts namespace".to_string(), ) })?; - EffectivePromptCatalog::from_projection( + let mut catalog = EffectivePromptCatalog::from_projection( prompts, state.snapshot.revision, state.contract.schema_bundle.fingerprint.clone(), state.contract.fingerprint.clone(), ) - .map_err(|error| Error::RegistryInconsistency(error.to_string())) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; + catalog.source_digest = state.snapshot.digest.clone(); + Ok(catalog) } pub fn project_workspace_prompt_projection( From 39aa465a51e3da3af9078607eb33c2cef81d83fc Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:36:38 +0900 Subject: [PATCH 05/19] fix: keep config bundles transport-only --- crates/worker-runtime/src/fs_store.rs | 13 ++----------- crates/worker-runtime/src/runtime.rs | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index a3ddcae0..61b75a10 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -1,5 +1,5 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus}; -use crate::config_bundle::{ConfigBundle, validate_config_bundle}; +use crate::config_bundle::ConfigBundle; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::error::RuntimeError; use crate::identity::{WorkerId, WorkerRef}; @@ -245,7 +245,6 @@ pub(crate) struct PersistedRuntimeState { pub(crate) next_diagnostic_id: u64, pub(crate) workers: BTreeMap, pub(crate) workspace_owners: BTreeMap, - pub(crate) config_bundles: BTreeMap, pub(crate) diagnostics: Vec, } @@ -300,7 +299,7 @@ impl RuntimeSnapshot { status: state.status, next_worker_sequence: state.next_worker_sequence, next_diagnostic_id: state.next_diagnostic_id, - config_bundles: state.config_bundles.clone(), + config_bundles: BTreeMap::new(), workspace_owners: state.workspace_owners.clone(), diagnostics: state.diagnostics.clone(), } @@ -324,13 +323,6 @@ impl RuntimeSnapshot { message: format!("runtime snapshot backend is {:?}", self.backend), }); } - for bundle in self.config_bundles.values() { - validate_config_bundle(bundle).map_err(|error| RuntimeError::StoreCorrupt { - operation: "read runtime snapshot", - path: path.to_path_buf(), - message: format!("invalid config bundle {}: {error}", bundle.metadata.id), - })?; - } Ok(()) } @@ -344,7 +336,6 @@ impl RuntimeSnapshot { next_worker_sequence: self.next_worker_sequence, next_diagnostic_id: self.next_diagnostic_id, workers, - config_bundles: self.config_bundles, workspace_owners: self.workspace_owners, diagnostics: self.diagnostics, } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index ca54eab1..ec308e7f 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1937,7 +1937,7 @@ impl RuntimeState { next_worker_sequence: persisted.next_worker_sequence, next_diagnostic_id, workers, - config_bundles: persisted.config_bundles, + config_bundles: BTreeMap::new(), workspace_owners: persisted.workspace_owners, diagnostics, subscription_revision: 0, @@ -1965,7 +1965,6 @@ impl RuntimeState { .iter() .map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record())) .collect(), - config_bundles: self.config_bundles.clone(), workspace_owners: self.workspace_owners.clone(), diagnostics: self.diagnostics.clone(), } @@ -4133,7 +4132,10 @@ mod tests { runtime.summary().unwrap().backend, RuntimeBackendKind::FsStore ); - runtime.store_config_bundle(test_bundle()).unwrap(); + let transport_bundle = test_bundle(); + runtime + .store_config_bundle(transport_bundle.clone()) + .unwrap(); let worker = runtime.create_worker(task_request("persist me")).unwrap(); runtime @@ -4167,6 +4169,13 @@ mod tests { .unwrap(); let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap(); assert_eq!(restored_worker.status, WorkerStatus::Stopped); + assert!(matches!( + restored.check_config_bundle(&ConfigBundleRef { + id: transport_bundle.metadata.id.clone(), + digest: transport_bundle.metadata.digest.clone(), + }), + Err(RuntimeError::ConfigBundleMissing { .. }) + )); assert!(!root.join("events.jsonl").exists()); assert!(!worker_store_dir.join("observations.jsonl").exists()); #[cfg(feature = "ws-server")] From d7e35ea9eedc8ef9f711f6c67e209423997e7c66 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:41:38 +0900 Subject: [PATCH 06/19] fix: keep restore independent of live prompts --- crates/worker-runtime/src/worker_backend.rs | 120 ++++---------------- crates/worker/src/prompt/catalog.rs | 5 + 2 files changed, 25 insertions(+), 100 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 2fdcabe4..c67e6af8 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -246,33 +246,14 @@ impl WorkspacePromptProjectionCache { Ok(projection) } - fn fetch_current( + fn current( &self, - workspace_client: &Arc, workspace_id: &worker::WorkspaceId, - ) -> Result, String> { - let response = workspace_client - .execute(worker::WorkspaceRequest { - method: worker::WorkspaceRequestMethod::Get, - path: format!( - "/api/w/{}/config/projections/prompts", - workspace_id.as_str() - ), - body: None, - }) - .map_err(|error| error.to_string())?; - if !(200..300).contains(&response.status) { - return Err(format!( - "Workspace Prompt projection request failed with status {}", - response.status - )); - } - let projection: worker::WorkspacePromptProjection = - serde_json::from_str(&response.body).map_err(|error| error.to_string())?; - if projection.workspace_id != workspace_id.as_str() { - return Err("Workspace Prompt projection returned a mismatched workspace".to_string()); - } - self.observe(projection) + ) -> Result>, String> { + self.active + .lock() + .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string()) + .map(|active| active.get(workspace_id.as_str()).cloned()) } } @@ -816,10 +797,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { self.embedded_worker_mutation_dispatcher.as_ref(), ); let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?; - if let Some(workspace_id) = workspace_context.workspace_id() { - let projection = self - .prompt_projection_cache - .fetch_current(&workspace_context.client_handle(), workspace_id)?; + if let Some(workspace_id) = workspace_context.workspace_id() + && let Some(projection) = self.prompt_projection_cache.current(workspace_id)? + { loader = loader.with_effective_catalog(projection.catalog.clone()); } @@ -1948,75 +1928,6 @@ mod tests { use manifest::{Scope, WorkerManifest}; use session_store::{LogEntry, WorkerMetadataStore}; - #[derive(Debug)] - struct PromptProjectionWorkspaceClient { - response: String, - calls: AtomicUsize, - } - - impl worker::WorkspaceClient for PromptProjectionWorkspaceClient { - fn kind(&self) -> &'static str { - "prompt-projection-test" - } - - fn workspace_id(&self) -> Option<&str> { - Some("workspace-a") - } - - fn is_available(&self) -> bool { - true - } - - fn execute( - &self, - request: worker::WorkspaceRequest, - ) -> Result { - assert_eq!( - request.path, - "/api/w/workspace-a/config/projections/prompts" - ); - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(worker::WorkspaceResponse { - status: 200, - body: self.response.clone(), - }) - } - } - - #[test] - fn workspace_prompt_projection_cache_fetches_and_validates_current_projection() { - let catalog = worker::EffectivePromptCatalog::new( - BTreeMap::from([("default".to_string(), "prompt".to_string())]), - 8, - "schema", - "toolchain", - ) - .unwrap(); - let projection = worker::WorkspacePromptProjection::new( - "workspace-a", - "source-digest", - catalog.catalog_digest.clone(), - catalog, - ) - .unwrap(); - let client = Arc::new(PromptProjectionWorkspaceClient { - response: serde_json::to_string(&projection).unwrap(), - calls: AtomicUsize::new(0), - }); - let cache = WorkspacePromptProjectionCache::default(); - let workspace_id = worker::WorkspaceId::new("workspace-a".to_string()).unwrap(); - - let resolved = cache - .fetch_current( - &(client.clone() as Arc), - &workspace_id, - ) - .unwrap(); - - assert_eq!(resolved.as_ref(), &projection); - assert_eq!(client.calls.load(Ordering::SeqCst), 1); - } - #[test] fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() { let catalog = worker::EffectivePromptCatalog::new( @@ -2704,14 +2615,23 @@ mod tests { ) .unwrap(); - let request = create_request("restore"); + let mut request = create_request("restore"); + request.workspace_api = Some(crate::catalog::WorkspaceApiRef { + workspace_id: "workspace-restore".to_string(), + base_url: "http://workspace.invalid".to_string(), + }); + let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); let controller = ProfileRuntimeWorkerFactory::new(root.path()) .with_runtime_store_dir(&runtime_store_dir) + .with_remote_worker_mutation_identity(identity) .restore_controller(WorkerExecutionRestoreRequest { worker_ref: worker_ref.clone(), run_generation: 1, request, - workspace_scope: None, + workspace_scope: Some(crate::runtime::RuntimeWorkspaceScope::new( + "workspace-restore", + "server-main", + )), context: test_execution_context(worker_ref), previous_working_directory: None, working_directory: None, diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 9c5d187a..6cf95753 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -211,6 +211,11 @@ impl WorkspacePromptProjection { "Workspace Prompt projection digest must not be empty".to_string(), )); } + if projection_digest != catalog.catalog_digest { + return Err(CatalogError::InvalidTemplateCatalog( + "Workspace Prompt projection digest does not match its catalog".to_string(), + )); + } if !catalog.source_digest.is_empty() && catalog.source_digest != source_digest { return Err(CatalogError::InvalidTemplateCatalog( "Workspace Prompt projection source digest does not match its catalog".to_string(), From 08daf782b984590be5111d3b0716298d5f3d8904 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 04:55:29 +0900 Subject: [PATCH 07/19] fix: accept queued tickets after coder spawn --- .../src/feature/builtin/orchestration.rs | 17 +-- crates/workspace-server/src/server.rs | 103 +++++++++++++++++- .../workspace_orchestrator_queue_attention.md | 2 +- .../panel/orchestrator_idle_queue_notice.md | 2 +- resources/prompts/role/orchestrator.md | 2 +- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index b6b47885..b0088ae1 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -45,7 +45,7 @@ impl FeatureModule for OrchestrationFeature { )) .with_tool(ToolDeclaration::new( TOOL_NAME, - "Spawn and atomically assign a Coder Worker for an inprogress Ticket. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", + "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", )) } @@ -96,9 +96,12 @@ impl Tool for SpawnTicketCoderTool { .ticket_service .workflow_state(&ticket_id) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if workflow_state != ticket::TicketWorkflowState::InProgress { + if !matches!( + workflow_state, + ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress + ) { return Err(ToolError::ExecutionFailed(format!( - "Ticket {ticket_id} must be inprogress before spawning its Coder; current state is {}", + "Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}", workflow_state.as_str() ))); } @@ -206,7 +209,7 @@ mod tests { impl TicketService for RecordingTicketService { fn workflow_state(&self, _ticket_id: &str) -> Result { - Ok(TicketWorkflowState::InProgress) + Ok(TicketWorkflowState::Queued) } } @@ -277,10 +280,10 @@ mod tests { } #[tokio::test] - async fn spawn_ticket_coder_rejects_ticket_before_worker_side_effect() { + async fn spawn_ticket_coder_rejects_ineligible_ticket_before_worker_side_effect() { let worker_service = Arc::new(RecordingService::default()); let tool = SpawnTicketCoderTool { - ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)), worker_service: worker_service.clone(), }; let error = tool @@ -295,7 +298,7 @@ mod tests { ) .await .unwrap_err(); - assert!(error.to_string().contains("must be inprogress")); + assert!(error.to_string().contains("must be queued or inprogress")); assert!(worker_service.requests.lock().unwrap().is_empty()); } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fc50dae1..718312d4 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2902,9 +2902,13 @@ fn validate_ticket_assignment_state( assignment: &WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if ticket.state != TicketWorkflowState::InProgress.as_str() { + if !matches!( + ticket.state.as_str(), + state if state == TicketWorkflowState::Queued.as_str() + || state == TicketWorkflowState::InProgress.as_str() + ) { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} must be inprogress before assigning an implementation Coder; current state is {}", + "Ticket {} must be queued or inprogress before assigning an implementation Coder; current state is {}", ticket.id, ticket.state ))); } @@ -3072,6 +3076,33 @@ fn assign_ticket_worker_from_lifecycle( .current) } +fn accept_queued_ticket_after_worker_spawn( + api: &WorkspaceApi, + assignment: &crate::hosts::WorkerTicketAssignmentRequest, +) -> Result<()> { + let ticket = api.authority.ticket(&assignment.ticket_id)?; + if ticket.state == TicketWorkflowState::InProgress.as_str() { + return Ok(()); + } + if ticket.state != TicketWorkflowState::Queued.as_str() { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} left queued state before Coder spawn acceptance; current state is {}", + ticket.id, ticket.state + ))); + } + let mut change = TicketStateChange::new( + TicketWorkflowState::Queued.as_str(), + TicketWorkflowState::InProgress.as_str(), + "Coder spawn, assignment, and initial input were durably accepted", + "", + ); + change.author = Some("workspace-orchestrator".to_string()); + browser_ticket_backend(api)? + .set_workflow_state(TicketIdOrSlug::Id(ticket.id), change) + .map_err(Error::from)?; + Ok(()) +} + fn existing_lifecycle_assignment_worker( api: &WorkspaceApi, assignment: &crate::hosts::WorkerTicketAssignmentRequest, @@ -9274,6 +9305,20 @@ fn browser_worker_response_from_summary( link_worker_to_workdir(api, &worker_record, workdir_id, None)?; } } + if let Some(assignment) = assignment { + let context = WorkerSpawnCompensationContext { + assignment: Some(assignment), + prepared_workdir_id: selected_working_directory_id, + cleanup_spawned_workdir: false, + }; + finalize_worker_spawn_stage( + api, + &worker, + &context, + WorkerSpawnFinalizeStage::TicketStateAccept, + accept_queued_ticket_after_worker_spawn(api, assignment).map_err(ApiError::from), + )?; + } let runtime_id = worker.worker.runtime_id.clone(); let worker_id = worker.worker.worker_id.clone(); let workspace_id = api.workspace_id().to_string(); @@ -9488,6 +9533,7 @@ enum WorkerSpawnFinalizeStage { WorkerRegistry, TicketAssignmentBind, TicketAssignmentCurrent, + TicketStateAccept, WorkdirRegistry, WorkdirAttachment, } @@ -9498,6 +9544,7 @@ impl WorkerSpawnFinalizeStage { Self::WorkerRegistry => "worker_registry", Self::TicketAssignmentBind => "ticket_assignment_bind", Self::TicketAssignmentCurrent => "ticket_assignment_current", + Self::TicketStateAccept => "ticket_state_accept", Self::WorkdirRegistry => "workdir_registry", Self::WorkdirAttachment => "workdir_attachment", } @@ -12829,7 +12876,7 @@ mod tests { } #[tokio::test] - async fn ticket_assignment_spawn_requires_inprogress_before_runtime_side_effects() { + async fn ticket_assignment_spawn_requires_queued_or_inprogress_before_runtime_side_effects() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -12889,7 +12936,7 @@ mod tests { let api = test_api(workspace.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Assigned Ticket"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketWorkflowState::Queued); let ticket = backend.create(input).unwrap(); let response = create_workspace_worker( State(api.clone()), @@ -12914,6 +12961,10 @@ mod tests { .unwrap() .0; + assert_eq!( + api.authority.ticket(&ticket.id).unwrap().state, + TicketWorkflowState::InProgress.as_str() + ); let current = api .store .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) @@ -12932,6 +12983,50 @@ mod tests { assert_eq!(operation.worker, Some(response.worker_ref)); } + #[tokio::test] + async fn failed_ticket_assignment_spawn_leaves_ticket_queued() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Queued Ticket"); + input.workflow_state = Some(TicketWorkflowState::Queued); + let ticket = backend.create(input).unwrap(); + + let result = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: "missing-runtime".to_string(), + display_name: "Rejected Coder".to_string(), + profile: Some("builtin:coder".to_string()), + ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { + ticket_id: ticket.id.clone(), + operation_id: "failed-queued-assignment".to_string(), + }), + initial_submit: vec![Segment::Flow { + selector: "builtin:coder-review".to_string(), + }], + working_directory: None, + control_operation_id: None, + resolved_control_operation: None, + }), + ) + .await; + + assert!(result.is_err()); + assert_eq!( + api.authority.ticket(&ticket.id).unwrap().state, + TicketWorkflowState::Queued.as_str() + ); + assert!( + api.store + .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn worker_source_auth_rejects_cross_workspace_mutation() { let workspace = tempfile::tempdir().unwrap(); diff --git a/resources/prompts/internal/workspace_orchestrator_queue_attention.md b/resources/prompts/internal/workspace_orchestrator_queue_attention.md index 340298c2..3d96603b 100644 --- a/resources/prompts/internal/workspace_orchestrator_queue_attention.md +++ b/resources/prompts/internal/workspace_orchestrator_queue_attention.md @@ -4,4 +4,4 @@ Workspace: {{workspace_id}} Remaining queued Tickets (bounded): {{ticket_lines}} {{omitted_line}} -Reread the listed Tickets, their relations, orchestration plans, current assignments, Workers, and Workdirs before acting. Continue only work already authorized by the human `ready -> queued` transition. Do not drain the queue automatically and do not create duplicate assignments, Workers, Workdirs, or merges. If no Ticket is currently actionable, record the durable waiting reason on the authoritative Ticket or orchestration plan and stop. Before implementation side effects, record the accepted `queued -> inprogress` transition. +Reread the listed Tickets, their relations, orchestration plans, current assignments, Workers, and Workdirs before acting. Continue only work already authorized by the human `ready -> queued` transition. Do not drain the queue automatically and do not create duplicate assignments, Workers, Workdirs, or merges. If no Ticket is currently actionable, record the durable waiting reason on the authoritative Ticket or orchestration plan and stop. For an actionable queued Ticket, call the guarded `SpawnTicketCoder` operation without first changing Ticket state; it records `queued -> inprogress` only after the Coder, initial input, current assignment, and Workdir finalization are durably accepted. diff --git a/resources/prompts/panel/orchestrator_idle_queue_notice.md b/resources/prompts/panel/orchestrator_idle_queue_notice.md index 3574523a..85e6e05c 100644 --- a/resources/prompts/panel/orchestrator_idle_queue_notice.md +++ b/resources/prompts/panel/orchestrator_idle_queue_notice.md @@ -1,7 +1,7 @@ Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present. -This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Before implementation side effects, verify the Ticket state and record the normal `queued -> inprogress` acceptance through Ticket tools. +This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Verify the Ticket is still `queued`, then use the guarded `SpawnTicketCoder` operation without a separate state transition; that operation records `queued -> inprogress` only after Worker creation, initial input, assignment, and Workdir finalization are durably accepted. Workspace: {{ workspace }} diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index bdbd7801..95fec5c0 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,7 +2,7 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, current assignment, Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If spawn or finalization fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. From 44b3c7876116eb792c8165bbd4f3af64ff1c7232 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 05:02:50 +0900 Subject: [PATCH 08/19] fix: reject unrecoverable pending worker restore --- crates/worker-runtime/src/worker_backend.rs | 54 +++++++-------------- crates/worker/src/worker.rs | 6 +++ 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index c67e6af8..2ee34bb3 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -245,16 +245,6 @@ impl WorkspacePromptProjectionCache { active.insert(workspace_id, projection.clone()); Ok(projection) } - - fn current( - &self, - workspace_id: &worker::WorkspaceId, - ) -> Result>, String> { - self.active - .lock() - .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string()) - .map(|active| active.get(workspace_id.as_str()).cloned()) - } } #[derive(Clone)] @@ -796,12 +786,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { self.worker_mutation_identity.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(), ); - let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?; - if let Some(workspace_id) = workspace_context.workspace_id() - && let Some(projection) = self.prompt_projection_cache.current(workspace_id)? - { - loader = loader.with_effective_catalog(projection.catalog.clone()); - } + let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; let session_dir = worker_aggregate_dir.join("session"); @@ -833,6 +818,15 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .await { Ok(worker) => worker, + Err(WorkerError::WorkerMetadataPending { .. }) + if workspace_context.workspace_id().is_some() + && request.config_bundle.is_none() => + { + return Err( + "pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config" + .to_string(), + ); + } Err(WorkerError::WorkerMetadataPending { .. }) if request.request.initial_input.is_none() => { @@ -2571,7 +2565,7 @@ mod tests { #[tokio::test] #[serial_test::serial(worker_allocation)] - async fn restore_pending_worker_uses_saved_manifest_snapshot() { + async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() { let root = tempfile::tempdir().unwrap(); let runtime_store_dir = root.path().join("runtime"); let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1)); @@ -2621,7 +2615,7 @@ mod tests { base_url: "http://workspace.invalid".to_string(), }); let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap(); - let controller = ProfileRuntimeWorkerFactory::new(root.path()) + let error = match ProfileRuntimeWorkerFactory::new(root.path()) .with_runtime_store_dir(&runtime_store_dir) .with_remote_worker_mutation_identity(identity) .restore_controller(WorkerExecutionRestoreRequest { @@ -2638,25 +2632,11 @@ mod tests { config_bundle: None, }) .await - .expect("pending restore should use the saved manifest snapshot"); - assert!(controller.handle.shared_state.flow_transition_enabled()); - let run_dir = runtime_store_dir.join("workers/1/runs/1"); - assert!(run_dir.join("worker.sock").exists()); - assert!(run_dir.join("worker.out.log").is_file()); - assert!(run_dir.join("worker.err.log").is_file()); - assert!(run_dir.join("artifacts").is_dir()); - assert!(run_dir.join("spawned").is_dir()); - - let shutdown = controller.shutdown.clone(); - controller.handle.send(Method::Shutdown).await.unwrap(); - if let Some(receiver) = shutdown.lock().await.take() { - receiver.await.unwrap(); - } - assert!( - run_dir.is_dir(), - "run evidence remains until a separate retention policy disposes it" - ); - assert!(!run_dir.join("worker.sock").exists()); + { + Ok(_) => panic!("pending Workspace Worker restore unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.contains("requires operation-owned launch material")); } #[tokio::test] diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index fa0f6596..56be1b96 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -4711,6 +4711,9 @@ where if state.entries_count == 0 { return Err(WorkerError::SegmentEmpty { segment_id }); } + if state.system_prompt.is_none() { + return Err(WorkerError::SegmentSystemPromptMissing { segment_id }); + } let mirror_entries: Vec = raw_entries.clone(); let scope_config = effective_restore_scope_config(&store, &manifest)?; @@ -5455,6 +5458,9 @@ pub enum WorkerError { #[error("session {segment_id} has no entries to restore")] SegmentEmpty { segment_id: SegmentId }, + #[error("session {segment_id} has no committed system prompt to restore")] + SegmentSystemPromptMissing { segment_id: SegmentId }, + #[error("worker metadata for {worker_name} was not found")] WorkerMetadataMissing { worker_name: String }, From d24d50cac93ae97a2b95d2f8ae7d7e1adba518d6 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:00:57 +0900 Subject: [PATCH 09/19] feat: refresh prompts at operation boundaries --- crates/worker/src/ipc/interceptor.rs | 83 +++++++++++++++++++++------- crates/worker/src/spawn/tool.rs | 8 ++- crates/worker/src/worker.rs | 82 ++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 4f388853..55266d31 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -11,6 +11,7 @@ use std::borrow::Cow; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use arc_swap::ArcSwap; use async_trait::async_trait; use llm_engine::Item; use llm_engine::UsageRecord; @@ -64,7 +65,7 @@ pub(crate) struct WorkerInterceptor { pending_attachments: Arc>>, /// Prompt catalog used to render pending notification entries into the /// same system-message text that will be persisted in history. - prompts: Arc, + prompts: Arc>, /// Type-erased commit handle. The interceptor uses it to commit /// `LogEntry::SystemItem` entries directly (sync) before /// returning the corresponding `Item::system_message`s up to the @@ -84,7 +85,7 @@ impl WorkerInterceptor { usage_history: Option>>>, pending_notifies: NotifyBuffer, pending_attachments: Arc>>, - prompts: Arc, + prompts: Arc>, log_writer: Option>, ) -> Self { Self { @@ -208,10 +209,11 @@ impl Interceptor for WorkerInterceptor { return Ok(Vec::new()); } + let prompts = self.prompts.load_full(); let mut system_items: Vec = Vec::with_capacity(drained.len()); let mut items: Vec = Vec::with_capacity(drained.len()); for entry in drained { - match build_system_item(&entry, &self.prompts) { + match build_system_item(&entry, &prompts) { Ok(system_item) => { items.push(system_item.to_history_item()); system_items.push(system_item); @@ -440,6 +442,10 @@ mod tests { HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, }; + fn test_prompts() -> Arc> { + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) + } + struct CountingHook(Arc); #[async_trait] @@ -541,7 +547,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx = ctx_items; @@ -571,7 +577,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), Some(Arc::new(RecordingSystemItemCommitter { committed: Arc::clone(&committed), })), @@ -609,7 +615,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ) .with_usage_tracker(usage_tracker); @@ -634,7 +640,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx = ctx_items; @@ -675,7 +681,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx = ctx_items; @@ -702,7 +708,7 @@ mod tests { Some(history), NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx = ctx_items; @@ -723,7 +729,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx: Vec = Vec::new(); @@ -751,7 +757,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), Some(committer), ); @@ -798,7 +804,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); @@ -855,7 +861,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"})); @@ -902,7 +908,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let info = task_tool_call_info("TaskList", serde_json::json!({})); @@ -953,7 +959,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let history = vec![Item::user_message("hi"), Item::assistant_message("done")]; @@ -985,7 +991,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), Some(Arc::new(RecordingSystemItemCommitter { committed: Arc::clone(&committed), })), @@ -1033,6 +1039,45 @@ mod tests { assert!(body.contains("track active work")); } + #[tokio::test] + async fn pending_notifications_use_the_latest_prompt_projection() { + let prompts = test_prompts(); + let buffer = NotifyBuffer::new(); + let interceptor = WorkerInterceptor::new( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + buffer.clone(), + Arc::new(Mutex::new(Vec::new())), + prompts.clone(), + None, + ); + + let current = prompts.load_full(); + let projection = current.projection(); + let mut templates = projection.templates.clone(); + templates.insert( + "internal.notify_wrapper".to_string(), + "CURRENT-PROJECTION {{ message }}".to_string(), + ); + let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new( + templates, + 2, + projection.schema_fingerprint.clone(), + projection.toolchain_fingerprint.clone(), + ) + .unwrap(); + projection.source_digest = "source-2".to_string(); + prompts.store(Arc::new( + PromptCatalog::from_projection(projection).unwrap(), + )); + + buffer.push_notify("updated".to_string(), false); + let appends = interceptor.pending_history_appends().await.unwrap(); + assert_eq!(appends.len(), 1); + assert!(format!("{:?}", appends[0]).contains("CURRENT-PROJECTION updated")); + } + #[tokio::test] async fn pending_history_appends_drains_buffer_into_items() { let registry = Arc::new(HookRegistryBuilder::new().build()); @@ -1046,7 +1091,7 @@ mod tests { None, buffer.clone(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); @@ -1083,7 +1128,7 @@ mod tests { None, buffer.clone(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx: Vec = vec![Item::user_message("hi")]; @@ -1113,7 +1158,7 @@ mod tests { None, NotifyBuffer::new(), Arc::new(Mutex::new(Vec::new())), - PromptCatalog::builtins_only().unwrap(), + test_prompts(), None, ); let mut ctx: Vec = Vec::new(); diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 17096356..26dae685 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use arc_swap::ArcSwap; use async_trait::async_trait; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use manifest::{ @@ -946,7 +947,7 @@ pub(crate) fn sub_worker_spawn_tool( registry: Arc, spawner_manifest: WorkerManifest, spawner_scope: SharedScope, - prompts: Arc, + prompts: Arc>, ) -> ToolDefinition { sub_worker_spawn_tool_impl( spawner_name, @@ -972,13 +973,14 @@ fn sub_worker_spawn_tool_impl( registry: Arc, spawner_manifest: WorkerManifest, spawner_scope: SharedScope, - prompts: Arc, + prompts: Arc>, ) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(SubWorkerSpawnInput); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); let available_profiles = AvailableProfiles::discover(&workspace_root); let description = prompts + .load_full() .sub_worker_spawn_tool_description( &available_profiles.compact_list(), &available_profiles.default_label(), @@ -1002,7 +1004,7 @@ fn sub_worker_spawn_tool_impl( spawner_cwd.clone(), registry.clone(), spawner_manifest.clone(), - prompts.source(), + prompts.load_full().source(), available_profiles, spawner_scope.clone(), DelegationScope::from_config(&spawner_manifest.delegation_scope) diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 56be1b96..f7606b20 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -67,7 +67,7 @@ use crate::ipc::alerter::Alerter; use crate::ipc::interceptor::WorkerInterceptor; use crate::ipc::notify_buffer::NotifyBuffer; use crate::prompt::agents_md::read_agents_md; -use crate::prompt::catalog::{CatalogError, PromptCatalog}; +use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection}; use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; @@ -224,6 +224,15 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { fn execute(&self, request: WorkspaceRequest) -> Result; + /// Resolve the Workspace's current immutable Prompt projection for future + /// operation boundaries. Creation and restore continue to use persisted + /// launch/session state; this hook never reconstructs historical prompts. + fn current_prompt_projection( + &self, + ) -> Result, WorkspaceClientError> { + Ok(None) + } + /// Executes the destructive WorkerRemove operation through Runtime-owned source proof. /// Target identity is operation data; source identity and permission are never caller inputs. fn execute_worker_remove( @@ -285,6 +294,12 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { Some(&self.context) } + fn current_prompt_projection( + &self, + ) -> Result, WorkspaceClientError> { + self.inner.current_prompt_projection() + } + fn execute( &self, mut request: WorkspaceRequest, @@ -872,7 +887,7 @@ pub struct Worker { /// sections, ...). Built from the 4-layer overlay in /// [`Self::from_manifest`], or defaults to the builtin pack when a /// Worker is constructed through lower-level paths that have no loader. - prompts: Arc, + prompts: Arc>, /// When true (default), the system-prompt assembler may append resident /// context from the workspace Memory document. Internal disposable /// workers disable this so resident memory exposure is opt-in per Worker. @@ -1146,7 +1161,7 @@ impl Worker { // `set_system_prompt_template`) can be captured by `SegmentStart`. let session_id = session_store::new_session_id(); let segment_id = session_store::new_segment_id(); - let prompts = PromptCatalog::builtins_only()?; + let prompts = Arc::new(ArcSwap::from(PromptCatalog::builtins_only()?)); let delegation_scope = DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; let scope = SharedScope::new(scope); @@ -1232,10 +1247,49 @@ impl Worker { self.inject_resident_summary = enabled; } - pub fn prompts(&self) -> Arc { + pub fn prompts(&self) -> Arc> { Arc::clone(&self.prompts) } + fn refresh_prompt_projection_for_future_operations(&self) -> Result<(), WorkerError> { + // The launch catalog remains authoritative until the initial system + // Prompt has been rendered and committed. Later operation boundaries + // may adopt the Workspace's current immutable projection. + if self.system_prompt_template.is_some() { + return Ok(()); + } + let Some(projection) = self + .workspace_context + .client() + .current_prompt_projection() + .map_err(|source| WorkerError::WorkspacePromptProjection { + message: source.to_string(), + })? + else { + return Ok(()); + }; + projection + .validate() + .map_err(|source| WorkerError::WorkspacePromptProjection { + message: source.to_string(), + })?; + let current = self.prompts.load(); + if current.projection().config_revision == projection.config_revision + && current.projection().source_digest == projection.source_digest + && current.projection().catalog_digest == projection.projection_digest + { + return Ok(()); + } + let catalog = PromptCatalog::load( + &PromptCatalogSource::builtins_only().with_effective_catalog(projection.catalog), + ) + .map_err(|source| WorkerError::WorkspacePromptProjection { + message: source.to_string(), + })?; + self.prompts.store(catalog); + Ok(()) + } + /// The current segment ID. Read lock-free from the shared session /// pointer so fork-time swaps are observed immediately. pub fn segment_id(&self) -> SegmentId { @@ -2020,6 +2074,7 @@ impl Worker { .local_working_directory() .map(|local| local.cwd.display().to_string()) .unwrap_or_else(|| "no local working directory".to_string()); + let prompt_catalog = self.prompts.load_full(); let ctx = SystemPromptContext { now: chrono::Utc::now(), cwd: cwd_for_prompt.into(), @@ -2029,7 +2084,7 @@ impl Worker { feature_instructions: &self.feature_instructions, agents_md: agents_md_read.and_then(|read| read.body), resident_summary: resident_summary.as_deref(), - prompts: &self.prompts, + prompts: &prompt_catalog, }; let rendered = template .render(&ctx) @@ -2085,6 +2140,7 @@ impl Worker { /// store, and runs pre-run compact (joining any in-flight memory task /// first so extract sees a stable history range). async fn prepare_for_run(&mut self) -> Result<(), WorkerError> { + self.refresh_prompt_projection_for_future_operations()?; self.ensure_interceptor_installed(); self.ensure_system_prompt_materialized().await?; self.cleanup_finished_memory_task(); @@ -2430,10 +2486,12 @@ impl Worker { fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> { let tool_result_summary = self .prompts() + .load_full() .interrupt_tool_result_summary() .map_err(WorkerError::from)?; let system_note = self .prompts() + .load_full() .interrupt_system_note() .map_err(WorkerError::from)?; @@ -3173,6 +3231,7 @@ impl Worker { let summary_client: Box = self.build_compactor_client()?; let summary_system_prompt = self .prompts + .load_full() .compact_system() .map_err(WorkerError::PromptCatalog)?; let mut summary_worker = Engine::new(summary_client).system_prompt(summary_system_prompt); @@ -3802,7 +3861,11 @@ impl Worker { } }; let memory_language = memory_language(memory_cfg); - let extract_system_prompt = match self.prompts.memory_extract_system(memory_language) { + let extract_system_prompt = match self + .prompts + .load_full() + .memory_extract_system(memory_language) + { Ok(prompt) => prompt, Err(err) => { audit @@ -5446,6 +5509,9 @@ pub enum WorkerError { #[error(transparent)] PromptCatalog(#[from] CatalogError), + #[error("failed to resolve current Workspace Prompt projection: {message}")] + WorkspacePromptProjection { message: String }, + #[error(transparent)] Skill(#[from] SkillClientError), @@ -5513,7 +5579,7 @@ struct WorkerCommon { scope: Scope, delegation_scope: DelegationScope, client: Box, - prompts: Arc, + prompts: Arc>, system_prompt_template: Option, feature_instructions: Vec, } @@ -5655,7 +5721,7 @@ fn prepare_worker_common_from_scope( DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; let client = crate::model_client::build_client(&manifest.model)?; - let prompts = PromptCatalog::load(loader)?; + let prompts = Arc::new(ArcSwap::from(PromptCatalog::load(loader)?)); let system_prompt_template = if parse_template { Some( SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone()) From 17d6789b41cbd3e99d265e74ed9bc4e805f07345 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:01:03 +0900 Subject: [PATCH 10/19] feat: propagate workspace prompt revisions --- crates/worker-runtime/src/execution.rs | 17 ++++ crates/worker-runtime/src/http_server.rs | 38 +++++++- crates/worker-runtime/src/runtime.rs | 23 +++++ crates/worker-runtime/src/worker_backend.rs | 86 ++++++++++++++++- crates/worker-runtime/src/worker_source.rs | 84 +++++++++++++++- crates/workspace-server/src/hosts.rs | 102 ++++++++++++++++++++ crates/workspace-server/src/server.rs | 8 ++ 7 files changed, 351 insertions(+), 7 deletions(-) diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 381e0c84..642e23e5 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -357,6 +357,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { )) } + /// Observe a newer immutable Workspace Prompt projection. Profile-backed + /// execution uses this as a revision notification; other backends may + /// safely ignore it. + fn observe_workspace_prompt_projection( + &self, + _projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + Ok(()) + } + fn dispatch_input( &self, handle: &WorkerExecutionHandle, @@ -469,6 +479,13 @@ impl WorkerExecutionBackendRef { self.backend.cleanup_working_directory(working_directory_id) } + pub(crate) fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.backend.observe_workspace_prompt_projection(projection) + } + pub(crate) fn dispatch_input( &self, handle: &WorkerExecutionHandle, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 3bdfbeff..f345ae02 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -195,6 +195,10 @@ fn runtime_http_router_with_optional_auth( "/v1/config-bundles/{bundle_id}/availability", get(check_config_bundle), ) + .route( + "/v1/workspace-prompt-projections", + post(observe_workspace_prompt_projection), + ) .route( "/v1/working-directories", get(list_working_directories).post(create_working_directory), @@ -285,6 +289,18 @@ pub struct RuntimeHttpConfigBundleSyncRequest { pub bundle: ConfigBundle, } +/// Server-owned notification carrying the Workspace's current immutable Prompt projection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHttpWorkspacePromptProjectionRequest { + pub projection: worker::WorkspacePromptProjection, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHttpWorkspacePromptProjectionResponse { + pub workspace_id: String, + pub config_revision: u64, +} + /// Config bundle availability response used by sync/check endpoints. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeHttpConfigBundleAvailabilityResponse { @@ -431,6 +447,23 @@ async fn store_config_bundle( })) } +async fn observe_workspace_prompt_projection( + State(state): State, + body: Result, JsonRejection>, +) -> RestResult { + let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; + let workspace_id = request.projection.workspace_id.clone(); + let config_revision = request.projection.config_revision; + state + .runtime + .observe_workspace_prompt_projection(request.projection) + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse { + workspace_id, + config_revision, + })) +} + async fn check_config_bundle( State(state): State, Path(bundle_id): Path, @@ -1518,7 +1551,10 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s { return Some("workdirs:operate"); } - if path.starts_with("/v1/config-bundles") || path.starts_with("/v1/working-directories") { + if path.starts_with("/v1/config-bundles") + || path.starts_with("/v1/workspace-prompt-projections") + || path.starts_with("/v1/working-directories") + { return Some("workers:create"); } if path.ends_with("/workspace-api") { diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index ec308e7f..dd8645f7 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -300,6 +300,29 @@ impl Runtime { state.check_config_bundle_ref(reference) } + /// Notify the execution backend of the Workspace's current immutable + /// Prompt projection. The Runtime keeps this cache outside persisted Worker + /// restore authority. + pub fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), RuntimeError> { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: + "Workspace Prompt projection notification requires an execution backend" + .to_string(), + } + })? + }; + backend + .observe_workspace_prompt_projection(projection) + .map_err(|message| RuntimeError::ExecutionBackendUnavailable { message }) + } + /// Stop the Runtime. v0 keeps data readable after stop, but rejects new /// create/send/worker lifecycle mutations. pub fn stop_runtime(&self) -> Result<(), RuntimeError> { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 2ee34bb3..963fe5c3 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -84,6 +84,13 @@ pub struct RuntimeWorkerController { /// controller-backed Worker for a Runtime catalog entry. #[async_trait] pub trait RuntimeWorkerFactory: Send + Sync + 'static { + fn observe_workspace_prompt_projection( + &self, + _projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + Ok(()) + } + async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, @@ -211,12 +218,22 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider { } #[derive(Debug, Default)] -struct WorkspacePromptProjectionCache { +pub(crate) struct WorkspacePromptProjectionCache { active: Mutex>>, } impl WorkspacePromptProjectionCache { - fn observe( + pub(crate) fn active( + &self, + workspace_id: &str, + ) -> Result>, String> { + self.active + .lock() + .map(|active| active.get(workspace_id).cloned()) + .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string()) + } + + pub(crate) fn observe( &self, projection: worker::WorkspacePromptProjection, ) -> Result, String> { @@ -448,6 +465,7 @@ impl RuntimeWorkspaceBackendRef { workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>, mutation_identity: Option<&RuntimeIdentityMaterial>, embedded_dispatcher: Option<&Arc>, + prompt_projection_cache: Option>, ) -> WorkerWorkspaceContext { match self { Self::None => WorkerWorkspaceContext::no_workspace(), @@ -462,6 +480,9 @@ impl RuntimeWorkspaceBackendRef { runtime_id.clone(), worker_ref.worker_id.to_string(), ); + if let Some(cache) = prompt_projection_cache { + client = client.with_prompt_projection_cache(cache); + } if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) { client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote( identity, @@ -560,6 +581,13 @@ fn runtime_local_workdir_session( #[async_trait] impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { + fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.prompt_projection_cache.observe(projection).map(|_| ()) + } + async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, @@ -599,6 +627,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { request.workspace_scope.as_ref(), self.worker_mutation_identity.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(), + Some(self.prompt_projection_cache.clone()), ); let selector = profile.as_ref(); let archive = self @@ -785,6 +814,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { request.workspace_scope.as_ref(), self.worker_mutation_identity.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(), + Some(self.prompt_projection_cache.clone()), ); let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; @@ -1341,6 +1371,13 @@ where &self.backend_id } + fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.factory.observe_workspace_prompt_projection(projection) + } + fn create_working_directory( &self, request: &WorkingDirectoryRequest, @@ -1922,6 +1959,46 @@ mod tests { use manifest::{Scope, WorkerManifest}; use session_store::{LogEntry, WorkerMetadataStore}; + #[test] + fn workspace_prompt_projection_notification_advances_shared_cache() { + let cache = WorkspacePromptProjectionCache::default(); + let catalog_v1 = worker::EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "prompt-v1".to_string())]), + 8, + "schema", + "toolchain", + ) + .unwrap(); + let projection_v1 = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-v1", + catalog_v1.catalog_digest.clone(), + catalog_v1, + ) + .unwrap(); + let catalog_v2 = worker::EffectivePromptCatalog::new( + BTreeMap::from([("default".to_string(), "prompt-v2".to_string())]), + 9, + "schema", + "toolchain", + ) + .unwrap(); + let projection_v2 = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-v2", + catalog_v2.catalog_digest.clone(), + catalog_v2.clone(), + ) + .unwrap(); + + cache.observe(projection_v1).unwrap(); + cache.observe(projection_v2).unwrap(); + + let active = cache.active("workspace-a").unwrap().unwrap(); + assert_eq!(active.config_revision, 9); + assert_eq!(active.catalog.catalog_digest, catalog_v2.catalog_digest); + } + #[test] fn workspace_prompt_projection_cache_rejects_same_revision_source_drift() { let catalog = worker::EffectivePromptCatalog::new( @@ -1968,12 +2045,12 @@ mod tests { let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main"); let before_restart = - backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None); + backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); let adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); let (after_restore_kind, after_restore_workspace_id) = adapter .run_on_adapter_runtime(async move { let after_restore = - backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None); + backend.worker_context(&worker_ref, Some(&scope), Some(&identity), None, None); let client = after_restore.client_handle(); Ok(( client.kind().to_string(), @@ -2163,6 +2240,7 @@ mod tests { request.workspace_scope.as_ref(), None, None, + None, ); let workspace_client = workspace_context.client_handle(); self.observed_workspace_clients.lock().unwrap().push(( diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index fd8daa44..43a838b0 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use worker::{ - WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, - WorkspaceResponse, + WorkspaceClient, WorkspaceClientError, WorkspacePromptProjection, WorkspaceRequest, + WorkspaceRequestMethod, WorkspaceResponse, }; use crate::auth::{ @@ -12,6 +12,7 @@ use crate::auth::{ WorkerMutationSourceClaims, new_token_id, }; use crate::runtime::RuntimeWorkspaceScope; +use crate::worker_backend::WorkspacePromptProjectionCache; pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60; @@ -293,6 +294,7 @@ pub struct RuntimeOwnedWorkspaceClient { worker_id: String, request_timeout: Option, worker_remove: Option, + prompt_projection_cache: Option>, } impl RuntimeOwnedWorkspaceClient { @@ -309,6 +311,7 @@ impl RuntimeOwnedWorkspaceClient { worker_id: worker_id.into(), request_timeout: None, worker_remove: None, + prompt_projection_cache: None, } } @@ -317,6 +320,14 @@ impl RuntimeOwnedWorkspaceClient { self } + pub(crate) fn with_prompt_projection_cache( + mut self, + cache: Arc, + ) -> Self { + self.prompt_projection_cache = Some(cache); + self + } + #[cfg(test)] fn with_request_timeout(mut self, request_timeout: Option) -> Self { self.request_timeout = request_timeout; @@ -385,6 +396,40 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { } } + fn current_prompt_projection( + &self, + ) -> Result, WorkspaceClientError> { + let Some(cache) = self.prompt_projection_cache.as_ref() else { + return Ok(None); + }; + if let Some(projection) = cache + .active(&self.workspace_id) + .map_err(WorkspaceClientError::Request)? + { + return Ok(Some((*projection).clone())); + } + let response = self.execute(WorkspaceRequest::get(format!( + "/api/w/{}/config-sources/active/prompt-projection", + self.workspace_id + )))?; + if !(200..300).contains(&response.status) { + return Err(WorkspaceClientError::Request(format!( + "active Workspace Prompt projection request failed with HTTP {}: {}", + response.status, response.body + ))); + } + let projection: WorkspacePromptProjection = + serde_json::from_str(&response.body).map_err(|error| { + WorkspaceClientError::Request(format!( + "invalid active Workspace Prompt projection response: {error}" + )) + })?; + let projection = cache + .observe(projection) + .map_err(WorkspaceClientError::Request)?; + Ok(Some((*projection).clone())) + } + fn execute_worker_remove( &self, target_runtime_id: &str, @@ -521,6 +566,41 @@ mod tests { verify_worker_mutation_source_proof, }; + #[test] + fn current_prompt_projection_uses_the_shared_runtime_cache_without_http() { + let cache = Arc::new(WorkspacePromptProjectionCache::default()); + let catalog = worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([( + "default".to_string(), + "workspace prompt".to_string(), + )]), + 3, + "schema", + "toolchain", + ) + .unwrap(); + let projection = WorkspacePromptProjection::new( + "workspace-a", + "source-3", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + cache.observe(projection).unwrap(); + let client = RuntimeOwnedWorkspaceClient::new( + "workspace-a", + "http://127.0.0.1:1", + "runtime-a", + "worker-a", + ) + .with_prompt_projection_cache(cache); + + let projection = client.current_prompt_projection().unwrap().unwrap(); + + assert_eq!(projection.config_revision, 3); + assert_eq!(projection.source_digest, "source-3"); + } + #[test] fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() { use std::io::{Read, Write}; diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index ab94b0b7..6087d38d 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -45,6 +45,7 @@ use worker_runtime::http_server::{ RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, + RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, }; use worker_runtime::identity::{ RuntimeWorkerRef, WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef, @@ -778,6 +779,13 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { } } + fn observe_workspace_prompt_projection( + &self, + _projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + Ok(()) + } + fn sync_config_bundle(&self, _bundle: ConfigBundle) -> ConfigBundleSyncResult { ConfigBundleSyncResult { state: WorkerOperationState::Unsupported, @@ -1185,6 +1193,36 @@ impl RuntimeRegistry { Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api)) } + pub fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Vec { + let runtimes = self + .runtimes + .read() + .map(|runtimes| runtimes.clone()) + .unwrap_or_default(); + runtimes + .into_iter() + .filter_map(|runtime| { + runtime + .observe_workspace_prompt_projection(projection.clone()) + .err() + .map(|message| { + diagnostic( + "workspace_prompt_projection_notification_failed", + DiagnosticSeverity::Warning, + format!( + "runtime '{}' rejected Workspace Prompt projection revision {}: {message}", + runtime.runtime_id(), projection.config_revision + ), + ) + }) + }) + .take(MAX_DIAGNOSTICS) + .collect() + } + pub fn spawn_worker( &self, runtime_id: &str, @@ -2040,6 +2078,15 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } } + fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.runtime + .observe_workspace_prompt_projection(projection) + .map_err(|error| error.to_string()) + } + fn sync_config_bundle(&self, bundle: ConfigBundle) -> ConfigBundleSyncResult { match self.runtime.store_config_bundle(bundle) { Ok(availability) => ConfigBundleSyncResult { @@ -3155,6 +3202,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } + fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.post_json::<_, RuntimeHttpWorkspacePromptProjectionResponse>( + "/v1/workspace-prompt-projections", + &RuntimeHttpWorkspacePromptProjectionRequest { projection }, + ) + .map(|_| ()) + .map_err(|error| error.message) + } + fn sync_config_bundle(&self, bundle: ConfigBundle) -> ConfigBundleSyncResult { let request = RuntimeHttpConfigBundleSyncRequest { bundle }; match self.post_json::<_, RuntimeHttpConfigBundleAvailabilityResponse>( @@ -4507,6 +4566,7 @@ mod tests { runtime_id: String, host_id: String, workers: Vec, + observed_prompt_revisions: Arc>>, } impl FixtureRuntime { @@ -4542,6 +4602,7 @@ mod tests { working_directory: None, diagnostics: Vec::new(), }], + observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())), } } } @@ -4551,6 +4612,17 @@ mod tests { &self.runtime_id } + fn observe_workspace_prompt_projection( + &self, + projection: worker::WorkspacePromptProjection, + ) -> Result<(), String> { + self.observed_prompt_revisions + .lock() + .map_err(|_| "prompt projection observations poisoned".to_string())? + .push(projection.config_revision); + Ok(()) + } + fn runtime_summary(&self, _limit: usize) -> RuntimeSummary { RuntimeSummary { runtime_id: self.runtime_id.clone(), @@ -4647,6 +4719,36 @@ mod tests { assert_eq!(from_runtime_a.label, "worker from runtime a"); } + #[test] + fn registry_broadcasts_workspace_prompt_projection_revisions() { + let runtime = + FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a"); + let observed = runtime.observed_prompt_revisions.clone(); + let registry = RuntimeRegistry::new(vec![Arc::new(runtime)]); + let catalog = worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([( + "default".to_string(), + "workspace prompt".to_string(), + )]), + 12, + "schema", + "toolchain", + ) + .unwrap(); + let projection = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-12", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + + let diagnostics = registry.observe_workspace_prompt_projection(projection); + + assert!(diagnostics.is_empty()); + assert_eq!(*observed.lock().unwrap(), vec![12]); + } + #[test] fn registry_worker_list_can_be_scoped_by_runtime_id() { let registry = RuntimeRegistry::new(vec![ diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 718312d4..e282e916 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2673,6 +2673,14 @@ async fn scoped_commit_workspace_config_tree( let state = api .config_store .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?; + if let Ok(projection) = api + .prompt_projection_cache + .resolve(&path.workspace_id, &state) + { + let _diagnostics = api + .runtime + .observe_workspace_prompt_projection((*projection).clone()); + } Ok(( StatusCode::CREATED, Json(WorkspaceConfigTreeResponse { From 82eaa986d8afe8121ce3cddc825371dddf86b082 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:03:18 +0900 Subject: [PATCH 11/19] fix: scope prompt projection cache fills --- crates/worker-runtime/src/worker_source.rs | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 43a838b0..750dc67c 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -424,6 +424,12 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { "invalid active Workspace Prompt projection response: {error}" )) })?; + if projection.workspace_id != self.workspace_id { + return Err(WorkspaceClientError::Request(format!( + "active Workspace Prompt projection scope mismatch: expected {}, got {}", + self.workspace_id, projection.workspace_id + ))); + } let projection = cache .observe(projection) .map_err(WorkspaceClientError::Request)?; @@ -601,6 +607,53 @@ mod tests { assert_eq!(projection.source_digest, "source-3"); } + #[test] + fn current_prompt_projection_rejects_cross_workspace_response() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let catalog = worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([( + "default".to_string(), + "foreign prompt".to_string(), + )]), + 4, + "schema", + "toolchain", + ) + .unwrap(); + let projection = WorkspacePromptProjection::new( + "workspace-b", + "source-4", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let body = serde_json::to_string(&projection).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") + .with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default())); + + let error = client.current_prompt_projection().unwrap_err(); + server.join().unwrap(); + + assert!(error.to_string().contains("scope mismatch")); + } + #[test] fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() { use std::io::{Read, Write}; From fb5f49d2a27e00d26682044621e527857bc0b547 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:05:43 +0900 Subject: [PATCH 12/19] test: align pending orchestrator restore contract --- crates/workspace-server/src/server.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e282e916..9aacc7b4 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -13131,7 +13131,7 @@ mod tests { } #[tokio::test] - async fn production_profile_backend_launches_and_restores_workspace_orchestrator() { + async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let config = test_server_config(workspace.path()); @@ -13171,17 +13171,19 @@ mod tests { ) .unwrap(); assert_eq!(stopped.state, WorkerOperationState::Accepted); - let Json(restored) = scoped_start_workspace_orchestrator( - State(api), - AxumPath(ScopedWorkspacePath { workspace_id }), + let error = scoped_start_workspace_orchestrator( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: workspace_id.clone(), + }), ) .await - .unwrap(); - assert_eq!(restored.disposition, "restored"); - assert!(restored.online); - assert_eq!( - restored.worker.expect("restored Orchestrator").worker, - worker + .expect_err("pending Workspace Orchestrator restore without durable Prompt must fail"); + assert!( + format!("{error:?}").contains( + "pending Workspace Worker restore requires operation-owned launch material" + ), + "unexpected restore error: {error:?}" ); } From fcc7c49ff1b600e5cee93dcc08cfdf0899871ac8 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:07:24 +0900 Subject: [PATCH 13/19] fix: enforce prompt projection workspace scope --- crates/worker-runtime/src/http_server.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index f345ae02..481c60fc 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -449,9 +449,19 @@ async fn store_config_bundle( async fn observe_workspace_prompt_projection( State(state): State, + auth: Option>, body: Result, JsonRejection>, ) -> RestResult { let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; + if let Some(scope) = auth_workspace_scope(&state, auth.as_ref())? + && request.projection.workspace_id != scope.workspace_id + { + return Err(RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "workspace_scope_mismatch", + "Workspace Prompt projection is outside the authenticated Workspace scope", + )); + } let workspace_id = request.projection.workspace_id.clone(); let config_revision = request.projection.config_revision; state From bb558bad2b12ffce2e208ec4ddb3b511a15fac96 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:19:31 +0900 Subject: [PATCH 14/19] fix: share compiled prompt projection cache --- crates/worker-runtime/src/worker_backend.rs | 83 +++++++++++++---- crates/worker-runtime/src/worker_source.rs | 98 +++++++++++++++++++-- crates/worker/src/lib.rs | 6 +- crates/worker/src/worker.rs | 53 +++++++---- 4 files changed, 200 insertions(+), 40 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 963fe5c3..d8f0e2f4 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -219,14 +219,26 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider { #[derive(Debug, Default)] pub(crate) struct WorkspacePromptProjectionCache { - active: Mutex>>, + active: Mutex>>, + fetch_gates: Mutex>>>, } impl WorkspacePromptProjectionCache { + pub(crate) fn fetch_gate(&self, workspace_id: &str) -> Result>, String> { + let mut gates = self + .fetch_gates + .lock() + .map_err(|_| "Workspace Prompt projection fetch gates lock was poisoned".to_string())?; + Ok(gates + .entry(workspace_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone()) + } + pub(crate) fn active( &self, workspace_id: &str, - ) -> Result>, String> { + ) -> Result>, String> { self.active .lock() .map(|active| active.get(workspace_id).cloned()) @@ -236,31 +248,40 @@ impl WorkspacePromptProjectionCache { pub(crate) fn observe( &self, projection: worker::WorkspacePromptProjection, - ) -> Result, String> { + ) -> Result, String> { projection.validate().map_err(|error| error.to_string())?; let workspace_id = projection.workspace_id.clone(); - let projection = Arc::new(projection); + let resolution = Arc::new( + worker::WorkspacePromptCatalogResolution::new(projection) + .map_err(|error| error.to_string())?, + ); let mut active = self .active .lock() .map_err(|_| "Workspace Prompt projection cache lock was poisoned".to_string())?; if let Some(current) = active.get(&workspace_id) { - if current.config_revision > projection.config_revision { + if current.projection.config_revision > resolution.projection.config_revision { return Ok(current.clone()); } - if current.config_revision == projection.config_revision - && (current.source_digest != projection.source_digest - || current.projection_digest != projection.projection_digest - || current.catalog.catalog_digest != projection.catalog.catalog_digest) + if current.projection.config_revision == resolution.projection.config_revision + && (current.projection.source_digest != resolution.projection.source_digest + || current.projection.projection_digest + != resolution.projection.projection_digest + || current.projection.catalog.catalog_digest + != resolution.projection.catalog.catalog_digest + || current.projection.catalog.schema_fingerprint + != resolution.projection.catalog.schema_fingerprint + || current.projection.catalog.toolchain_fingerprint + != resolution.projection.catalog.toolchain_fingerprint) { return Err(format!( "Workspace Prompt projection identity changed without a config revision transition: workspace={workspace_id} revision={}", - projection.config_revision + resolution.projection.config_revision )); } } - active.insert(workspace_id, projection.clone()); - Ok(projection) + active.insert(workspace_id, resolution.clone()); + Ok(resolution) } } @@ -677,7 +698,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) .map_err(|error| error.to_string())?; let projection = self.prompt_projection_cache.observe(projection)?; - loader = loader.with_effective_catalog(projection.catalog.clone()); + loader = loader.with_effective_catalog(projection.projection.catalog.clone()); } } let flow_transition_enabled = manifest.feature.flow.enabled; @@ -1995,8 +2016,11 @@ mod tests { cache.observe(projection_v2).unwrap(); let active = cache.active("workspace-a").unwrap().unwrap(); - assert_eq!(active.config_revision, 9); - assert_eq!(active.catalog.catalog_digest, catalog_v2.catalog_digest); + assert_eq!(active.projection.config_revision, 9); + assert_eq!( + active.projection.catalog.catalog_digest, + catalog_v2.catalog_digest + ); } #[test] @@ -2033,6 +2057,35 @@ mod tests { ); } + #[test] + fn workspace_prompt_projection_cache_rejects_same_revision_schema_drift() { + let templates = BTreeMap::from([("default".to_string(), "prompt".to_string())]); + let first_catalog = + worker::EffectivePromptCatalog::new(templates.clone(), 8, "schema-a", "toolchain") + .unwrap(); + let drifted_catalog = + worker::EffectivePromptCatalog::new(templates, 8, "schema-b", "toolchain").unwrap(); + let first = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-a", + first_catalog.catalog_digest.clone(), + first_catalog, + ) + .unwrap(); + let drifted = worker::WorkspacePromptProjection::new( + "workspace-a", + "source-a", + drifted_catalog.catalog_digest.clone(), + drifted_catalog, + ) + .unwrap(); + let cache = WorkspacePromptProjectionCache::default(); + + cache.observe(first).unwrap(); + let error = cache.observe(drifted).unwrap_err(); + assert!(error.contains("without a config revision transition")); + } + #[test] fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() { let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap(); diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 750dc67c..7454d53e 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use worker::{ - WorkspaceClient, WorkspaceClientError, WorkspacePromptProjection, WorkspaceRequest, - WorkspaceRequestMethod, WorkspaceResponse, + WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, + WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, }; use crate::auth::{ @@ -287,6 +287,7 @@ fn execute_remote_worker_remove_http_blocking( Ok(WorkspaceResponse { status, body }) } +#[derive(Clone)] pub struct RuntimeOwnedWorkspaceClient { workspace_id: String, base_url: String, @@ -398,15 +399,29 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn current_prompt_projection( &self, - ) -> Result, WorkspaceClientError> { + ) -> Result, WorkspaceClientError> { let Some(cache) = self.prompt_projection_cache.as_ref() else { return Ok(None); }; - if let Some(projection) = cache + if let Some(resolution) = cache .active(&self.workspace_id) .map_err(WorkspaceClientError::Request)? { - return Ok(Some((*projection).clone())); + return Ok(Some((*resolution).clone())); + } + let fetch_gate = cache + .fetch_gate(&self.workspace_id) + .map_err(WorkspaceClientError::Request)?; + let _fetch_guard = fetch_gate.lock().map_err(|_| { + WorkspaceClientError::Request( + "Workspace Prompt projection fetch gate was poisoned".to_string(), + ) + })?; + if let Some(resolution) = cache + .active(&self.workspace_id) + .map_err(WorkspaceClientError::Request)? + { + return Ok(Some((*resolution).clone())); } let response = self.execute(WorkspaceRequest::get(format!( "/api/w/{}/config-sources/active/prompt-projection", @@ -602,9 +617,78 @@ mod tests { .with_prompt_projection_cache(cache); let projection = client.current_prompt_projection().unwrap().unwrap(); + let second = client.current_prompt_projection().unwrap().unwrap(); - assert_eq!(projection.config_revision, 3); - assert_eq!(projection.source_digest, "source-3"); + assert_eq!(projection.projection.config_revision, 3); + assert_eq!(projection.projection.source_digest, "source-3"); + assert!(Arc::ptr_eq(&projection.catalog, &second.catalog)); + } + + #[test] + fn concurrent_prompt_projection_miss_fetches_once_and_shares_catalog() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::Barrier; + + let catalog = worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([( + "default".to_string(), + "shared prompt".to_string(), + )]), + 5, + "schema", + "toolchain", + ) + .unwrap(); + let projection = WorkspacePromptProjection::new( + "workspace-a", + "source-5", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let body = serde_json::to_string(&projection).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let cache = Arc::new(WorkspacePromptProjectionCache::default()); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") + .with_prompt_projection_cache(cache); + let barrier = Arc::new(Barrier::new(8)); + let threads = (0..8) + .map(|_| { + let client = client.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + client.current_prompt_projection().unwrap().unwrap() + }) + }) + .collect::>(); + let resolutions = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + server.join().unwrap(); + + let first = &resolutions[0].catalog; + assert!( + resolutions + .iter() + .all(|resolution| Arc::ptr_eq(first, &resolution.catalog)) + ); } #[test] diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index ad4fd207..76ced8ce 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -45,7 +45,7 @@ pub use shared_state::WorkerSharedState; pub use worker::{ LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient, - WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod, - WorkspaceResponse, apply_worker_manifest, marker_workspace_client, - unavailable_workspace_client, + WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution, + WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest, + marker_workspace_client, unavailable_workspace_client, }; diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index f7606b20..f618980a 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -212,6 +212,38 @@ pub enum WorkspaceClientError { Request(String), } +#[derive(Clone)] +pub struct WorkspacePromptCatalogResolution { + pub projection: Arc, + pub catalog: Arc, +} + +impl std::fmt::Debug for WorkspacePromptCatalogResolution { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspacePromptCatalogResolution") + .field("workspace_id", &self.projection.workspace_id) + .field("config_revision", &self.projection.config_revision) + .field("source_digest", &self.projection.source_digest) + .field("projection_digest", &self.projection.projection_digest) + .finish_non_exhaustive() + } +} + +impl WorkspacePromptCatalogResolution { + pub fn new(projection: WorkspacePromptProjection) -> Result { + projection.validate()?; + let catalog = PromptCatalog::load( + &PromptCatalogSource::builtins_only() + .with_effective_catalog(projection.catalog.clone()), + )?; + Ok(Self { + projection: Arc::new(projection), + catalog, + }) + } +} + /// Path-free Workspace operation authority injected by Runtime/host code. /// /// Workers receive this trait object rather than a Backend URL. The concrete @@ -229,7 +261,7 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { /// launch/session state; this hook never reconstructs historical prompts. fn current_prompt_projection( &self, - ) -> Result, WorkspaceClientError> { + ) -> Result, WorkspaceClientError> { Ok(None) } @@ -296,7 +328,7 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { fn current_prompt_projection( &self, - ) -> Result, WorkspaceClientError> { + ) -> Result, WorkspaceClientError> { self.inner.current_prompt_projection() } @@ -1258,7 +1290,7 @@ impl Worker { if self.system_prompt_template.is_some() { return Ok(()); } - let Some(projection) = self + let Some(resolution) = self .workspace_context .client() .current_prompt_projection() @@ -1268,25 +1300,16 @@ impl Worker { else { return Ok(()); }; - projection - .validate() - .map_err(|source| WorkerError::WorkspacePromptProjection { - message: source.to_string(), - })?; + let projection = &resolution.projection; let current = self.prompts.load(); if current.projection().config_revision == projection.config_revision && current.projection().source_digest == projection.source_digest && current.projection().catalog_digest == projection.projection_digest + && Arc::ptr_eq(¤t, &resolution.catalog) { return Ok(()); } - let catalog = PromptCatalog::load( - &PromptCatalogSource::builtins_only().with_effective_catalog(projection.catalog), - ) - .map_err(|source| WorkerError::WorkspacePromptProjection { - message: source.to_string(), - })?; - self.prompts.store(catalog); + self.prompts.store(resolution.catalog); Ok(()) } From 4208b6228ec55750c369369f1f2d5a39c135d1c8 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:22:41 +0900 Subject: [PATCH 15/19] fix: fence prompt projection freshness --- crates/worker-runtime/src/worker_source.rs | 77 ++++++++++++++++++++-- crates/worker/src/worker.rs | 6 +- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 7454d53e..15760540 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -399,6 +399,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn current_prompt_projection( &self, + minimum_revision: Option, ) -> Result, WorkspaceClientError> { let Some(cache) = self.prompt_projection_cache.as_ref() else { return Ok(None); @@ -406,6 +407,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { if let Some(resolution) = cache .active(&self.workspace_id) .map_err(WorkspaceClientError::Request)? + .filter(|resolution| { + minimum_revision + .map(|minimum| resolution.projection.config_revision >= minimum) + .unwrap_or(true) + }) { return Ok(Some((*resolution).clone())); } @@ -420,6 +426,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { if let Some(resolution) = cache .active(&self.workspace_id) .map_err(WorkspaceClientError::Request)? + .filter(|resolution| { + minimum_revision + .map(|minimum| resolution.projection.config_revision >= minimum) + .unwrap_or(true) + }) { return Ok(Some((*resolution).clone())); } @@ -445,10 +456,18 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { self.workspace_id, projection.workspace_id ))); } - let projection = cache + let resolution = cache .observe(projection) .map_err(WorkspaceClientError::Request)?; - Ok(Some((*projection).clone())) + if let Some(minimum_revision) = minimum_revision + && resolution.projection.config_revision < minimum_revision + { + return Err(WorkspaceClientError::Request(format!( + "active Workspace Prompt projection is stale: required revision {minimum_revision}, got {}", + resolution.projection.config_revision + ))); + } + Ok(Some((*resolution).clone())) } fn execute_worker_remove( @@ -616,14 +635,60 @@ mod tests { ) .with_prompt_projection_cache(cache); - let projection = client.current_prompt_projection().unwrap().unwrap(); - let second = client.current_prompt_projection().unwrap().unwrap(); + let projection = client.current_prompt_projection(None).unwrap().unwrap(); + let second = client.current_prompt_projection(None).unwrap().unwrap(); assert_eq!(projection.projection.config_revision, 3); assert_eq!(projection.projection.source_digest, "source-3"); assert!(Arc::ptr_eq(&projection.catalog, &second.catalog)); } + #[test] + fn prompt_projection_minimum_revision_rejects_stale_server_response() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let catalog = worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([("default".to_string(), "stale prompt".to_string())]), + 3, + "schema", + "toolchain", + ) + .unwrap(); + let projection = WorkspacePromptProjection::new( + "workspace-a", + "source-3", + catalog.catalog_digest.clone(), + catalog, + ) + .unwrap(); + let body = serde_json::to_string(&projection).unwrap(); + let cache = Arc::new(WorkspacePromptProjectionCache::default()); + cache.observe(projection).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") + .with_prompt_projection_cache(cache); + + let error = client.current_prompt_projection(Some(4)).unwrap_err(); + server.join().unwrap(); + + assert!(error.to_string().contains("required revision 4, got 3")); + } + #[test] fn concurrent_prompt_projection_miss_fetches_once_and_shares_catalog() { use std::io::{Read, Write}; @@ -673,7 +738,7 @@ mod tests { let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); - client.current_prompt_projection().unwrap().unwrap() + client.current_prompt_projection(None).unwrap().unwrap() }) }) .collect::>(); @@ -732,7 +797,7 @@ mod tests { RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a") .with_prompt_projection_cache(Arc::new(WorkspacePromptProjectionCache::default())); - let error = client.current_prompt_projection().unwrap_err(); + let error = client.current_prompt_projection(None).unwrap_err(); server.join().unwrap(); assert!(error.to_string().contains("scope mismatch")); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index f618980a..374a496f 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -261,6 +261,7 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { /// launch/session state; this hook never reconstructs historical prompts. fn current_prompt_projection( &self, + _minimum_revision: Option, ) -> Result, WorkspaceClientError> { Ok(None) } @@ -328,8 +329,9 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { fn current_prompt_projection( &self, + minimum_revision: Option, ) -> Result, WorkspaceClientError> { - self.inner.current_prompt_projection() + self.inner.current_prompt_projection(minimum_revision) } fn execute( @@ -1293,7 +1295,7 @@ impl Worker { let Some(resolution) = self .workspace_context .client() - .current_prompt_projection() + .current_prompt_projection(None) .map_err(|source| WorkerError::WorkspacePromptProjection { message: source.to_string(), })? From 50b05051bb6c25d2e46633ad6d6eacda237b874e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:40:29 +0900 Subject: [PATCH 16/19] feat: persist prompt render provenance --- crates/session-store/src/lib.rs | 4 +- crates/session-store/src/system_item.rs | 65 +++++++++- crates/tui/src/app.rs | 2 +- crates/worker/src/feature/builtin/task/mod.rs | 2 +- crates/worker/src/ipc/interceptor.rs | 111 +++++++++++++----- crates/worker/src/ipc/notify_buffer.rs | 15 ++- crates/worker/src/segment_log_sink.rs | 1 + crates/worker/src/worker.rs | 31 ++++- 8 files changed, 187 insertions(+), 44 deletions(-) diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index 17035316..93859748 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -53,7 +53,9 @@ pub use segment::{ }; pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use store::{Store, StoreError}; -pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event}; +pub use system_item::{ + PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event, +}; pub use worker_metadata::{ CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata, WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild, diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index e4fe4da5..ed662a62 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -82,6 +82,7 @@ impl SystemReminder { SystemReminderSource::TaskInactivity => SystemItem::TaskReminder { source: self.source, body: self.rendered_body(), + prompt_provenance: None, }, } } @@ -102,6 +103,16 @@ fn render_system_reminder(body: &str) -> String { format!("{SYSTEM_REMINDER_OPEN}\n{body}\n{SYSTEM_REMINDER_CLOSE}") } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PromptRenderProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + pub config_revision: u64, + pub source_digest: String, + pub projection_digest: String, + pub logical_name: String, +} + /// One agent-injected system item, tagged by origin. /// /// Each variant carries the kind-specific raw data clients use for @@ -124,13 +135,23 @@ pub enum SystemItem { /// `Method::Notify`. `message` is the raw caller-supplied text; /// `body` is the wrapped LLM-context form (Worker renders it via /// `notify_wrapper` at commit time). - Notification { message: String, body: String }, + Notification { + message: String, + body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, /// Lifecycle event reported by a child Worker via `Method::WorkerEvent`. /// `event` is the typed payload (so the TUI can render per-child /// banners without re-parsing); `body` is the wrapped LLM-context /// form (same `notify_wrapper` path as `Notification`). - WorkerEvent { event: WorkerEvent, body: String }, + WorkerEvent { + event: WorkerEvent, + body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, /// `@` file reference resolution. `body` is the rendered /// LLM-context text (`[File: ]\n…` for regular files, @@ -162,12 +183,18 @@ pub enum SystemItem { #[serde(default = "default_task_reminder_source")] source: SystemReminderSource, body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, }, /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. - Interrupt { body: String }, + Interrupt { + body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, } impl SystemItem { @@ -184,7 +211,7 @@ impl SystemItem { format!("Ignored legacy procedure item: /{slug}") } SystemItem::TaskReminder { body, .. } => body.clone(), - SystemItem::Interrupt { body } => body.clone(), + SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -237,11 +264,36 @@ pub fn render_worker_event(event: &WorkerEvent) -> String { mod tests { use super::*; + #[test] + fn legacy_prompt_rendered_items_default_missing_provenance() { + let notification: SystemItem = serde_json::from_str( + r#"{"kind":"notification","message":"legacy","body":"legacy body"}"#, + ) + .unwrap(); + let interrupt: SystemItem = + serde_json::from_str(r#"{"kind":"interrupt","body":"legacy interrupt"}"#).unwrap(); + assert!(matches!( + notification, + SystemItem::Notification { + prompt_provenance: None, + .. + } + )); + assert!(matches!( + interrupt, + SystemItem::Interrupt { + prompt_provenance: None, + .. + } + )); + } + #[test] fn notification_history_text_returns_stored_body() { let item = SystemItem::Notification { message: "child done".into(), body: "[Notification]\nchild done\n\n(non-blocking hint…)".into(), + prompt_provenance: None, }; assert_eq!( item.history_text(), @@ -256,6 +308,7 @@ mod tests { worker_name: "child".into(), }, body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(), + prompt_provenance: None, }; assert!(item.history_text().starts_with("[Notification]\n")); assert!(item.history_text().contains("`child`")); @@ -292,7 +345,7 @@ mod tests { fn system_reminder_source_is_retained_in_system_item() { let item = SystemReminder::task_inactivity("remember tasks").into_system_item(); match item { - SystemItem::TaskReminder { source, body } => { + SystemItem::TaskReminder { source, body, .. } => { assert_eq!(source, SystemReminderSource::TaskInactivity); assert_eq!( body, @@ -352,6 +405,7 @@ mod tests { worker_name: "child".into(), }, body: "[Notification] worker `child` finished a turn".into(), + prompt_provenance: None, }; let json = serde_json::to_string(&item).unwrap(); let parsed: SystemItem = serde_json::from_str(&json).unwrap(); @@ -359,6 +413,7 @@ mod tests { SystemItem::WorkerEvent { event: WorkerEvent::TurnEnded { worker_name }, body, + .. } => { assert_eq!(worker_name, "child"); assert!(body.contains("`child`")); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a765ea71..5f510610 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2165,7 +2165,7 @@ impl App { session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::TaskReminder { body, .. } - | session_store::SystemItem::Interrupt { body } => { + | session_store::SystemItem::Interrupt { body, .. } => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); } diff --git a/crates/worker/src/feature/builtin/task/mod.rs b/crates/worker/src/feature/builtin/task/mod.rs index c0317983..4cce2231 100644 --- a/crates/worker/src/feature/builtin/task/mod.rs +++ b/crates/worker/src/feature/builtin/task/mod.rs @@ -334,7 +334,7 @@ mod tests { } let queued = pending.lock().expect("pending queue poisoned"); - let SystemItem::TaskReminder { source, body } = &queued[0] else { + let SystemItem::TaskReminder { source, body, .. } = &queued[0] else { panic!("unexpected system item: {:?}", queued[0]); }; assert_eq!(*source, SystemReminderSource::TaskInactivity); diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 55266d31..8be84982 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -21,7 +21,6 @@ use llm_engine::interceptor::{ }; use llm_engine::tool::ToolOutput; use tracing::info; -use tracing::warn; use crate::compact::state::CompactState; use crate::compact::usage_tracker::UsageTracker; @@ -32,7 +31,7 @@ use crate::hook::{ HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo, SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo, }; -use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item}; +use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::prompt::catalog::PromptCatalog; use crate::worker::SystemItemCommitter; use llm_engine::token_counter::total_tokens; @@ -66,6 +65,8 @@ pub(crate) struct WorkerInterceptor { /// Prompt catalog used to render pending notification entries into the /// same system-message text that will be persisted in history. prompts: Arc>, + /// Workspace scope associated with Prompt projection provenance. + prompt_workspace_id: Option, /// Type-erased commit handle. The interceptor uses it to commit /// `LogEntry::SystemItem` entries directly (sync) before /// returning the corresponding `Item::system_message`s up to the @@ -96,6 +97,7 @@ impl WorkerInterceptor { pending_notifies, pending_attachments, prompts, + prompt_workspace_id: None, log_writer, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), @@ -107,6 +109,11 @@ impl WorkerInterceptor { self } + pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option) -> Self { + self.prompt_workspace_id = workspace_id; + self + } + /// Commit each `SystemItem` as its own `LogEntry::SystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -163,6 +170,32 @@ impl WorkerInterceptor { } false } + fn attach_prompt_provenance(&self, items: &mut [SystemItem]) { + let prompts = self.prompts.load(); + let projection = prompts.projection(); + let provenance = |logical_name: &str| session_store::PromptRenderProvenance { + workspace_id: self.prompt_workspace_id.clone(), + config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), + projection_digest: projection.catalog_digest.clone(), + logical_name: logical_name.to_string(), + }; + for item in items { + match item { + SystemItem::TaskReminder { + prompt_provenance, .. + } if prompt_provenance.is_none() => { + *prompt_provenance = Some(provenance("internal.task_reminder")); + } + SystemItem::Interrupt { + prompt_provenance, .. + } if prompt_provenance.is_none() => { + *prompt_provenance = Some(provenance("internal.interrupt_system_note")); + } + _ => {} + } + } + } } #[async_trait] @@ -181,7 +214,7 @@ impl Interceptor for WorkerInterceptor { return action.into(); } } - let extras: Vec = std::mem::take( + let mut extras: Vec = std::mem::take( &mut *self .pending_attachments .lock() @@ -195,6 +228,7 @@ impl Interceptor for WorkerInterceptor { // commits land BEFORE the worker pushes its // `Item::system_message`s, so on-disk order matches // worker-history order. + self.attach_prompt_provenance(&mut extras); let items: Vec = extras.iter().map(SystemItem::to_history_item).collect(); match self.commit_system_items(&extras) { Ok(()) => PromptAction::ContinueWith(items), @@ -210,31 +244,22 @@ impl Interceptor for WorkerInterceptor { } let prompts = self.prompts.load_full(); + let projection = prompts.projection(); + let provenance = session_store::PromptRenderProvenance { + workspace_id: self.prompt_workspace_id.clone(), + config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), + projection_digest: projection.catalog_digest.clone(), + logical_name: "internal.notify_wrapper".to_string(), + }; let mut system_items: Vec = Vec::with_capacity(drained.len()); let mut items: Vec = Vec::with_capacity(drained.len()); for entry in drained { - match build_system_item(&entry, &prompts) { - Ok(system_item) => { - items.push(system_item.to_history_item()); - system_items.push(system_item); - } - Err(e) => { - // A render failure here would starve the LLM of - // the notify text. Fall back to a raw item so the - // trigger still lands in history; the entry will - // simply be skipped from the SystemItem batch. - warn!(error = %e, "failed to render notify_wrapper; using raw message"); - let fallback = match &entry { - super::notify_buffer::PendingNotify::Notify { message, .. } => { - message.clone() - } - super::notify_buffer::PendingNotify::WorkerEvent { event } => { - session_store::render_worker_event(event) - } - }; - items.push(Item::system_message(fallback)); - } - } + let system_item = + build_system_item_with_provenance(&entry, &prompts, Some(provenance.clone())) + .map_err(|error| format!("failed to render notify_wrapper: {error}"))?; + items.push(system_item.to_history_item()); + system_items.push(system_item); } self.commit_system_items(&system_items) .map_err(|error| format!("session persistence failed: {error}"))?; @@ -265,11 +290,12 @@ impl Interceptor for WorkerInterceptor { } } - let system_items: Vec = std::mem::take( + let mut system_items: Vec = std::mem::take( &mut *pending_hook_system_items .lock() .expect("pending hook system-item queue poisoned"), ); + self.attach_prompt_provenance(&mut system_items); let appended_items: Vec = system_items .iter() .map(SystemItem::to_history_item) @@ -1033,16 +1059,26 @@ mod tests { .lock() .expect("committed system-item list poisoned"); assert_eq!(committed.len(), 1); - let SystemItem::TaskReminder { body, .. } = &committed[0] else { - panic!("expected task reminder, got {:?}", committed[0]); + let SystemItem::TaskReminder { + body, + prompt_provenance: Some(provenance), + .. + } = &committed[0] + else { + panic!( + "expected task reminder with Prompt provenance, got {:?}", + committed[0] + ); }; assert!(body.contains("track active work")); + assert_eq!(provenance.logical_name, "internal.task_reminder"); } #[tokio::test] async fn pending_notifications_use_the_latest_prompt_projection() { let prompts = test_prompts(); let buffer = NotifyBuffer::new(); + let committed = Arc::new(Mutex::new(Vec::new())); let interceptor = WorkerInterceptor::new( Arc::new(HookRegistryBuilder::new().build()), None, @@ -1050,8 +1086,11 @@ mod tests { buffer.clone(), Arc::new(Mutex::new(Vec::new())), prompts.clone(), - None, - ); + Some(Arc::new(RecordingSystemItemCommitter { + committed: committed.clone(), + })), + ) + .with_prompt_workspace_id(Some("workspace-a".to_string())); let current = prompts.load_full(); let projection = current.projection(); @@ -1076,6 +1115,18 @@ mod tests { let appends = interceptor.pending_history_appends().await.unwrap(); assert_eq!(appends.len(), 1); assert!(format!("{:?}", appends[0]).contains("CURRENT-PROJECTION updated")); + let committed = committed.lock().unwrap(); + let SystemItem::Notification { + prompt_provenance: Some(provenance), + .. + } = &committed[0] + else { + panic!("notification Prompt provenance was not committed"); + }; + assert_eq!(provenance.workspace_id.as_deref(), Some("workspace-a")); + assert_eq!(provenance.config_revision, 2); + assert_eq!(provenance.source_digest, "source-2"); + assert_eq!(provenance.logical_name, "internal.notify_wrapper"); } #[tokio::test] diff --git a/crates/worker/src/ipc/notify_buffer.rs b/crates/worker/src/ipc/notify_buffer.rs index 64c0483c..b07df6cd 100644 --- a/crates/worker/src/ipc/notify_buffer.rs +++ b/crates/worker/src/ipc/notify_buffer.rs @@ -111,9 +111,18 @@ impl NotifyBuffer { /// Render one pending entry into a typed `SystemItem`. The /// `notify_wrapper` prompt produces the LLM-context body for both /// `Notify` (raw message) and `WorkerEvent` (rendered event line). +#[cfg(test)] pub(crate) fn build_system_item( entry: &PendingNotify, prompts: &PromptCatalog, +) -> Result { + build_system_item_with_provenance(entry, prompts, None) +} + +pub(crate) fn build_system_item_with_provenance( + entry: &PendingNotify, + prompts: &PromptCatalog, + prompt_provenance: Option, ) -> Result { match entry { PendingNotify::Notify { message, .. } => { @@ -121,6 +130,7 @@ pub(crate) fn build_system_item( Ok(SystemItem::Notification { message: message.clone(), body, + prompt_provenance, }) } PendingNotify::WorkerEvent { event } => { @@ -129,6 +139,7 @@ pub(crate) fn build_system_item( Ok(SystemItem::WorkerEvent { event: event.clone(), body, + prompt_provenance, }) } } @@ -178,7 +189,7 @@ mod tests { let catalog = PromptCatalog::builtins_only().unwrap(); let item = build_system_item(&entry, &catalog).unwrap(); match item { - SystemItem::Notification { message, body } => { + SystemItem::Notification { message, body, .. } => { assert_eq!(message, "hello"); assert!(body.contains("[Notification]")); assert!(body.contains("hello")); @@ -198,7 +209,7 @@ mod tests { let catalog = PromptCatalog::builtins_only().unwrap(); let item = build_system_item(&entry, &catalog).unwrap(); match item { - SystemItem::WorkerEvent { event, body } => { + SystemItem::WorkerEvent { event, body, .. } => { assert!( matches!(event, WorkerEvent::TurnEnded { ref worker_name } if worker_name == "child") ); diff --git a/crates/worker/src/segment_log_sink.rs b/crates/worker/src/segment_log_sink.rs index 0951d80c..45c6b2b7 100644 --- a/crates/worker/src/segment_log_sink.rs +++ b/crates/worker/src/segment_log_sink.rs @@ -281,6 +281,7 @@ mod tests { item: session_store::SystemItem::Notification { message: text.to_owned(), body: format!("[Notification] {text}"), + prompt_provenance: None, }, } } diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 374a496f..dd4ec9f2 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -13,8 +13,8 @@ use llm_engine::llm_client::types::Role; use llm_engine::state::Mutable; use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord}; use session_store::{ - LogEntry, SegmentId, SessionExtension, SessionId, Store, StoreError, SystemItem, segment_log, - to_logged, + LogEntry, PromptRenderProvenance, SegmentId, SessionExtension, SessionId, Store, StoreError, + SystemItem, segment_log, to_logged, }; use session_store::{ WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild, @@ -1285,6 +1285,21 @@ impl Worker { Arc::clone(&self.prompts) } + fn prompt_render_provenance(&self, logical_name: &str) -> PromptRenderProvenance { + let prompts = self.prompts.load(); + let projection = prompts.projection(); + PromptRenderProvenance { + workspace_id: self + .workspace_context + .workspace_id() + .map(|workspace_id| workspace_id.as_str().to_string()), + config_revision: projection.config_revision, + source_digest: projection.source_digest.clone(), + projection_digest: projection.catalog_digest.clone(), + logical_name: logical_name.to_string(), + } + } + fn refresh_prompt_projection_for_future_operations(&self) -> Result<(), WorkerError> { // The launch catalog remains authoritative until the initial system // Prompt has been rendered and committed. Later operation boundaries @@ -2037,7 +2052,12 @@ impl Worker { self.prompts.clone(), self.log_writer.clone(), ) - .with_usage_tracker(self.usage_tracker.clone()); + .with_usage_tracker(self.usage_tracker.clone()) + .with_prompt_workspace_id( + self.workspace_context + .workspace_id() + .map(|workspace_id| workspace_id.as_str().to_string()), + ); self.engine_mut().set_interceptor(interceptor); self.interceptor_installed = true; } @@ -2527,10 +2547,13 @@ impl Worker { if !closures.is_empty() { self.engine_mut().append_history(closures)?; } + let interrupt_prompt_provenance = + self.prompt_render_provenance("internal.interrupt_system_note"); self.commit_entry(LogEntry::SystemItem { ts: segment_log::now_millis(), item: SystemItem::Interrupt { body: system_note.clone(), + prompt_provenance: Some(interrupt_prompt_provenance), }, })?; self.engine_mut() @@ -6879,7 +6902,7 @@ mod build_summary_prompt_tests { matches!( entry, LogEntry::SystemItem { - item: SystemItem::Interrupt { body }, + item: SystemItem::Interrupt { body, .. }, .. } if body == &interrupt_note ) From 1e33b2945c9ae2abbec303e48163c961a79899d5 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:45:45 +0900 Subject: [PATCH 17/19] fix: requeue uncommitted notifications --- crates/worker/src/ipc/interceptor.rs | 62 +++++++++++++++++++++++--- crates/worker/src/ipc/notify_buffer.rs | 17 +++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 8be84982..14bc2288 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -254,15 +254,25 @@ impl Interceptor for WorkerInterceptor { }; let mut system_items: Vec = Vec::with_capacity(drained.len()); let mut items: Vec = Vec::with_capacity(drained.len()); - for entry in drained { - let system_item = - build_system_item_with_provenance(&entry, &prompts, Some(provenance.clone())) - .map_err(|error| format!("failed to render notify_wrapper: {error}"))?; + for entry in &drained { + let system_item = match build_system_item_with_provenance( + entry, + &prompts, + Some(provenance.clone()), + ) { + Ok(system_item) => system_item, + Err(error) => { + self.pending_notifies.requeue_front(drained); + return Err(format!("failed to render notify_wrapper: {error}")); + } + }; items.push(system_item.to_history_item()); system_items.push(system_item); } - self.commit_system_items(&system_items) - .map_err(|error| format!("session persistence failed: {error}"))?; + if let Err(error) = self.commit_system_items(&system_items) { + self.pending_notifies.requeue_front(drained); + return Err(format!("session persistence failed: {error}")); + } Ok(items) } @@ -1129,6 +1139,46 @@ mod tests { assert_eq!(provenance.logical_name, "internal.notify_wrapper"); } + #[tokio::test] + async fn notify_render_failure_requeues_without_context_only_fallback() { + let prompts = test_prompts(); + let buffer = NotifyBuffer::new(); + let interceptor = WorkerInterceptor::new( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + buffer.clone(), + Arc::new(Mutex::new(Vec::new())), + prompts.clone(), + None, + ); + let current = prompts.load_full(); + let projection = current.projection(); + let mut templates = projection.templates.clone(); + templates.insert( + "internal.notify_wrapper".to_string(), + "{{ message | missing_notify_filter }}".to_string(), + ); + let mut projection = crate::prompt::catalog::EffectivePromptCatalog::new( + templates, + 3, + projection.schema_fingerprint.clone(), + projection.toolchain_fingerprint.clone(), + ) + .unwrap(); + projection.source_digest = "source-3".to_string(); + prompts.store(Arc::new( + PromptCatalog::from_projection(projection).unwrap(), + )); + buffer.push_notify("must persist".to_string(), false); + + let error = interceptor.pending_history_appends().await.unwrap_err(); + + assert!(error.contains("failed to render notify_wrapper")); + let requeued = buffer.drain(); + assert_eq!(requeued.len(), 1); + } + #[tokio::test] async fn pending_history_appends_drains_buffer_into_items() { let registry = Arc::new(HookRegistryBuilder::new().build()); diff --git a/crates/worker/src/ipc/notify_buffer.rs b/crates/worker/src/ipc/notify_buffer.rs index b07df6cd..0feb47fc 100644 --- a/crates/worker/src/ipc/notify_buffer.rs +++ b/crates/worker/src/ipc/notify_buffer.rs @@ -89,6 +89,23 @@ impl NotifyBuffer { q.drain(..).collect() } + /// Restore a failed drain ahead of entries queued concurrently while the + /// consumer was rendering. FIFO order is preserved. + pub(crate) fn requeue_front(&self, entries: Vec) { + let mut q = self.inner.lock().expect("notify buffer poisoned"); + for entry in entries.into_iter().rev() { + q.push_front(entry); + } + while q.len() > CAPACITY { + let dropped = q.pop_front(); + warn!( + capacity = CAPACITY, + dropped = ?dropped, + "notify buffer overflow while restoring failed drain; dropped oldest" + ); + } + } + /// Whether an undrained `Method::Notify { auto_run: true }` remains. pub fn has_auto_run_pending(&self) -> bool { self.inner From 5b5396599d1beee27125562b3bbd4750ee684710 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 06:52:32 +0900 Subject: [PATCH 18/19] fix: restore pending workspace prompt catalog --- crates/worker-runtime/src/worker_backend.rs | 142 ++++++++++++++------ 1 file changed, 104 insertions(+), 38 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d8f0e2f4..804076e0 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -412,6 +412,48 @@ impl ProfileRuntimeWorkerFactory { .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; Ok((manifest, PromptCatalogSource::builtins_only())) } + fn observe_bundle_prompt_projection( + &self, + bundle: &crate::config_bundle::ConfigBundle, + expected_workspace_id: Option<&str>, + ) -> Result>, String> { + let Some(prompt_catalog) = bundle.prompt_catalog.clone() else { + return Ok(None); + }; + if let Some(expected_workspace_id) = expected_workspace_id + && bundle.metadata.workspace_id != expected_workspace_id + { + return Err(format!( + "Workspace Prompt projection scope mismatch: expected {expected_workspace_id}, got {}", + bundle.metadata.workspace_id + )); + } + let source_digest = if prompt_catalog.source_digest.is_empty() { + bundle + .metadata + .provenance + .detail + .as_deref() + .and_then(|detail| { + detail + .split(';') + .find_map(|part| part.strip_prefix("source_tree_digest=")) + }) + .unwrap_or(&prompt_catalog.catalog_digest) + .to_string() + } else { + prompt_catalog.source_digest.clone() + }; + let projection = worker::WorkspacePromptProjection::new( + bundle.metadata.workspace_id.clone(), + source_digest, + prompt_catalog.catalog_digest.clone(), + prompt_catalog, + ) + .map_err(|error| error.to_string())?; + self.prompt_projection_cache.observe(projection).map(Some) + } + async fn resolve_profile_source_archive( &self, source: &ProfileSourceArchiveSource, @@ -672,34 +714,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )? } }; - if let Some(bundle) = request.config_bundle.as_ref() { - if let Some(prompt_catalog) = bundle.prompt_catalog.clone() { - let source_digest = if prompt_catalog.source_digest.is_empty() { - bundle - .metadata - .provenance - .detail - .as_deref() - .and_then(|detail| { - detail - .split(';') - .find_map(|part| part.strip_prefix("source_tree_digest=")) - }) - .unwrap_or(&prompt_catalog.catalog_digest) - .to_string() - } else { - prompt_catalog.source_digest.clone() - }; - let projection = worker::WorkspacePromptProjection::new( - bundle.metadata.workspace_id.clone(), - source_digest, - prompt_catalog.catalog_digest.clone(), - prompt_catalog, - ) - .map_err(|error| error.to_string())?; - let projection = self.prompt_projection_cache.observe(projection)?; - loader = loader.with_effective_catalog(projection.projection.catalog.clone()); - } + if let Some(bundle) = request.config_bundle.as_ref() + && let Some(resolution) = + self.observe_bundle_prompt_projection(bundle, observation_workspace_id.as_deref())? + { + loader = loader.with_effective_catalog(resolution.projection.catalog.clone()); } let flow_transition_enabled = manifest.feature.flow.enabled; @@ -869,23 +888,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .await { Ok(worker) => worker, - Err(WorkerError::WorkerMetadataPending { .. }) - if workspace_context.workspace_id().is_some() - && request.config_bundle.is_none() => - { - return Err( - "pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config" - .to_string(), - ); - } Err(WorkerError::WorkerMetadataPending { .. }) if request.request.initial_input.is_none() => { + let pending_loader = if workspace_context.workspace_id().is_some() { + let bundle = request.config_bundle.as_ref().ok_or_else(|| { + "pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config" + .to_string() + })?; + let resolution = self + .observe_bundle_prompt_projection( + bundle, + observation_workspace_id.as_deref(), + )? + .ok_or_else(|| { + "pending Workspace Worker restore requires a saved Workspace Prompt projection" + .to_string() + })?; + loader + .clone() + .with_effective_catalog(resolution.projection.catalog.clone()) + } else { + loader.clone() + }; Worker::restore_pending_from_worker_metadata_with_context( &worker_name, manifest.clone(), store, - loader, + pending_loader, workspace_context, filesystem_authority, ) @@ -2694,6 +2724,42 @@ mod tests { .expect("embedded archive should resolve without Backend resource client"); } + #[test] + fn pending_restore_launch_material_preserves_workspace_prompt_catalog() { + let root = tempfile::tempdir().unwrap(); + let factory = ProfileRuntimeWorkerFactory::new(root.path()); + let builtins = worker::PromptCatalog::builtins_only().unwrap(); + let projection = builtins.projection(); + let mut templates = projection.templates.clone(); + templates.insert( + "internal.notify_wrapper".to_string(), + "PENDING-LAUNCH {{ message }}".to_string(), + ); + let mut effective = worker::EffectivePromptCatalog::new( + templates, + 7, + projection.schema_fingerprint.clone(), + projection.toolchain_fingerprint.clone(), + ) + .unwrap(); + effective.source_digest = "source-7".to_string(); + let mut bundle = test_bundle(); + bundle.metadata.workspace_id = "workspace-restore".to_string(); + bundle.prompt_catalog = Some(effective); + bundle = bundle.with_computed_digest(); + + let resolution = factory + .observe_bundle_prompt_projection(&bundle, Some("workspace-restore")) + .unwrap() + .unwrap(); + + assert_eq!(resolution.projection.config_revision, 7); + assert_eq!( + resolution.catalog.notify_wrapper("restored").unwrap(), + "PENDING-LAUNCH restored" + ); + } + #[tokio::test] #[serial_test::serial(worker_allocation)] async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() { From 89ee5e48a57299eb2e2586284b41e1cd0d0ca9f6 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 07:03:15 +0900 Subject: [PATCH 19/19] fix: use active prompt projection route --- crates/worker-runtime/src/worker_source.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 15760540..8acde9f1 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -435,7 +435,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { return Ok(Some((*resolution).clone())); } let response = self.execute(WorkspaceRequest::get(format!( - "/api/w/{}/config-sources/active/prompt-projection", + "/api/w/{}/config/projections/prompts", self.workspace_id )))?; if !(200..300).contains(&response.status) { @@ -718,7 +718,12 @@ mod tests { let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0_u8; 4096]; - let _ = stream.read(&mut request).unwrap(); + let read = stream.read(&mut request).unwrap(); + let request = std::str::from_utf8(&request[..read]).unwrap(); + assert!( + request.contains("GET /api/w/workspace-a/config/projections/prompts "), + "unexpected Prompt projection request: {request}" + ); write!( stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",