chore: update ticket source from develop
This commit is contained in:
@@ -53,7 +53,9 @@ pub use segment::{
|
|||||||
};
|
};
|
||||||
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
|
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
|
||||||
pub use store::{Store, StoreError};
|
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::{
|
pub use worker_metadata::{
|
||||||
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
||||||
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ impl SystemReminder {
|
|||||||
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
|
SystemReminderSource::TaskInactivity => SystemItem::TaskReminder {
|
||||||
source: self.source,
|
source: self.source,
|
||||||
body: self.rendered_body(),
|
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}")
|
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<String>,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub source_digest: String,
|
||||||
|
pub projection_digest: String,
|
||||||
|
pub logical_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// One agent-injected system item, tagged by origin.
|
/// One agent-injected system item, tagged by origin.
|
||||||
///
|
///
|
||||||
/// Each variant carries the kind-specific raw data clients use for
|
/// 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;
|
/// `Method::Notify`. `message` is the raw caller-supplied text;
|
||||||
/// `body` is the wrapped LLM-context form (Worker renders it via
|
/// `body` is the wrapped LLM-context form (Worker renders it via
|
||||||
/// `notify_wrapper` at commit time).
|
/// `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<PromptRenderProvenance>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Lifecycle event reported by a child Worker via `Method::WorkerEvent`.
|
/// Lifecycle event reported by a child Worker via `Method::WorkerEvent`.
|
||||||
/// `event` is the typed payload (so the TUI can render per-child
|
/// `event` is the typed payload (so the TUI can render per-child
|
||||||
/// banners without re-parsing); `body` is the wrapped LLM-context
|
/// banners without re-parsing); `body` is the wrapped LLM-context
|
||||||
/// form (same `notify_wrapper` path as `Notification`).
|
/// 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<PromptRenderProvenance>,
|
||||||
|
},
|
||||||
|
|
||||||
/// `@<path>` file reference resolution. `body` is the rendered
|
/// `@<path>` file reference resolution. `body` is the rendered
|
||||||
/// LLM-context text (`[File: <path>]\n…` for regular files,
|
/// LLM-context text (`[File: <path>]\n…` for regular files,
|
||||||
@@ -162,12 +183,18 @@ pub enum SystemItem {
|
|||||||
#[serde(default = "default_task_reminder_source")]
|
#[serde(default = "default_task_reminder_source")]
|
||||||
source: SystemReminderSource,
|
source: SystemReminderSource,
|
||||||
body: String,
|
body: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
prompt_provenance: Option<PromptRenderProvenance>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Synthetic note inserted after an interrupted turn before the next
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
/// previous turn was cut short.
|
/// previous turn was cut short.
|
||||||
Interrupt { body: String },
|
Interrupt {
|
||||||
|
body: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
prompt_provenance: Option<PromptRenderProvenance>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemItem {
|
impl SystemItem {
|
||||||
@@ -184,7 +211,7 @@ impl SystemItem {
|
|||||||
format!("Ignored legacy procedure item: /{slug}")
|
format!("Ignored legacy procedure item: /{slug}")
|
||||||
}
|
}
|
||||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn notification_history_text_returns_stored_body() {
|
fn notification_history_text_returns_stored_body() {
|
||||||
let item = SystemItem::Notification {
|
let item = SystemItem::Notification {
|
||||||
message: "child done".into(),
|
message: "child done".into(),
|
||||||
body: "[Notification]\nchild done\n\n(non-blocking hint…)".into(),
|
body: "[Notification]\nchild done\n\n(non-blocking hint…)".into(),
|
||||||
|
prompt_provenance: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.history_text(),
|
item.history_text(),
|
||||||
@@ -256,6 +308,7 @@ mod tests {
|
|||||||
worker_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
},
|
},
|
||||||
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".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().starts_with("[Notification]\n"));
|
||||||
assert!(item.history_text().contains("`child`"));
|
assert!(item.history_text().contains("`child`"));
|
||||||
@@ -292,7 +345,7 @@ mod tests {
|
|||||||
fn system_reminder_source_is_retained_in_system_item() {
|
fn system_reminder_source_is_retained_in_system_item() {
|
||||||
let item = SystemReminder::task_inactivity("remember tasks").into_system_item();
|
let item = SystemReminder::task_inactivity("remember tasks").into_system_item();
|
||||||
match item {
|
match item {
|
||||||
SystemItem::TaskReminder { source, body } => {
|
SystemItem::TaskReminder { source, body, .. } => {
|
||||||
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body,
|
body,
|
||||||
@@ -352,6 +405,7 @@ mod tests {
|
|||||||
worker_name: "child".into(),
|
worker_name: "child".into(),
|
||||||
},
|
},
|
||||||
body: "[Notification] worker `child` finished a turn".into(),
|
body: "[Notification] worker `child` finished a turn".into(),
|
||||||
|
prompt_provenance: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&item).unwrap();
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
|
let parsed: SystemItem = serde_json::from_str(&json).unwrap();
|
||||||
@@ -359,6 +413,7 @@ mod tests {
|
|||||||
SystemItem::WorkerEvent {
|
SystemItem::WorkerEvent {
|
||||||
event: WorkerEvent::TurnEnded { worker_name },
|
event: WorkerEvent::TurnEnded { worker_name },
|
||||||
body,
|
body,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(worker_name, "child");
|
assert_eq!(worker_name, "child");
|
||||||
assert!(body.contains("`child`"));
|
assert!(body.contains("`child`"));
|
||||||
|
|||||||
@@ -2190,7 +2190,7 @@ impl App {
|
|||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::SkillActivation { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::TaskReminder { 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.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { text: body });
|
self.blocks.push(Block::SystemMessage { text: body });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
fn dispatch_input(
|
||||||
&self,
|
&self,
|
||||||
handle: &WorkerExecutionHandle,
|
handle: &WorkerExecutionHandle,
|
||||||
@@ -469,6 +479,13 @@ impl WorkerExecutionBackendRef {
|
|||||||
self.backend.cleanup_working_directory(working_directory_id)
|
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(
|
pub(crate) fn dispatch_input(
|
||||||
&self,
|
&self,
|
||||||
handle: &WorkerExecutionHandle,
|
handle: &WorkerExecutionHandle,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
|
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::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||||
use crate::error::RuntimeError;
|
use crate::error::RuntimeError;
|
||||||
use crate::identity::{WorkerId, WorkerRef};
|
use crate::identity::{WorkerId, WorkerRef};
|
||||||
@@ -245,7 +245,6 @@ pub(crate) struct PersistedRuntimeState {
|
|||||||
pub(crate) next_diagnostic_id: u64,
|
pub(crate) next_diagnostic_id: u64,
|
||||||
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||||
pub(crate) workspace_owners: BTreeMap<String, String>,
|
pub(crate) workspace_owners: BTreeMap<String, String>,
|
||||||
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
|
|
||||||
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +299,7 @@ impl RuntimeSnapshot {
|
|||||||
status: state.status,
|
status: state.status,
|
||||||
next_worker_sequence: state.next_worker_sequence,
|
next_worker_sequence: state.next_worker_sequence,
|
||||||
next_diagnostic_id: state.next_diagnostic_id,
|
next_diagnostic_id: state.next_diagnostic_id,
|
||||||
config_bundles: state.config_bundles.clone(),
|
config_bundles: BTreeMap::new(),
|
||||||
workspace_owners: state.workspace_owners.clone(),
|
workspace_owners: state.workspace_owners.clone(),
|
||||||
diagnostics: state.diagnostics.clone(),
|
diagnostics: state.diagnostics.clone(),
|
||||||
}
|
}
|
||||||
@@ -324,13 +323,6 @@ impl RuntimeSnapshot {
|
|||||||
message: format!("runtime snapshot backend is {:?}", self.backend),
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +336,6 @@ impl RuntimeSnapshot {
|
|||||||
next_worker_sequence: self.next_worker_sequence,
|
next_worker_sequence: self.next_worker_sequence,
|
||||||
next_diagnostic_id: self.next_diagnostic_id,
|
next_diagnostic_id: self.next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
config_bundles: self.config_bundles,
|
|
||||||
workspace_owners: self.workspace_owners,
|
workspace_owners: self.workspace_owners,
|
||||||
diagnostics: self.diagnostics,
|
diagnostics: self.diagnostics,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ fn runtime_http_router_with_optional_auth(
|
|||||||
"/v1/config-bundles/{bundle_id}/availability",
|
"/v1/config-bundles/{bundle_id}/availability",
|
||||||
get(check_config_bundle),
|
get(check_config_bundle),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/workspace-prompt-projections",
|
||||||
|
post(observe_workspace_prompt_projection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/v1/working-directories",
|
"/v1/working-directories",
|
||||||
get(list_working_directories).post(create_working_directory),
|
get(list_working_directories).post(create_working_directory),
|
||||||
@@ -285,6 +289,18 @@ pub struct RuntimeHttpConfigBundleSyncRequest {
|
|||||||
pub bundle: ConfigBundle,
|
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.
|
/// Config bundle availability response used by sync/check endpoints.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeHttpConfigBundleAvailabilityResponse {
|
pub struct RuntimeHttpConfigBundleAvailabilityResponse {
|
||||||
@@ -431,6 +447,33 @@ async fn store_config_bundle(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn observe_workspace_prompt_projection(
|
||||||
|
State(state): State<RuntimeHttpState>,
|
||||||
|
auth: Option<Extension<RuntimeAuthContext>>,
|
||||||
|
body: Result<Json<RuntimeHttpWorkspacePromptProjectionRequest>, JsonRejection>,
|
||||||
|
) -> RestResult<RuntimeHttpWorkspacePromptProjectionResponse> {
|
||||||
|
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
|
||||||
|
.runtime
|
||||||
|
.observe_workspace_prompt_projection(request.projection)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
|
Ok(Json(RuntimeHttpWorkspacePromptProjectionResponse {
|
||||||
|
workspace_id,
|
||||||
|
config_revision,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn check_config_bundle(
|
async fn check_config_bundle(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
Path(bundle_id): Path<String>,
|
Path(bundle_id): Path<String>,
|
||||||
@@ -1518,7 +1561,10 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
{
|
{
|
||||||
return Some("workdirs:operate");
|
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");
|
return Some("workers:create");
|
||||||
}
|
}
|
||||||
if path.ends_with("/workspace-api") {
|
if path.ends_with("/workspace-api") {
|
||||||
|
|||||||
@@ -300,6 +300,29 @@ impl Runtime {
|
|||||||
state.check_config_bundle_ref(reference)
|
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
|
/// Stop the Runtime. v0 keeps data readable after stop, but rejects new
|
||||||
/// create/send/worker lifecycle mutations.
|
/// create/send/worker lifecycle mutations.
|
||||||
pub fn stop_runtime(&self) -> Result<(), RuntimeError> {
|
pub fn stop_runtime(&self) -> Result<(), RuntimeError> {
|
||||||
@@ -900,8 +923,6 @@ impl Runtime {
|
|||||||
worker.run_generation.saturating_add(1).max(1),
|
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(|| {
|
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||||
RuntimeError::WorkerExecutionUnavailable {
|
RuntimeError::WorkerExecutionUnavailable {
|
||||||
worker_id: worker_ref.worker_id.clone(),
|
worker_id: worker_ref.worker_id.clone(),
|
||||||
@@ -924,7 +945,7 @@ impl Runtime {
|
|||||||
context: self.execution_context(worker_ref.clone()),
|
context: self.execution_context(worker_ref.clone()),
|
||||||
previous_working_directory,
|
previous_working_directory,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
config_bundle,
|
config_bundle: None,
|
||||||
};
|
};
|
||||||
(backend, request)
|
(backend, request)
|
||||||
};
|
};
|
||||||
@@ -1580,8 +1601,6 @@ impl Runtime {
|
|||||||
worker.run_generation.saturating_add(1).max(1),
|
worker.run_generation.saturating_add(1).max(1),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let config_bundle =
|
|
||||||
state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
|
|
||||||
state
|
state
|
||||||
.workers
|
.workers
|
||||||
.get_mut(&worker_id)
|
.get_mut(&worker_id)
|
||||||
@@ -1593,7 +1612,7 @@ impl Runtime {
|
|||||||
request,
|
request,
|
||||||
run_generation,
|
run_generation,
|
||||||
previous_working_directory,
|
previous_working_directory,
|
||||||
config_bundle,
|
config_bundle: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
candidates
|
candidates
|
||||||
@@ -1941,7 +1960,7 @@ impl RuntimeState {
|
|||||||
next_worker_sequence: persisted.next_worker_sequence,
|
next_worker_sequence: persisted.next_worker_sequence,
|
||||||
next_diagnostic_id,
|
next_diagnostic_id,
|
||||||
workers,
|
workers,
|
||||||
config_bundles: persisted.config_bundles,
|
config_bundles: BTreeMap::new(),
|
||||||
workspace_owners: persisted.workspace_owners,
|
workspace_owners: persisted.workspace_owners,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
subscription_revision: 0,
|
subscription_revision: 0,
|
||||||
@@ -1969,7 +1988,6 @@ impl RuntimeState {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
|
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
|
||||||
.collect(),
|
.collect(),
|
||||||
config_bundles: self.config_bundles.clone(),
|
|
||||||
workspace_owners: self.workspace_owners.clone(),
|
workspace_owners: self.workspace_owners.clone(),
|
||||||
diagnostics: self.diagnostics.clone(),
|
diagnostics: self.diagnostics.clone(),
|
||||||
}
|
}
|
||||||
@@ -3476,12 +3494,12 @@ mod tests {
|
|||||||
runtime.restore_worker(&detail.worker_ref).unwrap();
|
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
backend.config_bundles.lock().unwrap().as_slice(),
|
backend.config_bundles.lock().unwrap().as_slice(),
|
||||||
&[Some(bundle.clone()), Some(bundle)]
|
&[Some(bundle), None]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 (runtime, backend) = runtime_and_backend();
|
||||||
let bundle = test_bundle();
|
let bundle = test_bundle();
|
||||||
let detail = runtime
|
let detail = runtime
|
||||||
@@ -3489,11 +3507,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||||
runtime.lock().unwrap().config_bundles.clear();
|
runtime.lock().unwrap().config_bundles.clear();
|
||||||
assert!(matches!(
|
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||||
runtime.restore_worker(&detail.worker_ref),
|
assert_eq!(
|
||||||
Err(RuntimeError::ConfigBundleMissing { .. })
|
backend.config_bundles.lock().unwrap().as_slice(),
|
||||||
));
|
&[Some(bundle), None]
|
||||||
assert_eq!(backend.config_bundles.lock().unwrap().len(), 1);
|
);
|
||||||
|
|
||||||
let (runtime, backend) = runtime_and_backend();
|
let (runtime, backend) = runtime_and_backend();
|
||||||
let bundle = test_bundle();
|
let bundle = test_bundle();
|
||||||
@@ -3509,11 +3527,11 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.config_bundles
|
.config_bundles
|
||||||
.insert(replacement.metadata.id.clone(), replacement);
|
.insert(replacement.metadata.id.clone(), replacement);
|
||||||
assert!(matches!(
|
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||||
runtime.restore_worker(&detail.worker_ref),
|
assert_eq!(
|
||||||
Err(RuntimeError::ConfigBundleDigestMismatch { .. })
|
backend.config_bundles.lock().unwrap().as_slice(),
|
||||||
));
|
&[Some(bundle), None]
|
||||||
assert_eq!(backend.config_bundles.lock().unwrap().len(), 1);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4137,7 +4155,10 @@ mod tests {
|
|||||||
runtime.summary().unwrap().backend,
|
runtime.summary().unwrap().backend,
|
||||||
RuntimeBackendKind::FsStore
|
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();
|
let worker = runtime.create_worker(task_request("persist me")).unwrap();
|
||||||
runtime
|
runtime
|
||||||
@@ -4171,6 +4192,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap();
|
let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap();
|
||||||
assert_eq!(restored_worker.status, WorkerStatus::Stopped);
|
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!(!root.join("events.jsonl").exists());
|
||||||
assert!(!worker_store_dir.join("observations.jsonl").exists());
|
assert!(!worker_store_dir.join("observations.jsonl").exists());
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ pub struct RuntimeWorkerController {
|
|||||||
/// controller-backed Worker for a Runtime catalog entry.
|
/// controller-backed Worker for a Runtime catalog entry.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
||||||
|
fn observe_workspace_prompt_projection(
|
||||||
|
&self,
|
||||||
|
_projection: worker::WorkspacePromptProjection,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_controller(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
@@ -210,6 +217,74 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct WorkspacePromptProjectionCache {
|
||||||
|
active: Mutex<HashMap<String, Arc<worker::WorkspacePromptCatalogResolution>>>,
|
||||||
|
fetch_gates: Mutex<HashMap<String, Arc<Mutex<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspacePromptProjectionCache {
|
||||||
|
pub(crate) fn fetch_gate(&self, workspace_id: &str) -> Result<Arc<Mutex<()>>, 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<Option<Arc<worker::WorkspacePromptCatalogResolution>>, 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<Arc<worker::WorkspacePromptCatalogResolution>, String> {
|
||||||
|
projection.validate().map_err(|error| error.to_string())?;
|
||||||
|
let workspace_id = projection.workspace_id.clone();
|
||||||
|
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.projection.config_revision > resolution.projection.config_revision {
|
||||||
|
return Ok(current.clone());
|
||||||
|
}
|
||||||
|
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={}",
|
||||||
|
resolution.projection.config_revision
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active.insert(workspace_id, resolution.clone());
|
||||||
|
Ok(resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProfileRuntimeWorkerFactory {
|
pub struct ProfileRuntimeWorkerFactory {
|
||||||
observation_hub: Arc<RuntimeWorkerObservationHub>,
|
observation_hub: Arc<RuntimeWorkerObservationHub>,
|
||||||
@@ -217,6 +292,7 @@ pub struct ProfileRuntimeWorkerFactory {
|
|||||||
worker_aggregate_root: Option<PathBuf>,
|
worker_aggregate_root: Option<PathBuf>,
|
||||||
resource_client: Option<Arc<dyn BackendResourceClient>>,
|
resource_client: Option<Arc<dyn BackendResourceClient>>,
|
||||||
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
|
profile_archive_cache: Arc<ProfileSourceArchiveCache>,
|
||||||
|
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
|
||||||
runtime_id: Option<String>,
|
runtime_id: Option<String>,
|
||||||
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
worker_mutation_identity: Option<RuntimeIdentityMaterial>,
|
||||||
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
@@ -232,6 +308,7 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
worker_aggregate_root: None,
|
worker_aggregate_root: None,
|
||||||
resource_client: None,
|
resource_client: None,
|
||||||
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
|
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
|
||||||
|
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
worker_mutation_identity: None,
|
worker_mutation_identity: None,
|
||||||
embedded_worker_mutation_dispatcher: None,
|
embedded_worker_mutation_dispatcher: None,
|
||||||
@@ -335,6 +412,48 @@ impl ProfileRuntimeWorkerFactory {
|
|||||||
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
|
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
|
||||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||||
}
|
}
|
||||||
|
fn observe_bundle_prompt_projection(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::config_bundle::ConfigBundle,
|
||||||
|
expected_workspace_id: Option<&str>,
|
||||||
|
) -> Result<Option<Arc<worker::WorkspacePromptCatalogResolution>>, 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(
|
async fn resolve_profile_source_archive(
|
||||||
&self,
|
&self,
|
||||||
source: &ProfileSourceArchiveSource,
|
source: &ProfileSourceArchiveSource,
|
||||||
@@ -409,6 +528,7 @@ impl RuntimeWorkspaceBackendRef {
|
|||||||
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
|
||||||
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
mutation_identity: Option<&RuntimeIdentityMaterial>,
|
||||||
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
|
||||||
|
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||||
) -> WorkerWorkspaceContext {
|
) -> WorkerWorkspaceContext {
|
||||||
match self {
|
match self {
|
||||||
Self::None => WorkerWorkspaceContext::no_workspace(),
|
Self::None => WorkerWorkspaceContext::no_workspace(),
|
||||||
@@ -423,6 +543,9 @@ impl RuntimeWorkspaceBackendRef {
|
|||||||
runtime_id.clone(),
|
runtime_id.clone(),
|
||||||
worker_ref.worker_id.to_string(),
|
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) {
|
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
|
||||||
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
|
||||||
identity,
|
identity,
|
||||||
@@ -521,6 +644,13 @@ fn runtime_local_workdir_session(
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
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(
|
async fn spawn_controller(
|
||||||
&self,
|
&self,
|
||||||
request: WorkerExecutionSpawnRequest,
|
request: WorkerExecutionSpawnRequest,
|
||||||
@@ -560,6 +690,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
request.workspace_scope.as_ref(),
|
request.workspace_scope.as_ref(),
|
||||||
self.worker_mutation_identity.as_ref(),
|
self.worker_mutation_identity.as_ref(),
|
||||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
|
Some(self.prompt_projection_cache.clone()),
|
||||||
);
|
);
|
||||||
let selector = profile.as_ref();
|
let selector = profile.as_ref();
|
||||||
let archive = self
|
let archive = self
|
||||||
@@ -583,12 +714,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(prompt_catalog) = request
|
if let Some(bundle) = request.config_bundle.as_ref()
|
||||||
.config_bundle
|
&& let Some(resolution) =
|
||||||
.as_ref()
|
self.observe_bundle_prompt_projection(bundle, observation_workspace_id.as_deref())?
|
||||||
.and_then(|bundle| bundle.prompt_catalog.clone())
|
|
||||||
{
|
{
|
||||||
loader = loader.with_effective_catalog(prompt_catalog);
|
loader = loader.with_effective_catalog(resolution.projection.catalog.clone());
|
||||||
}
|
}
|
||||||
let flow_transition_enabled = manifest.feature.flow.enabled;
|
let flow_transition_enabled = manifest.feature.flow.enabled;
|
||||||
|
|
||||||
@@ -724,15 +854,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
request.workspace_scope.as_ref(),
|
request.workspace_scope.as_ref(),
|
||||||
self.worker_mutation_identity.as_ref(),
|
self.worker_mutation_identity.as_ref(),
|
||||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||||
|
Some(self.prompt_projection_cache.clone()),
|
||||||
);
|
);
|
||||||
let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?;
|
let (manifest, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||||
let session_dir = worker_aggregate_dir.join("session");
|
let session_dir = worker_aggregate_dir.join("session");
|
||||||
@@ -767,11 +891,31 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
Err(WorkerError::WorkerMetadataPending { .. })
|
Err(WorkerError::WorkerMetadataPending { .. })
|
||||||
if request.request.initial_input.is_none() =>
|
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::restore_pending_from_worker_metadata_with_context(
|
||||||
&worker_name,
|
&worker_name,
|
||||||
manifest.clone(),
|
manifest.clone(),
|
||||||
store,
|
store,
|
||||||
loader,
|
pending_loader,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
)
|
)
|
||||||
@@ -1278,6 +1422,13 @@ where
|
|||||||
&self.backend_id
|
&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(
|
fn create_working_directory(
|
||||||
&self,
|
&self,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
@@ -1859,6 +2010,112 @@ mod tests {
|
|||||||
use manifest::{Scope, WorkerManifest};
|
use manifest::{Scope, WorkerManifest};
|
||||||
use session_store::{LogEntry, WorkerMetadataStore};
|
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.projection.config_revision, 9);
|
||||||
|
assert_eq!(
|
||||||
|
active.projection.catalog.catalog_digest,
|
||||||
|
catalog_v2.catalog_digest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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]
|
#[test]
|
||||||
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
|
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
|
||||||
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
|
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
|
||||||
@@ -1871,12 +2128,12 @@ mod tests {
|
|||||||
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
let scope = crate::runtime::RuntimeWorkspaceScope::new("workspace-a", "server-main");
|
||||||
|
|
||||||
let before_restart =
|
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 adapter = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||||
let (after_restore_kind, after_restore_workspace_id) = adapter
|
let (after_restore_kind, after_restore_workspace_id) = adapter
|
||||||
.run_on_adapter_runtime(async move {
|
.run_on_adapter_runtime(async move {
|
||||||
let after_restore =
|
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();
|
let client = after_restore.client_handle();
|
||||||
Ok((
|
Ok((
|
||||||
client.kind().to_string(),
|
client.kind().to_string(),
|
||||||
@@ -2066,6 +2323,7 @@ mod tests {
|
|||||||
request.workspace_scope.as_ref(),
|
request.workspace_scope.as_ref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let workspace_client = workspace_context.client_handle();
|
let workspace_client = workspace_context.client_handle();
|
||||||
self.observed_workspace_clients.lock().unwrap().push((
|
self.observed_workspace_clients.lock().unwrap().push((
|
||||||
@@ -2466,9 +2724,45 @@ mod tests {
|
|||||||
.expect("embedded archive should resolve without Backend resource client");
|
.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]
|
#[tokio::test]
|
||||||
#[serial_test::serial(worker_allocation)]
|
#[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 root = tempfile::tempdir().unwrap();
|
||||||
let runtime_store_dir = root.path().join("runtime");
|
let runtime_store_dir = root.path().join("runtime");
|
||||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||||
@@ -2518,9 +2812,9 @@ mod tests {
|
|||||||
base_url: "http://workspace.invalid".to_string(),
|
base_url: "http://workspace.invalid".to_string(),
|
||||||
});
|
});
|
||||||
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
|
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
|
||||||
let controller = ProfileRuntimeWorkerFactory::new(root.path())
|
let error = match ProfileRuntimeWorkerFactory::new(root.path())
|
||||||
.with_remote_worker_mutation_identity(identity)
|
|
||||||
.with_runtime_store_dir(&runtime_store_dir)
|
.with_runtime_store_dir(&runtime_store_dir)
|
||||||
|
.with_remote_worker_mutation_identity(identity)
|
||||||
.restore_controller(WorkerExecutionRestoreRequest {
|
.restore_controller(WorkerExecutionRestoreRequest {
|
||||||
worker_ref: worker_ref.clone(),
|
worker_ref: worker_ref.clone(),
|
||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
@@ -2535,25 +2829,11 @@ mod tests {
|
|||||||
config_bundle: None,
|
config_bundle: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("pending restore should use the saved manifest snapshot");
|
{
|
||||||
assert!(controller.handle.shared_state.flow_transition_enabled());
|
Ok(_) => panic!("pending Workspace Worker restore unexpectedly succeeded"),
|
||||||
let run_dir = runtime_store_dir.join("workers/1/runs/1");
|
Err(error) => error,
|
||||||
assert!(run_dir.join("worker.sock").exists());
|
};
|
||||||
assert!(run_dir.join("worker.out.log").is_file());
|
assert!(error.contains("requires operation-owned launch material"));
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use worker::{
|
use worker::{
|
||||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||||
WorkspaceResponse,
|
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -12,6 +12,7 @@ use crate::auth::{
|
|||||||
WorkerMutationSourceClaims, new_token_id,
|
WorkerMutationSourceClaims, new_token_id,
|
||||||
};
|
};
|
||||||
use crate::runtime::RuntimeWorkspaceScope;
|
use crate::runtime::RuntimeWorkspaceScope;
|
||||||
|
use crate::worker_backend::WorkspacePromptProjectionCache;
|
||||||
|
|
||||||
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
|
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
|
||||||
|
|
||||||
@@ -286,6 +287,7 @@ fn execute_remote_worker_remove_http_blocking(
|
|||||||
Ok(WorkspaceResponse { status, body })
|
Ok(WorkspaceResponse { status, body })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct RuntimeOwnedWorkspaceClient {
|
pub struct RuntimeOwnedWorkspaceClient {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -293,6 +295,7 @@ pub struct RuntimeOwnedWorkspaceClient {
|
|||||||
worker_id: String,
|
worker_id: String,
|
||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||||
|
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeOwnedWorkspaceClient {
|
impl RuntimeOwnedWorkspaceClient {
|
||||||
@@ -309,6 +312,7 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
request_timeout: None,
|
request_timeout: None,
|
||||||
worker_remove: None,
|
worker_remove: None,
|
||||||
|
prompt_projection_cache: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +321,14 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_prompt_projection_cache(
|
||||||
|
mut self,
|
||||||
|
cache: Arc<WorkspacePromptProjectionCache>,
|
||||||
|
) -> Self {
|
||||||
|
self.prompt_projection_cache = Some(cache);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn with_request_timeout(mut self, request_timeout: Option<Duration>) -> Self {
|
fn with_request_timeout(mut self, request_timeout: Option<Duration>) -> Self {
|
||||||
self.request_timeout = request_timeout;
|
self.request_timeout = request_timeout;
|
||||||
@@ -385,6 +397,79 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_prompt_projection(
|
||||||
|
&self,
|
||||||
|
minimum_revision: Option<u64>,
|
||||||
|
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||||
|
let Some(cache) = self.prompt_projection_cache.as_ref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
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)?
|
||||||
|
.filter(|resolution| {
|
||||||
|
minimum_revision
|
||||||
|
.map(|minimum| resolution.projection.config_revision >= minimum)
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Ok(Some((*resolution).clone()));
|
||||||
|
}
|
||||||
|
let response = self.execute(WorkspaceRequest::get(format!(
|
||||||
|
"/api/w/{}/config/projections/prompts",
|
||||||
|
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}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
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 resolution = cache
|
||||||
|
.observe(projection)
|
||||||
|
.map_err(WorkspaceClientError::Request)?;
|
||||||
|
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(
|
fn execute_worker_remove(
|
||||||
&self,
|
&self,
|
||||||
target_runtime_id: &str,
|
target_runtime_id: &str,
|
||||||
@@ -521,6 +606,208 @@ mod tests {
|
|||||||
verify_worker_mutation_source_proof,
|
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(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};
|
||||||
|
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 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{}",
|
||||||
|
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(None).unwrap().unwrap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let resolutions = threads
|
||||||
|
.into_iter()
|
||||||
|
.map(|thread| thread.join().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
server.join().unwrap();
|
||||||
|
|
||||||
|
let first = &resolutions[0].catalog;
|
||||||
|
assert!(
|
||||||
|
resolutions
|
||||||
|
.iter()
|
||||||
|
.all(|resolution| Arc::ptr_eq(first, &resolution.catalog))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(None).unwrap_err();
|
||||||
|
server.join().unwrap();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("scope mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
|
fn ordinary_workspace_forwarding_stamps_legacy_source_only_inside_runtime() {
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ impl FeatureModule for OrchestrationFeature {
|
|||||||
))
|
))
|
||||||
.with_tool(ToolDeclaration::new(
|
.with_tool(ToolDeclaration::new(
|
||||||
TOOL_NAME,
|
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
|
.ticket_service
|
||||||
.workflow_state(&ticket_id)
|
.workflow_state(&ticket_id)
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
.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!(
|
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()
|
workflow_state.as_str()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -206,7 +209,7 @@ mod tests {
|
|||||||
|
|
||||||
impl TicketService for RecordingTicketService {
|
impl TicketService for RecordingTicketService {
|
||||||
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
|
||||||
Ok(TicketWorkflowState::InProgress)
|
Ok(TicketWorkflowState::Queued)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,10 +280,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 worker_service = Arc::new(RecordingService::default());
|
||||||
let tool = SpawnTicketCoderTool {
|
let tool = SpawnTicketCoderTool {
|
||||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)),
|
||||||
worker_service: worker_service.clone(),
|
worker_service: worker_service.clone(),
|
||||||
};
|
};
|
||||||
let error = tool
|
let error = tool
|
||||||
@@ -295,7 +298,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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());
|
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let queued = pending.lock().expect("pending queue poisoned");
|
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]);
|
panic!("unexpected system item: {:?}", queued[0]);
|
||||||
};
|
};
|
||||||
assert_eq!(*source, SystemReminderSource::TaskInactivity);
|
assert_eq!(*source, SystemReminderSource::TaskInactivity);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::borrow::Cow;
|
|||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_engine::Item;
|
use llm_engine::Item;
|
||||||
use llm_engine::UsageRecord;
|
use llm_engine::UsageRecord;
|
||||||
@@ -20,7 +21,6 @@ use llm_engine::interceptor::{
|
|||||||
};
|
};
|
||||||
use llm_engine::tool::ToolOutput;
|
use llm_engine::tool::ToolOutput;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
@@ -31,7 +31,7 @@ use crate::hook::{
|
|||||||
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
|
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
|
||||||
SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
|
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::prompt::catalog::PromptCatalog;
|
||||||
use crate::worker::SystemItemCommitter;
|
use crate::worker::SystemItemCommitter;
|
||||||
use llm_engine::token_counter::total_tokens;
|
use llm_engine::token_counter::total_tokens;
|
||||||
@@ -64,7 +64,9 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
/// Prompt catalog used to render pending notification entries into the
|
/// Prompt catalog used to render pending notification entries into the
|
||||||
/// same system-message text that will be persisted in history.
|
/// same system-message text that will be persisted in history.
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
|
/// Workspace scope associated with Prompt projection provenance.
|
||||||
|
prompt_workspace_id: Option<String>,
|
||||||
/// Type-erased commit handle. The interceptor uses it to commit
|
/// Type-erased commit handle. The interceptor uses it to commit
|
||||||
/// `LogEntry::SystemItem` entries directly (sync) before
|
/// `LogEntry::SystemItem` entries directly (sync) before
|
||||||
/// returning the corresponding `Item::system_message`s up to the
|
/// returning the corresponding `Item::system_message`s up to the
|
||||||
@@ -84,7 +86,7 @@ impl WorkerInterceptor {
|
|||||||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||||
pending_notifies: NotifyBuffer,
|
pending_notifies: NotifyBuffer,
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -95,6 +97,7 @@ impl WorkerInterceptor {
|
|||||||
pending_notifies,
|
pending_notifies,
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
prompts,
|
prompts,
|
||||||
|
prompt_workspace_id: None,
|
||||||
log_writer,
|
log_writer,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: AtomicUsize::new(0),
|
tool_calls_this_turn: AtomicUsize::new(0),
|
||||||
@@ -106,6 +109,11 @@ impl WorkerInterceptor {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||||
|
self.prompt_workspace_id = workspace_id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
|
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
|
||||||
/// entry through the attached writer (no-op when no writer is
|
/// entry through the attached writer (no-op when no writer is
|
||||||
/// wired). Sync — writes complete before the matching
|
/// wired). Sync — writes complete before the matching
|
||||||
@@ -162,6 +170,32 @@ impl WorkerInterceptor {
|
|||||||
}
|
}
|
||||||
false
|
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]
|
#[async_trait]
|
||||||
@@ -180,7 +214,7 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
return action.into();
|
return action.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let extras: Vec<SystemItem> = std::mem::take(
|
let mut extras: Vec<SystemItem> = std::mem::take(
|
||||||
&mut *self
|
&mut *self
|
||||||
.pending_attachments
|
.pending_attachments
|
||||||
.lock()
|
.lock()
|
||||||
@@ -194,6 +228,7 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
// commits land BEFORE the worker pushes its
|
// commits land BEFORE the worker pushes its
|
||||||
// `Item::system_message`s, so on-disk order matches
|
// `Item::system_message`s, so on-disk order matches
|
||||||
// worker-history order.
|
// worker-history order.
|
||||||
|
self.attach_prompt_provenance(&mut extras);
|
||||||
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
|
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
|
||||||
match self.commit_system_items(&extras) {
|
match self.commit_system_items(&extras) {
|
||||||
Ok(()) => PromptAction::ContinueWith(items),
|
Ok(()) => PromptAction::ContinueWith(items),
|
||||||
@@ -208,34 +243,36 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<SystemItem> = Vec::with_capacity(drained.len());
|
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
|
||||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
||||||
for entry in drained {
|
for entry in &drained {
|
||||||
match build_system_item(&entry, &self.prompts) {
|
let system_item = match build_system_item_with_provenance(
|
||||||
Ok(system_item) => {
|
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());
|
items.push(system_item.to_history_item());
|
||||||
system_items.push(system_item);
|
system_items.push(system_item);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
if let Err(error) = self.commit_system_items(&system_items) {
|
||||||
// A render failure here would starve the LLM of
|
self.pending_notifies.requeue_front(drained);
|
||||||
// the notify text. Fall back to a raw item so the
|
return Err(format!("session persistence failed: {error}"));
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.commit_system_items(&system_items)
|
|
||||||
.map_err(|error| format!("session persistence failed: {error}"))?;
|
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,11 +300,12 @@ impl Interceptor for WorkerInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let system_items: Vec<SystemItem> = std::mem::take(
|
let mut system_items: Vec<SystemItem> = std::mem::take(
|
||||||
&mut *pending_hook_system_items
|
&mut *pending_hook_system_items
|
||||||
.lock()
|
.lock()
|
||||||
.expect("pending hook system-item queue poisoned"),
|
.expect("pending hook system-item queue poisoned"),
|
||||||
);
|
);
|
||||||
|
self.attach_prompt_provenance(&mut system_items);
|
||||||
let appended_items: Vec<Item> = system_items
|
let appended_items: Vec<Item> = system_items
|
||||||
.iter()
|
.iter()
|
||||||
.map(SystemItem::to_history_item)
|
.map(SystemItem::to_history_item)
|
||||||
@@ -440,6 +478,10 @@ mod tests {
|
|||||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
|
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
struct CountingHook(Arc<AtomicUsize>);
|
struct CountingHook(Arc<AtomicUsize>);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -541,7 +583,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
@@ -571,7 +613,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
@@ -609,7 +651,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.with_usage_tracker(usage_tracker);
|
.with_usage_tracker(usage_tracker);
|
||||||
@@ -634,7 +676,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
@@ -675,7 +717,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
@@ -702,7 +744,7 @@ mod tests {
|
|||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
@@ -723,7 +765,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
@@ -751,7 +793,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
Some(committer),
|
Some(committer),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -798,7 +840,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -855,7 +897,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
||||||
@@ -902,7 +944,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
||||||
@@ -953,7 +995,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
||||||
@@ -985,7 +1027,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
@@ -1027,10 +1069,114 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("committed system-item list poisoned");
|
.expect("committed system-item list poisoned");
|
||||||
assert_eq!(committed.len(), 1);
|
assert_eq!(committed.len(), 1);
|
||||||
let SystemItem::TaskReminder { body, .. } = &committed[0] else {
|
let SystemItem::TaskReminder {
|
||||||
panic!("expected task reminder, got {:?}", committed[0]);
|
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!(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,
|
||||||
|
None,
|
||||||
|
buffer.clone(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
prompts.clone(),
|
||||||
|
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();
|
||||||
|
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"));
|
||||||
|
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]
|
||||||
|
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]
|
#[tokio::test]
|
||||||
@@ -1046,7 +1192,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1083,7 +1229,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
buffer.clone(),
|
buffer.clone(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
@@ -1113,7 +1259,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
test_prompts(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ impl NotifyBuffer {
|
|||||||
q.drain(..).collect()
|
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<PendingNotify>) {
|
||||||
|
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.
|
/// Whether an undrained `Method::Notify { auto_run: true }` remains.
|
||||||
pub fn has_auto_run_pending(&self) -> bool {
|
pub fn has_auto_run_pending(&self) -> bool {
|
||||||
self.inner
|
self.inner
|
||||||
@@ -111,9 +128,18 @@ impl NotifyBuffer {
|
|||||||
/// Render one pending entry into a typed `SystemItem`. The
|
/// Render one pending entry into a typed `SystemItem`. The
|
||||||
/// `notify_wrapper` prompt produces the LLM-context body for both
|
/// `notify_wrapper` prompt produces the LLM-context body for both
|
||||||
/// `Notify` (raw message) and `WorkerEvent` (rendered event line).
|
/// `Notify` (raw message) and `WorkerEvent` (rendered event line).
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn build_system_item(
|
pub(crate) fn build_system_item(
|
||||||
entry: &PendingNotify,
|
entry: &PendingNotify,
|
||||||
prompts: &PromptCatalog,
|
prompts: &PromptCatalog,
|
||||||
|
) -> Result<SystemItem, CatalogError> {
|
||||||
|
build_system_item_with_provenance(entry, prompts, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_system_item_with_provenance(
|
||||||
|
entry: &PendingNotify,
|
||||||
|
prompts: &PromptCatalog,
|
||||||
|
prompt_provenance: Option<session_store::PromptRenderProvenance>,
|
||||||
) -> Result<SystemItem, CatalogError> {
|
) -> Result<SystemItem, CatalogError> {
|
||||||
match entry {
|
match entry {
|
||||||
PendingNotify::Notify { message, .. } => {
|
PendingNotify::Notify { message, .. } => {
|
||||||
@@ -121,6 +147,7 @@ pub(crate) fn build_system_item(
|
|||||||
Ok(SystemItem::Notification {
|
Ok(SystemItem::Notification {
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
body,
|
body,
|
||||||
|
prompt_provenance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
PendingNotify::WorkerEvent { event } => {
|
PendingNotify::WorkerEvent { event } => {
|
||||||
@@ -129,6 +156,7 @@ pub(crate) fn build_system_item(
|
|||||||
Ok(SystemItem::WorkerEvent {
|
Ok(SystemItem::WorkerEvent {
|
||||||
event: event.clone(),
|
event: event.clone(),
|
||||||
body,
|
body,
|
||||||
|
prompt_provenance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +206,7 @@ mod tests {
|
|||||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
let item = build_system_item(&entry, &catalog).unwrap();
|
let item = build_system_item(&entry, &catalog).unwrap();
|
||||||
match item {
|
match item {
|
||||||
SystemItem::Notification { message, body } => {
|
SystemItem::Notification { message, body, .. } => {
|
||||||
assert_eq!(message, "hello");
|
assert_eq!(message, "hello");
|
||||||
assert!(body.contains("[Notification]"));
|
assert!(body.contains("[Notification]"));
|
||||||
assert!(body.contains("hello"));
|
assert!(body.contains("hello"));
|
||||||
@@ -198,7 +226,7 @@ mod tests {
|
|||||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
let item = build_system_item(&entry, &catalog).unwrap();
|
let item = build_system_item(&entry, &catalog).unwrap();
|
||||||
match item {
|
match item {
|
||||||
SystemItem::WorkerEvent { event, body } => {
|
SystemItem::WorkerEvent { event, body, .. } => {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(event, WorkerEvent::TurnEnded { ref worker_name } if worker_name == "child")
|
matches!(event, WorkerEvent::TurnEnded { ref worker_name } if worker_name == "child")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ pub use manifest::{
|
|||||||
};
|
};
|
||||||
pub use model_client::{ProviderError, build_client};
|
pub use model_client::{ProviderError, build_client};
|
||||||
pub use prompt::catalog::{
|
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::source::PromptCatalogSource;
|
||||||
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
@@ -44,7 +45,7 @@ pub use shared_state::WorkerSharedState;
|
|||||||
pub use worker::{
|
pub use worker::{
|
||||||
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||||
WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
|
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest,
|
||||||
unavailable_workspace_client,
|
marker_workspace_client, unavailable_workspace_client,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4";
|
|||||||
pub struct EffectivePromptCatalog {
|
pub struct EffectivePromptCatalog {
|
||||||
pub templates: BTreeMap<String, String>,
|
pub templates: BTreeMap<String, String>,
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub source_digest: String,
|
||||||
pub schema_fingerprint: String,
|
pub schema_fingerprint: String,
|
||||||
pub toolchain_fingerprint: String,
|
pub toolchain_fingerprint: String,
|
||||||
pub catalog_digest: String,
|
pub catalog_digest: String,
|
||||||
@@ -48,6 +50,7 @@ impl EffectivePromptCatalog {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
templates,
|
templates,
|
||||||
config_revision,
|
config_revision,
|
||||||
|
source_digest: String::new(),
|
||||||
schema_fingerprint: schema_fingerprint.into(),
|
schema_fingerprint: schema_fingerprint.into(),
|
||||||
toolchain_fingerprint: toolchain_fingerprint.into(),
|
toolchain_fingerprint: toolchain_fingerprint.into(),
|
||||||
catalog_digest,
|
catalog_digest,
|
||||||
@@ -171,6 +174,87 @@ pub enum CatalogError {
|
|||||||
DigestMismatch { expected: String, actual: String },
|
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<String>,
|
||||||
|
source_digest: impl Into<String>,
|
||||||
|
projection_digest: impl Into<String>,
|
||||||
|
catalog: EffectivePromptCatalog,
|
||||||
|
) -> Result<Self, CatalogError> {
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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 {
|
pub struct PromptCatalog {
|
||||||
env: Environment<'static>,
|
env: Environment<'static>,
|
||||||
projection: EffectivePromptCatalog,
|
projection: EffectivePromptCatalog,
|
||||||
@@ -550,6 +634,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]
|
#[test]
|
||||||
fn catalog_source_preserves_workspace_projection_for_subworkers() {
|
fn catalog_source_preserves_workspace_projection_for_subworkers() {
|
||||||
let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]);
|
let templates = BTreeMap::from([("template".to_string(), "OVERRIDE".to_string())]);
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ mod tests {
|
|||||||
item: session_store::SystemItem::Notification {
|
item: session_store::SystemItem::Notification {
|
||||||
message: text.to_owned(),
|
message: text.to_owned(),
|
||||||
body: format!("[Notification] {text}"),
|
body: format!("[Notification] {text}"),
|
||||||
|
prompt_provenance: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
@@ -946,7 +947,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
sub_worker_spawn_tool_impl(
|
sub_worker_spawn_tool_impl(
|
||||||
spawner_name,
|
spawner_name,
|
||||||
@@ -972,13 +973,14 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
spawner_scope: SharedScope,
|
spawner_scope: SharedScope,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(SubWorkerSpawnInput);
|
let schema = schemars::schema_for!(SubWorkerSpawnInput);
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||||
let available_profiles = AvailableProfiles::discover(&workspace_root);
|
let available_profiles = AvailableProfiles::discover(&workspace_root);
|
||||||
let description = prompts
|
let description = prompts
|
||||||
|
.load_full()
|
||||||
.sub_worker_spawn_tool_description(
|
.sub_worker_spawn_tool_description(
|
||||||
&available_profiles.compact_list(),
|
&available_profiles.compact_list(),
|
||||||
&available_profiles.default_label(),
|
&available_profiles.default_label(),
|
||||||
@@ -1002,7 +1004,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
spawner_cwd.clone(),
|
spawner_cwd.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
spawner_manifest.clone(),
|
spawner_manifest.clone(),
|
||||||
prompts.source(),
|
prompts.load_full().source(),
|
||||||
available_profiles,
|
available_profiles,
|
||||||
spawner_scope.clone(),
|
spawner_scope.clone(),
|
||||||
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
DelegationScope::from_config(&spawner_manifest.delegation_scope)
|
||||||
|
|||||||
+132
-12
@@ -13,8 +13,8 @@ use llm_engine::llm_client::types::Role;
|
|||||||
use llm_engine::state::Mutable;
|
use llm_engine::state::Mutable;
|
||||||
use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord};
|
use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
LogEntry, SegmentId, SessionExtension, SessionId, Store, StoreError, SystemItem, segment_log,
|
LogEntry, PromptRenderProvenance, SegmentId, SessionExtension, SessionId, Store, StoreError,
|
||||||
to_logged,
|
SystemItem, segment_log, to_logged,
|
||||||
};
|
};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
|
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild,
|
||||||
@@ -67,7 +67,7 @@ use crate::ipc::alerter::Alerter;
|
|||||||
use crate::ipc::interceptor::WorkerInterceptor;
|
use crate::ipc::interceptor::WorkerInterceptor;
|
||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
use crate::prompt::agents_md::read_agents_md;
|
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::source::PromptCatalogSource;
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
@@ -212,6 +212,38 @@ pub enum WorkspaceClientError {
|
|||||||
Request(String),
|
Request(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WorkspacePromptCatalogResolution {
|
||||||
|
pub projection: Arc<WorkspacePromptProjection>,
|
||||||
|
pub catalog: Arc<PromptCatalog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self, CatalogError> {
|
||||||
|
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.
|
/// Path-free Workspace operation authority injected by Runtime/host code.
|
||||||
///
|
///
|
||||||
/// Workers receive this trait object rather than a Backend URL. The concrete
|
/// Workers receive this trait object rather than a Backend URL. The concrete
|
||||||
@@ -224,6 +256,16 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
fn execute(&self, request: WorkspaceRequest)
|
fn execute(&self, request: WorkspaceRequest)
|
||||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
_minimum_revision: Option<u64>,
|
||||||
|
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes the destructive WorkerRemove operation through Runtime-owned source proof.
|
/// Executes the destructive WorkerRemove operation through Runtime-owned source proof.
|
||||||
/// Target identity is operation data; source identity and permission are never caller inputs.
|
/// Target identity is operation data; source identity and permission are never caller inputs.
|
||||||
fn execute_worker_remove(
|
fn execute_worker_remove(
|
||||||
@@ -285,6 +327,13 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
|||||||
Some(&self.context)
|
Some(&self.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_prompt_projection(
|
||||||
|
&self,
|
||||||
|
minimum_revision: Option<u64>,
|
||||||
|
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||||
|
self.inner.current_prompt_projection(minimum_revision)
|
||||||
|
}
|
||||||
|
|
||||||
fn execute(
|
fn execute(
|
||||||
&self,
|
&self,
|
||||||
mut request: WorkspaceRequest,
|
mut request: WorkspaceRequest,
|
||||||
@@ -872,7 +921,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
/// sections, ...). Built from the 4-layer overlay in
|
/// sections, ...). Built from the 4-layer overlay in
|
||||||
/// [`Self::from_manifest`], or defaults to the builtin pack when a
|
/// [`Self::from_manifest`], or defaults to the builtin pack when a
|
||||||
/// Worker is constructed through lower-level paths that have no loader.
|
/// Worker is constructed through lower-level paths that have no loader.
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
/// When true (default), the system-prompt assembler may append resident
|
/// When true (default), the system-prompt assembler may append resident
|
||||||
/// context from the workspace Memory document. Internal disposable
|
/// context from the workspace Memory document. Internal disposable
|
||||||
/// workers disable this so resident memory exposure is opt-in per Worker.
|
/// workers disable this so resident memory exposure is opt-in per Worker.
|
||||||
@@ -1146,7 +1195,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
// `set_system_prompt_template`) can be captured by `SegmentStart`.
|
// `set_system_prompt_template`) can be captured by `SegmentStart`.
|
||||||
let session_id = session_store::new_session_id();
|
let session_id = session_store::new_session_id();
|
||||||
let segment_id = session_store::new_segment_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 =
|
let delegation_scope =
|
||||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||||
let scope = SharedScope::new(scope);
|
let scope = SharedScope::new(scope);
|
||||||
@@ -1232,10 +1281,55 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
self.inject_resident_summary = enabled;
|
self.inject_resident_summary = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prompts(&self) -> Arc<PromptCatalog> {
|
pub fn prompts(&self) -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
Arc::clone(&self.prompts)
|
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
|
||||||
|
// may adopt the Workspace's current immutable projection.
|
||||||
|
if self.system_prompt_template.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(resolution) = self
|
||||||
|
.workspace_context
|
||||||
|
.client()
|
||||||
|
.current_prompt_projection(None)
|
||||||
|
.map_err(|source| WorkerError::WorkspacePromptProjection {
|
||||||
|
message: source.to_string(),
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
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(());
|
||||||
|
}
|
||||||
|
self.prompts.store(resolution.catalog);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The current segment ID. Read lock-free from the shared session
|
/// The current segment ID. Read lock-free from the shared session
|
||||||
/// pointer so fork-time swaps are observed immediately.
|
/// pointer so fork-time swaps are observed immediately.
|
||||||
pub fn segment_id(&self) -> SegmentId {
|
pub fn segment_id(&self) -> SegmentId {
|
||||||
@@ -1958,7 +2052,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
self.prompts.clone(),
|
self.prompts.clone(),
|
||||||
self.log_writer.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.engine_mut().set_interceptor(interceptor);
|
||||||
self.interceptor_installed = true;
|
self.interceptor_installed = true;
|
||||||
}
|
}
|
||||||
@@ -2020,6 +2119,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
.local_working_directory()
|
.local_working_directory()
|
||||||
.map(|local| local.cwd.display().to_string())
|
.map(|local| local.cwd.display().to_string())
|
||||||
.unwrap_or_else(|| "no local working directory".to_string());
|
.unwrap_or_else(|| "no local working directory".to_string());
|
||||||
|
let prompt_catalog = self.prompts.load_full();
|
||||||
let ctx = SystemPromptContext {
|
let ctx = SystemPromptContext {
|
||||||
now: chrono::Utc::now(),
|
now: chrono::Utc::now(),
|
||||||
cwd: cwd_for_prompt.into(),
|
cwd: cwd_for_prompt.into(),
|
||||||
@@ -2029,7 +2129,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
feature_instructions: &self.feature_instructions,
|
feature_instructions: &self.feature_instructions,
|
||||||
agents_md: agents_md_read.and_then(|read| read.body),
|
agents_md: agents_md_read.and_then(|read| read.body),
|
||||||
resident_summary: resident_summary.as_deref(),
|
resident_summary: resident_summary.as_deref(),
|
||||||
prompts: &self.prompts,
|
prompts: &prompt_catalog,
|
||||||
};
|
};
|
||||||
let rendered = template
|
let rendered = template
|
||||||
.render(&ctx)
|
.render(&ctx)
|
||||||
@@ -2085,6 +2185,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
/// store, and runs pre-run compact (joining any in-flight memory task
|
/// store, and runs pre-run compact (joining any in-flight memory task
|
||||||
/// first so extract sees a stable history range).
|
/// first so extract sees a stable history range).
|
||||||
async fn prepare_for_run(&mut self) -> Result<(), WorkerError> {
|
async fn prepare_for_run(&mut self) -> Result<(), WorkerError> {
|
||||||
|
self.refresh_prompt_projection_for_future_operations()?;
|
||||||
self.ensure_interceptor_installed();
|
self.ensure_interceptor_installed();
|
||||||
self.ensure_system_prompt_materialized().await?;
|
self.ensure_system_prompt_materialized().await?;
|
||||||
self.cleanup_finished_memory_task();
|
self.cleanup_finished_memory_task();
|
||||||
@@ -2430,10 +2531,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
|
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
|
||||||
let tool_result_summary = self
|
let tool_result_summary = self
|
||||||
.prompts()
|
.prompts()
|
||||||
|
.load_full()
|
||||||
.interrupt_tool_result_summary()
|
.interrupt_tool_result_summary()
|
||||||
.map_err(WorkerError::from)?;
|
.map_err(WorkerError::from)?;
|
||||||
let system_note = self
|
let system_note = self
|
||||||
.prompts()
|
.prompts()
|
||||||
|
.load_full()
|
||||||
.interrupt_system_note()
|
.interrupt_system_note()
|
||||||
.map_err(WorkerError::from)?;
|
.map_err(WorkerError::from)?;
|
||||||
|
|
||||||
@@ -2444,10 +2547,13 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
if !closures.is_empty() {
|
if !closures.is_empty() {
|
||||||
self.engine_mut().append_history(closures)?;
|
self.engine_mut().append_history(closures)?;
|
||||||
}
|
}
|
||||||
|
let interrupt_prompt_provenance =
|
||||||
|
self.prompt_render_provenance("internal.interrupt_system_note");
|
||||||
self.commit_entry(LogEntry::SystemItem {
|
self.commit_entry(LogEntry::SystemItem {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
item: SystemItem::Interrupt {
|
item: SystemItem::Interrupt {
|
||||||
body: system_note.clone(),
|
body: system_note.clone(),
|
||||||
|
prompt_provenance: Some(interrupt_prompt_provenance),
|
||||||
},
|
},
|
||||||
})?;
|
})?;
|
||||||
self.engine_mut()
|
self.engine_mut()
|
||||||
@@ -3173,6 +3279,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||||
let summary_system_prompt = self
|
let summary_system_prompt = self
|
||||||
.prompts
|
.prompts
|
||||||
|
.load_full()
|
||||||
.compact_system()
|
.compact_system()
|
||||||
.map_err(WorkerError::PromptCatalog)?;
|
.map_err(WorkerError::PromptCatalog)?;
|
||||||
let mut summary_worker = Engine::new(summary_client).system_prompt(summary_system_prompt);
|
let mut summary_worker = Engine::new(summary_client).system_prompt(summary_system_prompt);
|
||||||
@@ -3802,7 +3909,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let memory_language = memory_language(memory_cfg);
|
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,
|
Ok(prompt) => prompt,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
audit
|
audit
|
||||||
@@ -4711,6 +4822,9 @@ where
|
|||||||
if state.entries_count == 0 {
|
if state.entries_count == 0 {
|
||||||
return Err(WorkerError::SegmentEmpty { segment_id });
|
return Err(WorkerError::SegmentEmpty { segment_id });
|
||||||
}
|
}
|
||||||
|
if state.system_prompt.is_none() {
|
||||||
|
return Err(WorkerError::SegmentSystemPromptMissing { segment_id });
|
||||||
|
}
|
||||||
let mirror_entries: Vec<LogEntry> = raw_entries.clone();
|
let mirror_entries: Vec<LogEntry> = raw_entries.clone();
|
||||||
let scope_config = effective_restore_scope_config(&store, &manifest)?;
|
let scope_config = effective_restore_scope_config(&store, &manifest)?;
|
||||||
|
|
||||||
@@ -5443,6 +5557,9 @@ pub enum WorkerError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
PromptCatalog(#[from] CatalogError),
|
PromptCatalog(#[from] CatalogError),
|
||||||
|
|
||||||
|
#[error("failed to resolve current Workspace Prompt projection: {message}")]
|
||||||
|
WorkspacePromptProjection { message: String },
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Skill(#[from] SkillClientError),
|
Skill(#[from] SkillClientError),
|
||||||
|
|
||||||
@@ -5455,6 +5572,9 @@ pub enum WorkerError {
|
|||||||
#[error("session {segment_id} has no entries to restore")]
|
#[error("session {segment_id} has no entries to restore")]
|
||||||
SegmentEmpty { segment_id: SegmentId },
|
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")]
|
#[error("worker metadata for {worker_name} was not found")]
|
||||||
WorkerMetadataMissing { worker_name: String },
|
WorkerMetadataMissing { worker_name: String },
|
||||||
|
|
||||||
@@ -5507,7 +5627,7 @@ struct WorkerCommon {
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
delegation_scope: DelegationScope,
|
delegation_scope: DelegationScope,
|
||||||
client: Box<dyn LlmClient>,
|
client: Box<dyn LlmClient>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
system_prompt_template: Option<SystemPromptTemplate>,
|
system_prompt_template: Option<SystemPromptTemplate>,
|
||||||
feature_instructions: Vec<FeatureInstructionDeclaration>,
|
feature_instructions: Vec<FeatureInstructionDeclaration>,
|
||||||
}
|
}
|
||||||
@@ -5649,7 +5769,7 @@ fn prepare_worker_common_from_scope(
|
|||||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||||
|
|
||||||
let client = crate::model_client::build_client(&manifest.model)?;
|
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 {
|
let system_prompt_template = if parse_template {
|
||||||
Some(
|
Some(
|
||||||
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
|
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
|
||||||
@@ -6782,7 +6902,7 @@ mod build_summary_prompt_tests {
|
|||||||
matches!(
|
matches!(
|
||||||
entry,
|
entry,
|
||||||
LogEntry::SystemItem {
|
LogEntry::SystemItem {
|
||||||
item: SystemItem::Interrupt { body },
|
item: SystemItem::Interrupt { body, .. },
|
||||||
..
|
..
|
||||||
} if body == &interrupt_note
|
} if body == &interrupt_note
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ use worker_runtime::http_server::{
|
|||||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
||||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||||
|
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
|
||||||
};
|
};
|
||||||
use worker_runtime::identity::{
|
use worker_runtime::identity::{
|
||||||
RuntimeWorkerRef, WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef,
|
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 {
|
fn sync_config_bundle(&self, _bundle: ConfigBundle) -> ConfigBundleSyncResult {
|
||||||
ConfigBundleSyncResult {
|
ConfigBundleSyncResult {
|
||||||
state: WorkerOperationState::Unsupported,
|
state: WorkerOperationState::Unsupported,
|
||||||
@@ -1185,6 +1193,36 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api))
|
Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn observe_workspace_prompt_projection(
|
||||||
|
&self,
|
||||||
|
projection: worker::WorkspacePromptProjection,
|
||||||
|
) -> Vec<RuntimeDiagnostic> {
|
||||||
|
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(
|
pub fn spawn_worker(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
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 {
|
fn sync_config_bundle(&self, bundle: ConfigBundle) -> ConfigBundleSyncResult {
|
||||||
match self.runtime.store_config_bundle(bundle) {
|
match self.runtime.store_config_bundle(bundle) {
|
||||||
Ok(availability) => ConfigBundleSyncResult {
|
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 {
|
fn sync_config_bundle(&self, bundle: ConfigBundle) -> ConfigBundleSyncResult {
|
||||||
let request = RuntimeHttpConfigBundleSyncRequest { bundle };
|
let request = RuntimeHttpConfigBundleSyncRequest { bundle };
|
||||||
match self.post_json::<_, RuntimeHttpConfigBundleAvailabilityResponse>(
|
match self.post_json::<_, RuntimeHttpConfigBundleAvailabilityResponse>(
|
||||||
@@ -4507,6 +4566,7 @@ mod tests {
|
|||||||
runtime_id: String,
|
runtime_id: String,
|
||||||
host_id: String,
|
host_id: String,
|
||||||
workers: Vec<WorkerSummary>,
|
workers: Vec<WorkerSummary>,
|
||||||
|
observed_prompt_revisions: Arc<Mutex<Vec<u64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FixtureRuntime {
|
impl FixtureRuntime {
|
||||||
@@ -4542,6 +4602,7 @@ mod tests {
|
|||||||
working_directory: None,
|
working_directory: None,
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}],
|
}],
|
||||||
|
observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4551,6 +4612,17 @@ mod tests {
|
|||||||
&self.runtime_id
|
&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 {
|
fn runtime_summary(&self, _limit: usize) -> RuntimeSummary {
|
||||||
RuntimeSummary {
|
RuntimeSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
@@ -4647,6 +4719,36 @@ mod tests {
|
|||||||
assert_eq!(from_runtime_a.label, "worker from runtime a");
|
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]
|
#[test]
|
||||||
fn registry_worker_list_can_be_scoped_by_runtime_id() {
|
fn registry_worker_list_can_be_scoped_by_runtime_id() {
|
||||||
let registry = RuntimeRegistry::new(vec![
|
let registry = RuntimeRegistry::new(vec![
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use std::time::UNIX_EPOCH;
|
|||||||
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
||||||
use manifest::{ProfileSource, resolve_profile_artifact_value};
|
use manifest::{ProfileSource, resolve_profile_artifact_value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use worker::EffectivePromptCatalog;
|
||||||
use worker_runtime::config_bundle::{
|
use worker_runtime::config_bundle::{
|
||||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||||
};
|
};
|
||||||
@@ -247,6 +249,95 @@ pub fn selector_for_workspace_candidate(
|
|||||||
.then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string()))
|
.then(|| worker_runtime::catalog::ProfileSelector::Named(profile.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_prompt_projection_matches_state(
|
||||||
|
workspace_id: &str,
|
||||||
|
state: &WorkspaceConfigState,
|
||||||
|
projection: &worker::WorkspacePromptProjection,
|
||||||
|
) -> Result<()> {
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
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::<String>();
|
||||||
|
Ok(format!(
|
||||||
|
"workspace-config-profile-r{}-{identity}",
|
||||||
|
state.snapshot.revision
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_virtual_profile_config_bundle(
|
pub fn build_virtual_profile_config_bundle(
|
||||||
projection: &ProfileConfigProjection,
|
projection: &ProfileConfigProjection,
|
||||||
state: &WorkspaceConfigState,
|
state: &WorkspaceConfigState,
|
||||||
@@ -254,6 +345,28 @@ pub fn build_virtual_profile_config_bundle(
|
|||||||
workspace_created_at: &str,
|
workspace_created_at: &str,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
) -> Result<Option<ConfigBundle>> {
|
) -> Result<Option<ConfigBundle>> {
|
||||||
|
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: &worker::WorkspacePromptProjection,
|
||||||
|
) -> Result<Option<ConfigBundle>> {
|
||||||
|
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
|
||||||
|
let prompt_catalog = prompt_projection.catalog().clone();
|
||||||
let archive = projection
|
let archive = projection
|
||||||
.entries
|
.entries
|
||||||
.get(selector)
|
.get(selector)
|
||||||
@@ -261,9 +374,16 @@ pub fn build_virtual_profile_config_bundle(
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
let profile_selector = selector_for_builtin_candidate(selector)
|
let profile_selector = selector_for_builtin_candidate(selector)
|
||||||
.unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string()));
|
.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 {
|
let bundle = ConfigBundle {
|
||||||
metadata: ConfigBundleMetadata {
|
metadata: ConfigBundleMetadata {
|
||||||
id: format!("workspace-config-profile-r{}", state.snapshot.revision),
|
id: bundle_id,
|
||||||
digest: String::new(),
|
digest: String::new(),
|
||||||
revision: state.snapshot.revision.to_string(),
|
revision: state.snapshot.revision.to_string(),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
@@ -281,7 +401,7 @@ pub fn build_virtual_profile_config_bundle(
|
|||||||
label: Some(selector.to_string()),
|
label: Some(selector.to_string()),
|
||||||
}],
|
}],
|
||||||
declarations: Vec::new(),
|
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: archive,
|
||||||
profile_source_archive_handle: None,
|
profile_source_archive_handle: None,
|
||||||
}
|
}
|
||||||
@@ -828,6 +948,146 @@ mod tests {
|
|||||||
assert_eq!(archive.reference.source_graph.import_count, 1);
|
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]
|
#[test]
|
||||||
fn virtual_config_projection_rejects_missing_profile_source() {
|
fn virtual_config_projection_rejects_missing_profile_source() {
|
||||||
let state = virtual_state(vec![
|
let state = virtual_state(vec![
|
||||||
|
|||||||
@@ -1,11 +1,162 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use config_source::{ConfigProjectionValidator, ConfigSchemaContribution};
|
use config_source::{ConfigProjectionValidator, ConfigSchemaContribution};
|
||||||
use worker::{EffectivePromptCatalog, prompt_schema_source};
|
use worker::{EffectivePromptCatalog, WorkspacePromptProjection, prompt_schema_source};
|
||||||
|
|
||||||
use crate::config_source::{
|
use crate::config_source::{
|
||||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
struct PromptProjectionCacheKey {
|
||||||
|
workspace_id: String,
|
||||||
|
config_revision: u64,
|
||||||
|
source_digest: String,
|
||||||
|
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,
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptProjectionCell = OnceLock<std::result::Result<Arc<WorkspacePromptProjection>, String>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct PromptProjectionCacheState {
|
||||||
|
entries: BTreeMap<PromptProjectionCacheKey, Arc<PromptProjectionCell>>,
|
||||||
|
active: BTreeMap<String, PromptProjectionCacheKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Mutex<PromptProjectionCacheState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspacePromptProjectionCache {
|
||||||
|
pub fn resolve(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
state: &WorkspaceConfigState,
|
||||||
|
) -> Result<Arc<WorkspacePromptProjection>> {
|
||||||
|
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<PromptProjectionCell>,
|
||||||
|
) -> 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<std::sync::MutexGuard<'_, PromptProjectionCacheState>> {
|
||||||
|
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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct PromptConfigSchemaProvider;
|
pub struct PromptConfigSchemaProvider;
|
||||||
|
|
||||||
@@ -64,12 +215,28 @@ pub fn project_prompts_from_workspace_config(
|
|||||||
"active Workspace config projection has no prompts namespace".to_string(),
|
"active Workspace config projection has no prompts namespace".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
EffectivePromptCatalog::from_projection(
|
let mut catalog = EffectivePromptCatalog::from_projection(
|
||||||
prompts,
|
prompts,
|
||||||
state.snapshot.revision,
|
state.snapshot.revision,
|
||||||
state.contract.schema_bundle.fingerprint.clone(),
|
state.contract.schema_bundle.fingerprint.clone(),
|
||||||
state.contract.fingerprint.clone(),
|
state.contract.fingerprint.clone(),
|
||||||
)
|
)
|
||||||
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||||
|
catalog.source_digest = state.snapshot.digest.clone();
|
||||||
|
Ok(catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_workspace_prompt_projection(
|
||||||
|
workspace_id: &str,
|
||||||
|
state: &WorkspaceConfigState,
|
||||||
|
) -> Result<WorkspacePromptProjection> {
|
||||||
|
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()))
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +249,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn state(source: &str) -> WorkspaceConfigState {
|
fn state(source: &str) -> WorkspaceConfigState {
|
||||||
|
state_at(7, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_at(revision: u64, source: &str) -> WorkspaceConfigState {
|
||||||
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
let schema = WorkspaceConfigSchemaBundle::compose([PromptConfigSchemaProvider
|
||||||
.contribution()
|
.contribution()
|
||||||
.unwrap()])
|
.unwrap()])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
7,
|
revision,
|
||||||
[ConfigEntry::new(
|
[ConfigEntry::new(
|
||||||
VirtualPath::parse("main.dcdl").unwrap(),
|
VirtualPath::parse("main.dcdl").unwrap(),
|
||||||
ConfigContentType::Decodal,
|
ConfigContentType::Decodal,
|
||||||
@@ -113,6 +284,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::<Vec<_>>();
|
||||||
|
let first = &catalogs[0];
|
||||||
|
assert!(catalogs.iter().all(|catalog| Arc::ptr_eq(first, catalog)));
|
||||||
|
assert_eq!(cache.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() {
|
fn workspace_override_deep_patches_builtin_and_preserves_other_leaves() {
|
||||||
let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap();
|
let baseline = project_prompts_from_workspace_config(&state("{}")).unwrap();
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ pub struct WorkspaceApi {
|
|||||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||||
config_store: Arc<crate::SqliteWorkspaceStore>,
|
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||||
|
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
companion: Arc<CompanionConsole>,
|
companion: Arc<CompanionConsole>,
|
||||||
@@ -806,6 +807,8 @@ impl WorkspaceApi {
|
|||||||
let api = Self {
|
let api = Self {
|
||||||
config_store,
|
config_store,
|
||||||
config_schema_registry,
|
config_schema_registry,
|
||||||
|
prompt_projection_cache:
|
||||||
|
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
||||||
authority: SqliteWorkspaceAuthority::new(
|
authority: SqliteWorkspaceAuthority::new(
|
||||||
config.database_path.clone(),
|
config.database_path.clone(),
|
||||||
config.workspace_id.clone(),
|
config.workspace_id.clone(),
|
||||||
@@ -1240,6 +1243,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/config/source-tree",
|
"/api/w/{workspace_id}/config/source-tree",
|
||||||
get(scoped_get_workspace_config_tree),
|
get(scoped_get_workspace_config_tree),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/config/projections/prompts",
|
||||||
|
get(scoped_get_prompt_projection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/config/source-tree/commit",
|
"/api/w/{workspace_id}/config/source-tree/commit",
|
||||||
post(scoped_commit_workspace_config_tree),
|
post(scoped_commit_workspace_config_tree),
|
||||||
@@ -2578,6 +2585,25 @@ struct WorkspaceConfigEntryPath {
|
|||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_prompt_projection(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<worker::WorkspacePromptProjection>> {
|
||||||
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct WorkspaceConfigTreeResponse {
|
struct WorkspaceConfigTreeResponse {
|
||||||
snapshot: ConfigTreeSnapshot,
|
snapshot: ConfigTreeSnapshot,
|
||||||
@@ -2647,6 +2673,14 @@ async fn scoped_commit_workspace_config_tree(
|
|||||||
let state = api
|
let state = api
|
||||||
.config_store
|
.config_store
|
||||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
.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((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(WorkspaceConfigTreeResponse {
|
Json(WorkspaceConfigTreeResponse {
|
||||||
@@ -2876,9 +2910,13 @@ fn validate_ticket_assignment_state(
|
|||||||
assignment: &WorkerTicketAssignmentRequest,
|
assignment: &WorkerTicketAssignmentRequest,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket = api.authority.ticket(&assignment.ticket_id)?;
|
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!(
|
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
|
ticket.id, ticket.state
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -3046,6 +3084,33 @@ fn assign_ticket_worker_from_lifecycle(
|
|||||||
.current)
|
.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(
|
fn existing_lifecycle_assignment_worker(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
assignment: &crate::hosts::WorkerTicketAssignmentRequest,
|
assignment: &crate::hosts::WorkerTicketAssignmentRequest,
|
||||||
@@ -5243,12 +5308,13 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(projection) =
|
let Ok(projection) = api
|
||||||
crate::prompt_settings::project_prompts_from_workspace_config(&config_state)
|
.prompt_projection_cache
|
||||||
|
.resolve(&api.config.workspace_id, &config_state)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(catalog) = worker::PromptCatalog::from_projection(projection) else {
|
let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = match catalog.render_serializable(
|
let content = match catalog.render_serializable(
|
||||||
@@ -9005,12 +9071,17 @@ async fn create_workspace_worker(
|
|||||||
"profile must be selected from Backend-published worker profile candidates",
|
"profile must be selected from Backend-published worker profile candidates",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let resolved_config_bundle = crate::profile_settings::build_virtual_profile_config_bundle(
|
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,
|
&profile_projection,
|
||||||
&config_state,
|
&config_state,
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&api.config.workspace_created_at,
|
&api.config.workspace_created_at,
|
||||||
&profile,
|
&profile,
|
||||||
|
prompt_catalog.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
let display_name = sanitize_worker_display_name(&display_name).ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
@@ -9242,6 +9313,20 @@ fn browser_worker_response_from_summary(
|
|||||||
link_worker_to_workdir(api, &worker_record, workdir_id, None)?;
|
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 runtime_id = worker.worker.runtime_id.clone();
|
||||||
let worker_id = worker.worker.worker_id.clone();
|
let worker_id = worker.worker.worker_id.clone();
|
||||||
let workspace_id = api.workspace_id().to_string();
|
let workspace_id = api.workspace_id().to_string();
|
||||||
@@ -9456,6 +9541,7 @@ enum WorkerSpawnFinalizeStage {
|
|||||||
WorkerRegistry,
|
WorkerRegistry,
|
||||||
TicketAssignmentBind,
|
TicketAssignmentBind,
|
||||||
TicketAssignmentCurrent,
|
TicketAssignmentCurrent,
|
||||||
|
TicketStateAccept,
|
||||||
WorkdirRegistry,
|
WorkdirRegistry,
|
||||||
WorkdirAttachment,
|
WorkdirAttachment,
|
||||||
}
|
}
|
||||||
@@ -9466,6 +9552,7 @@ impl WorkerSpawnFinalizeStage {
|
|||||||
Self::WorkerRegistry => "worker_registry",
|
Self::WorkerRegistry => "worker_registry",
|
||||||
Self::TicketAssignmentBind => "ticket_assignment_bind",
|
Self::TicketAssignmentBind => "ticket_assignment_bind",
|
||||||
Self::TicketAssignmentCurrent => "ticket_assignment_current",
|
Self::TicketAssignmentCurrent => "ticket_assignment_current",
|
||||||
|
Self::TicketStateAccept => "ticket_state_accept",
|
||||||
Self::WorkdirRegistry => "workdir_registry",
|
Self::WorkdirRegistry => "workdir_registry",
|
||||||
Self::WorkdirAttachment => "workdir_attachment",
|
Self::WorkdirAttachment => "workdir_attachment",
|
||||||
}
|
}
|
||||||
@@ -12797,7 +12884,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
init_clean_git_workspace(workspace.path());
|
||||||
let api = test_api(workspace.path()).await;
|
let api = test_api(workspace.path()).await;
|
||||||
@@ -12857,7 +12944,7 @@ mod tests {
|
|||||||
let api = test_api(workspace.path()).await;
|
let api = test_api(workspace.path()).await;
|
||||||
let backend = browser_ticket_backend(&api).unwrap();
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
let mut input = ticket::NewTicket::new("Assigned Ticket");
|
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 ticket = backend.create(input).unwrap();
|
||||||
let response = create_workspace_worker(
|
let response = create_workspace_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -12882,6 +12969,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
api.authority.ticket(&ticket.id).unwrap().state,
|
||||||
|
TicketWorkflowState::InProgress.as_str()
|
||||||
|
);
|
||||||
let current = api
|
let current = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id)
|
.get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id)
|
||||||
@@ -12900,6 +12991,50 @@ mod tests {
|
|||||||
assert_eq!(operation.worker, Some(response.worker_ref));
|
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]
|
#[tokio::test]
|
||||||
async fn worker_source_auth_rejects_cross_workspace_mutation() {
|
async fn worker_source_auth_rejects_cross_workspace_mutation() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -12996,7 +13131,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
init_clean_git_workspace(workspace.path());
|
||||||
let config = test_server_config(workspace.path());
|
let config = test_server_config(workspace.path());
|
||||||
@@ -13036,17 +13171,19 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(stopped.state, WorkerOperationState::Accepted);
|
assert_eq!(stopped.state, WorkerOperationState::Accepted);
|
||||||
let Json(restored) = scoped_start_workspace_orchestrator(
|
let error = scoped_start_workspace_orchestrator(
|
||||||
State(api),
|
State(api.clone()),
|
||||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.expect_err("pending Workspace Orchestrator restore without durable Prompt must fail");
|
||||||
assert_eq!(restored.disposition, "restored");
|
assert!(
|
||||||
assert!(restored.online);
|
format!("{error:?}").contains(
|
||||||
assert_eq!(
|
"pending Workspace Worker restore requires operation-owned launch material"
|
||||||
restored.worker.expect("restored Orchestrator").worker,
|
),
|
||||||
worker
|
"unexpected restore error: {error:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ Workspace: {{workspace_id}}
|
|||||||
Remaining queued Tickets (bounded):
|
Remaining queued Tickets (bounded):
|
||||||
{{ticket_lines}}
|
{{ticket_lines}}
|
||||||
{{omitted_line}}
|
{{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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<system-reminder>
|
<system-reminder>
|
||||||
Workspace Dashboard observed that this Orchestrator Worker is idle while queued Ticket work is present.
|
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 }}
|
Workspace: {{ workspace }}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ You are the Ticket Orchestrator role.
|
|||||||
|
|
||||||
{% include "common.git" %}
|
{% 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user