feat: caller-selected Backend Jobs and bounded subjektiv consolidation (T-704)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
//! Host-authored Job authority, separate from reusable Profile policy.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Immutable execution capability delivered only by a trusted Backend host.
|
||||
/// Names, instructions and Profiles never confer this capability.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
|
||||
impl BackendJobExecutionBinding {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if [&self.job_id, &self.attempt_id]
|
||||
.into_iter()
|
||||
.chain(self.input_revision.iter())
|
||||
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Backend Job binding contains an invalid identity or input revision");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1376,10 +1376,12 @@ where
|
||||
),
|
||||
None => durable_parent_notifications,
|
||||
};
|
||||
let backend_job_profile = worker.manifest().engine.instruction == "internal.backend_job_system";
|
||||
let backend_job = worker.backend_job().cloned();
|
||||
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
|
||||
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
||||
let prompts = worker.prompts().clone();
|
||||
let tracker = tools::Tracker::new();
|
||||
if !backend_job_profile {
|
||||
{
|
||||
let paste_store = worker.store().clone();
|
||||
let paste_session_id = worker.session_id();
|
||||
worker
|
||||
@@ -1426,11 +1428,9 @@ where
|
||||
let worker_enabled = feature_config.worker.enabled;
|
||||
let sub_worker_enabled = feature_config.sub_worker.enabled;
|
||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||
if !backend_job_profile {
|
||||
feature_registry
|
||||
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
|
||||
}
|
||||
if backend_job_profile {
|
||||
feature_registry
|
||||
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
|
||||
if let Some(binding) = backend_job.as_ref() {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
@@ -1438,11 +1438,18 @@ where
|
||||
"Backend Job result capability requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
let mut result_feature =
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace_client,
|
||||
),
|
||||
);
|
||||
workspace_client.clone(),
|
||||
);
|
||||
if binding.subjektiv_consolidation {
|
||||
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
|
||||
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
|
||||
prompts.clone(), spawner_workspace_context.clone(),
|
||||
)?;
|
||||
result_feature = result_feature.with_surface(surface);
|
||||
}
|
||||
feature_registry.add_module(result_feature);
|
||||
}
|
||||
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||
@@ -1455,7 +1462,11 @@ where
|
||||
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
(
|
||||
plan.resident_summary_source.clone(),
|
||||
plan.system_prompt_override.clone(),
|
||||
if backend_job.is_none() {
|
||||
plan.system_prompt_override.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
});
|
||||
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
|
||||
@@ -1469,7 +1480,9 @@ where
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
if let Some(plan) = subjektiv_consolidation_plan {
|
||||
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
|
||||
if backend_job.is_none() {
|
||||
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
|
||||
}
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
let ordinary_subjektiv_features_enabled =
|
||||
@@ -1488,7 +1501,7 @@ where
|
||||
.to_string();
|
||||
if let Some(refresh_feature) =
|
||||
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
Arc::clone(&resident_summary_source),
|
||||
worker.prompts(),
|
||||
workspace_id,
|
||||
@@ -1519,7 +1532,7 @@ where
|
||||
}
|
||||
if let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
memory_lifecycle_config,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
@@ -1533,8 +1546,8 @@ where
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
}
|
||||
if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
|
||||
lifecycle_enabled,
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
@@ -1546,7 +1559,7 @@ where
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
@@ -1721,7 +1734,7 @@ where
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode && !backend_job_profile {
|
||||
if wip_mode {
|
||||
crate::checkout::mount_checkouts(
|
||||
&mut wip_mount_registry,
|
||||
worker.workdir_sessions(),
|
||||
|
||||
@@ -190,7 +190,7 @@ impl BackgroundTaskCancellation {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
pub(crate) fn cancel(&self) {
|
||||
self.sender.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! identity come from the authenticated Workspace client, while Job, attempt,
|
||||
//! and input revision are resolved from the Backend-owned binding.
|
||||
|
||||
use std::sync::Arc;
|
||||
use crate::feature::background::BackgroundTaskCancellation;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -12,6 +14,7 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration, ToolDefinition,
|
||||
@@ -27,9 +30,61 @@ struct SubmitResultInput {
|
||||
result: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConsolidationResult {
|
||||
subject_id: String,
|
||||
candidate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmitConsolidationResultInput {
|
||||
/// Closed candidate ids only. Host awaits and adds the actual surface outcome.
|
||||
result: ConsolidationResult,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ResultSubmissionState {
|
||||
sealed: Option<SealedResult>,
|
||||
reusable_surface: Option<Value>,
|
||||
}
|
||||
|
||||
struct SealedResult {
|
||||
original: Value,
|
||||
completed: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubmitResultTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
submission: Arc<tokio::sync::Mutex<ResultSubmissionState>>,
|
||||
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
|
||||
}
|
||||
|
||||
struct ActiveResultGuard {
|
||||
id: String,
|
||||
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
}
|
||||
impl Drop for ActiveResultGuard {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
self.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled_output() -> ToolError {
|
||||
ToolError::Cancelled(ToolOutput {
|
||||
summary: "Job result submission cancelled; committed candidate decisions are preserved"
|
||||
.into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -37,27 +92,128 @@ impl Tool for SubmitResultTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: ToolExecutionContext,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SubmitResultInput = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let id = context.execution_id();
|
||||
let cancellation = self
|
||||
.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
.clone();
|
||||
let _active = ActiveResultGuard {
|
||||
id,
|
||||
active: self.active.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
};
|
||||
// Serialize result calls and retain the exact lifecycle outcome for ambiguous retries.
|
||||
let mut submission = tokio::select! {
|
||||
guard = self.submission.lock() => guard,
|
||||
_ = cancellation.cancelled() => return Err(cancelled_output()),
|
||||
};
|
||||
// An existing seal may already have had an effect, even if this replay
|
||||
// is now definitively rejected. It must never be reopened.
|
||||
let was_previously_sealed = submission.sealed.is_some();
|
||||
let result = if let Some(sealed) = submission.sealed.as_ref() {
|
||||
if sealed.original != input.result {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Job result is already sealed; retry the identical result".into(),
|
||||
));
|
||||
}
|
||||
sealed.completed.clone()
|
||||
} else {
|
||||
let mut result = input.result.clone();
|
||||
if let Some(surface) = &self.surface {
|
||||
let checked = serde_json::from_str::<SubmitConsolidationResultInput>(input_json)
|
||||
.map_err(|error| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"invalid consolidation result (surface is Host-generated): {error}"
|
||||
))
|
||||
})?
|
||||
.result;
|
||||
if checked.subject_id.trim().is_empty()
|
||||
|| checked.candidate_ids.iter().any(|id| id.trim().is_empty())
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"subject_id and candidate ids must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let object = result
|
||||
.as_object_mut()
|
||||
.expect("typed consolidation result is an object");
|
||||
let outcome = surface
|
||||
.complete_for_job(cancellation.clone(), submission.reusable_surface.as_ref())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if cancellation.is_cancelled() {
|
||||
cancelled_output()
|
||||
} else {
|
||||
ToolError::ExecutionFailed(error.to_string())
|
||||
}
|
||||
})?;
|
||||
submission.reusable_surface = Some(outcome.clone());
|
||||
object.insert("surface".into(), outcome);
|
||||
}
|
||||
result
|
||||
};
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(cancelled_output());
|
||||
}
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"Backend Job result submission requires Workspace identity".to_string(),
|
||||
)
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/workers/self/backend-job-result"),
|
||||
serde_json::to_string(&serde_json::json!({ "result": input.result }))
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/workers/self/backend-job-result"),
|
||||
serde_json::to_string(&serde_json::json!({ "result": result }))
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||
);
|
||||
// Seal before dispatch: a transport failure may follow durable acceptance.
|
||||
if submission.sealed.is_none() {
|
||||
submission.sealed = Some(SealedResult {
|
||||
original: input.result,
|
||||
completed: result,
|
||||
});
|
||||
submission.reusable_surface = None;
|
||||
}
|
||||
let response = match self.client.execute(request) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result acceptance is unknown; retry the identical result: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let definitive_rejection = matches!(response.status, 400 | 403 | 409 | 422);
|
||||
if !response.is_success() {
|
||||
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
|
||||
if definitive_rejection && !was_previously_sealed {
|
||||
// Only a definitively unaccepted request can be corrected. Reuse
|
||||
// a ready snapshot after a new preparation confirms its revision;
|
||||
// a conflict invalidates even that snapshot. Failed outcomes are
|
||||
// rebuilt, since another generation can replace their marker.
|
||||
submission.reusable_surface = if response.status != 409 {
|
||||
sealed
|
||||
.completed
|
||||
.get("surface")
|
||||
.filter(|surface| surface["availability"] == "ready")
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
submission.sealed = None;
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result was definitively rejected with HTTP {}; correct result fields or retry to rebuild a stale surface: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result submission was rejected with HTTP {}: {}",
|
||||
"Backend Job result acceptance remains sealed; retry the identical result (HTTP {}): {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
@@ -67,11 +223,30 @@ impl Tool for SubmitResultTool {
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.entry(context.execution_id())
|
||||
.or_default()
|
||||
.cancel();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SubmitResultInput))
|
||||
.expect("Backend Job result schema must serialize");
|
||||
fn definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
) -> ToolDefinition {
|
||||
let submission = Arc::new(tokio::sync::Mutex::new(ResultSubmissionState::default()));
|
||||
let active = Arc::new(Mutex::new(HashMap::new()));
|
||||
let schema = if surface.is_some() {
|
||||
serde_json::to_value(schemars::schema_for!(SubmitConsolidationResultInput))
|
||||
} else {
|
||||
serde_json::to_value(schemars::schema_for!(SubmitResultInput))
|
||||
}
|
||||
.expect("Backend Job result schema must serialize");
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(TOOL_NAME)
|
||||
@@ -79,6 +254,9 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
.input_schema(schema.clone()),
|
||||
Arc::new(SubmitResultTool {
|
||||
client: client.clone(),
|
||||
surface: surface.clone(),
|
||||
submission: submission.clone(),
|
||||
active: active.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
@@ -87,11 +265,22 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
#[derive(Clone)]
|
||||
pub struct BackendJobResultFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
}
|
||||
|
||||
impl BackendJobResultFeature {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
Self {
|
||||
client,
|
||||
surface: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendJobResultFeature {
|
||||
pub(crate) fn with_surface(mut self, surface: SubjektivSurfaceLifecycleFeature) -> Self {
|
||||
self.surface = Some(surface);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +294,7 @@ impl FeatureModule for BackendJobResultFeature {
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(ToolContribution::new(
|
||||
TOOL_NAME,
|
||||
definition(self.client.clone()),
|
||||
definition(self.client.clone(), self.surface.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -172,4 +361,14 @@ mod tests {
|
||||
.ends_with("/workers/self/backend-job-result")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_before_execution_never_submits_a_result() {
|
||||
let client = Arc::new(RecordingClient::default());
|
||||
let (_, tool) = definition(client.clone(), None)();
|
||||
let context = ToolExecutionContext::new("result-call", "batch", 0);
|
||||
tool.cancel_execution(&context).await.unwrap();
|
||||
let outcome = tool.execute(r#"{"result":{"valid":true}}"#, context).await;
|
||||
assert!(matches!(outcome, Err(ToolError::Cancelled(_))));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,7 +1115,7 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
|
||||
manifest.feature.subjektiv.execution_enabled()
|
||||
&& !is_builtin_subjektiv_consolidation_profile(manifest)
|
||||
&& !manifest.feature.subjektiv.profile.consolidation_tools
|
||||
}
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
@@ -1175,7 +1175,7 @@ impl SubjektivConsolidationFeatureInstallPlan {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !is_builtin_subjektiv_consolidation_profile(manifest) {
|
||||
if !manifest.feature.subjektiv.profile.consolidation_tools {
|
||||
return Ok(None);
|
||||
}
|
||||
let config = &manifest.feature.subjektiv;
|
||||
|
||||
@@ -104,6 +104,23 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
if !lifecycle_enabled || !dedicated {
|
||||
return Ok(None);
|
||||
}
|
||||
Self::for_job(
|
||||
workspace_client,
|
||||
manifest,
|
||||
client,
|
||||
prompts,
|
||||
workspace_context,
|
||||
)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn for_job(
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> std::io::Result<Self> {
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
@@ -121,7 +138,7 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
"subjektiv surface generation requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
Ok(Self {
|
||||
task: SubjektivSurfaceLifecycleTask {
|
||||
workspace_client,
|
||||
manifest,
|
||||
@@ -129,7 +146,18 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
prompts,
|
||||
workspace_context,
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The Job tool awaits this clean-context lifecycle before submitting its result.
|
||||
pub(crate) async fn complete_for_job(
|
||||
&self,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
reusable_surface: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
self.task
|
||||
.generate(None, cancellation, reusable_surface)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,80 +210,125 @@ impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask {
|
||||
context: BackgroundTaskContext,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
self.generate(Some(&context), cancellation, None)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleTask {
|
||||
async fn generate(
|
||||
&self,
|
||||
context: Option<&BackgroundTaskContext>,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
reusable_surface: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
for attempt in 0..MAX_GENERATION_ATTEMPTS {
|
||||
context.generation_fence.ensure_current()?;
|
||||
if let Some(context) = context {
|
||||
context.generation_fence.ensure_current()?;
|
||||
}
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
let generation = self
|
||||
.workspace_client
|
||||
.prepare_subjektiv_memory_surface()
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
if context.is_none() {
|
||||
if let Some(snapshot_id) = &generation.current_snapshot_id {
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Preparation is Backend-fenced to the live attempt and immutable batch.
|
||||
// Only a ready snapshot is immutable within its store revision. Never
|
||||
// reuse a failed marker, which a later generation can replace.
|
||||
if let Some(surface) = reusable_surface.filter(|surface| {
|
||||
surface["availability"] == "ready"
|
||||
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
|
||||
}) {
|
||||
return Ok(surface.clone());
|
||||
}
|
||||
if generation.materials.is_empty() && generation.active_memory_count > 0 {
|
||||
self.record_failure(&generation.generation_id, "input_budget_exhausted")
|
||||
return self
|
||||
.record_failure(&generation, "input_budget_exhausted", context.is_none())
|
||||
.await;
|
||||
tracing::warn!(
|
||||
active_memory_count = generation.active_memory_count,
|
||||
"subject Memory surface materials could not fit the bounded input"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let points = if generation.materials.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match self.edit_surface(&generation, cancellation.clone()).await {
|
||||
Ok(points) => points,
|
||||
Err(failure) => {
|
||||
self.record_failure(&generation.generation_id, failure.reason_code)
|
||||
.await;
|
||||
match tokio::time::timeout(
|
||||
TASK_TIMEOUT,
|
||||
self.edit_surface(&generation, cancellation.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(points)) => points,
|
||||
Ok(Err(failure)) => {
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed");
|
||||
return Ok(());
|
||||
return self
|
||||
.record_failure(&generation, failure.reason_code, context.is_none())
|
||||
.await;
|
||||
}
|
||||
Err(_) => {
|
||||
return self
|
||||
.record_failure(&generation, "editor_timeout", context.is_none())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
if let Some(context) = context {
|
||||
context.generation_fence.ensure_current()?;
|
||||
}
|
||||
let publish = self
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
match self
|
||||
.workspace_client
|
||||
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
|
||||
generation_id: generation.generation_id.clone(),
|
||||
points,
|
||||
})
|
||||
.await;
|
||||
match publish {
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
tracing::debug!(
|
||||
snapshot_id = output.snapshot_id,
|
||||
store_revision = output.built_from_store_revision,
|
||||
empty = output.empty,
|
||||
"published subject Memory surface"
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
|
||||
}));
|
||||
}
|
||||
Err(WorkspaceMemoryBackendError::Http { status, .. })
|
||||
if status == reqwest::StatusCode::CONFLICT
|
||||
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
|
||||
{
|
||||
// Confirmed Memory moved while editing. Discard the stale
|
||||
// output and rebuild once from a fresh bounded generation.
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.record_failure(&generation.generation_id, "publish_failed")
|
||||
.await;
|
||||
tracing::warn!(%error, "subject Memory surface publication failed");
|
||||
return Ok(());
|
||||
return self
|
||||
.record_failure(&generation, "publish_failed", context.is_none())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
unreachable!("bounded generation loop always returns on its last attempt")
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleTask {
|
||||
async fn edit_surface(
|
||||
&self,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
@@ -344,17 +417,39 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
.map_err(SurfaceEditorFailure::editor)
|
||||
}
|
||||
|
||||
async fn record_failure(&self, generation_id: &str, reason_code: &str) {
|
||||
if let Err(error) = self
|
||||
async fn record_failure(
|
||||
&self,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
reason_code: &str,
|
||||
job_confirmation_required: bool,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
let failure = self
|
||||
.workspace_client
|
||||
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
|
||||
generation_id: generation_id.to_string(),
|
||||
generation_id: generation.generation_id.clone(),
|
||||
reason_code: reason_code.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
// Success must attest the actual persisted marker, not merely receipt of
|
||||
// a failure notification. A protected ready surface or moved revision
|
||||
// must yield a Backend rejection/non-failed status, never a failed claim.
|
||||
let confirmed_status = if job_confirmation_required {
|
||||
"failed_confirmed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
|
||||
{
|
||||
tracing::debug!(%error, "could not record subject Memory surface failure");
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
|
||||
));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"availability": "failed", "generation_id": generation.generation_id,
|
||||
"store_revision": failure.store_revision, "reason_code": reason_code,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +632,7 @@ mod tests {
|
||||
) -> server_api::SubjektivSurfacePrepareResponse {
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: "generation-1".into(),
|
||||
current_snapshot_id: None,
|
||||
store_revision: 1,
|
||||
active_memory_count: materials.len(),
|
||||
materials,
|
||||
@@ -561,6 +657,13 @@ mod tests {
|
||||
prepare_calls: AtomicUsize,
|
||||
publish_calls: AtomicUsize,
|
||||
failure_reasons: Mutex<Vec<String>>,
|
||||
job_results: Mutex<Vec<serde_json::Value>>,
|
||||
result_responses:
|
||||
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
current_revision: AtomicUsize,
|
||||
failure_status: Mutex<String>,
|
||||
failure_revision: AtomicUsize,
|
||||
ready_snapshot: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl SurfaceWorkspaceClient {
|
||||
@@ -571,6 +674,12 @@ mod tests {
|
||||
prepare_calls: AtomicUsize::new(0),
|
||||
publish_calls: AtomicUsize::new(0),
|
||||
failure_reasons: Mutex::new(Vec::new()),
|
||||
job_results: Mutex::new(Vec::new()),
|
||||
result_responses: Mutex::new(Default::default()),
|
||||
current_revision: AtomicUsize::new(0),
|
||||
failure_status: Mutex::new("failed".into()),
|
||||
failure_revision: AtomicUsize::new(0),
|
||||
ready_snapshot: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +690,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(&self, fallback: usize) -> u64 {
|
||||
let current = self.current_revision.load(Ordering::SeqCst);
|
||||
if current == 0 {
|
||||
fallback as u64
|
||||
} else {
|
||||
current as u64
|
||||
}
|
||||
}
|
||||
|
||||
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
@@ -606,6 +724,28 @@ mod tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/workers/self/backend-job-result") {
|
||||
assert!(
|
||||
self.publish_calls.load(Ordering::SeqCst) > 0
|
||||
|| !self.failure_reasons.lock().unwrap().is_empty(),
|
||||
"result must follow publication or recorded failure"
|
||||
);
|
||||
self.job_results
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(serde_json::from_str(request.body.as_deref().unwrap()).unwrap());
|
||||
return self
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{\"replayed\":false}".into(),
|
||||
})
|
||||
});
|
||||
}
|
||||
let request: server_api::SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default())
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
@@ -615,7 +755,8 @@ mod tests {
|
||||
let mut prepared =
|
||||
generation(self.input_token_budget, vec![material("memory-1", 32)]);
|
||||
prepared.generation_id = format!("generation-{call}");
|
||||
prepared.store_revision = call as u64;
|
||||
prepared.store_revision = self.revision(call);
|
||||
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
|
||||
))
|
||||
@@ -633,7 +774,7 @@ mod tests {
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
|
||||
server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: format!("surface-{call}"),
|
||||
built_from_store_revision: call as u64,
|
||||
built_from_store_revision: self.revision(call),
|
||||
empty: false,
|
||||
},
|
||||
),
|
||||
@@ -644,8 +785,13 @@ mod tests {
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
|
||||
server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64,
|
||||
status: "failed".into(),
|
||||
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
|
||||
{
|
||||
self.failure_revision.load(Ordering::SeqCst) as u64
|
||||
} else {
|
||||
self.revision(self.prepare_calls.load(Ordering::SeqCst))
|
||||
},
|
||||
status: self.failure_status.lock().unwrap().clone(),
|
||||
},
|
||||
),
|
||||
))
|
||||
@@ -974,4 +1120,340 @@ permission = "write"
|
||||
let error = bounded_editor_input("system policy", &generation).unwrap_err();
|
||||
assert!(error.to_string().contains("input budget"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn job_result_waits_for_surface_and_retries_the_exact_outcome() {
|
||||
for failed in [false, true] {
|
||||
let workspace = Arc::new(if failed {
|
||||
SurfaceWorkspaceClient::with_input_token_budget(1)
|
||||
} else {
|
||||
SurfaceWorkspaceClient::new(0)
|
||||
});
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
let lifecycle = SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
|
||||
};
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(lifecycle),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
let input = r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
|
||||
for _ in 0..2 {
|
||||
tool.execute(input, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0], results[1]);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["availability"],
|
||||
if failed { "failed" } else { "ready" }
|
||||
);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
"generation-1"
|
||||
);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
tool.execute(
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
|
||||
agen::tool::ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_uses_backend_confirmed_current_surface_without_regeneration() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
*workspace.ready_snapshot.lock().unwrap() = Some("already-current".into());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let task = test_task(workspace.clone(), calls.clone());
|
||||
let outcome = task
|
||||
.generate(None, BackgroundTaskCancellation::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome["availability"], "ready");
|
||||
assert_eq!(outcome["generation_id"], "generation-1");
|
||||
assert_eq!(outcome["snapshot_id"], "already-current");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_rejects_model_surface_claims_before_generation() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
let lifecycle = SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
|
||||
};
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(lifecycle),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
assert!(tool.execute(r#"{"result":{"subject_id":"subject-1","candidate_ids":[],"surface":{"availability":"ready"}}}"#, agen::tool::ToolExecutionContext::direct()).await.is_err());
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PendingSurfaceEditorClient(Arc<tokio::sync::Notify>);
|
||||
#[async_trait]
|
||||
impl LlmClient for PendingSurfaceEditorClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
self.0.notify_one();
|
||||
Ok(Box::pin(futures::stream::pending()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_job_surface_waits_for_editor_and_never_submits_result() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let mut task = test_task(workspace.clone(), Arc::new(AtomicUsize::new(0)));
|
||||
task.client = Box::new(PendingSurfaceEditorClient(entered.clone()));
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(SubjektivSurfaceLifecycleFeature { task }),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
let running_tool = tool.clone();
|
||||
let context = agen::tool::ToolExecutionContext::new("result-call", "batch", 0);
|
||||
let running_context = context.clone();
|
||||
let running = tokio::spawn(async move {
|
||||
running_tool
|
||||
.execute(
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
|
||||
running_context,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
tool.cancel_execution(&context).await.unwrap();
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), running)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Err(agen::tool::ToolError::Cancelled(_))));
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
|
||||
}
|
||||
fn job_result_tool(workspace: Arc<SurfaceWorkspaceClient>) -> Arc<dyn agen::tool::Tool> {
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace, Arc::new(AtomicUsize::new(0))),
|
||||
}),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
pending.remove(0)().1
|
||||
}
|
||||
|
||||
const CORRECT_RESULT: &str =
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
|
||||
const WRONG_RESULT: &str = r#"{"result":{"subject_id":"wrong-subject","candidate_ids":[]}}"#;
|
||||
|
||||
fn rejection(status: u16) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Ok(WorkspaceResponse {
|
||||
status,
|
||||
body: "explicit rejection before acceptance".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
|
||||
for status in [400, 403, 422] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(rejection(status));
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_ne!(
|
||||
results[0]["result"]["subject_id"],
|
||||
results[1]["result"]["subject_id"]
|
||||
);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"],
|
||||
results[1]["result"]["surface"]
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.prepare_calls.load(Ordering::SeqCst),
|
||||
2,
|
||||
"reuse must verify current revision"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.publish_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"correction must not duplicate publication"
|
||||
);
|
||||
drop(results);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err(),
|
||||
"accepted results stay sealed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
|
||||
for status in [400, 409] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(rejection(status));
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
workspace.current_revision.store(2, Ordering::SeqCst);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
|
||||
assert_ne!(
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
results[1]["result"]["surface"]["generation_id"]
|
||||
);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_ambiguous_acceptance_never_reopens_even_after_later_rejection() {
|
||||
for first in [
|
||||
Err(WorkspaceClientError::Request(
|
||||
"accepted but response lost".into(),
|
||||
)),
|
||||
rejection(500),
|
||||
] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([first, rejection(403)]);
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], results[1]);
|
||||
assert_eq!(results[1], results[2]);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
|
||||
for (status, revision) in [
|
||||
("failed", 0),
|
||||
("ready", 0),
|
||||
("stale", 0),
|
||||
("failed_confirmed", 2),
|
||||
] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
*workspace.failure_status.lock().unwrap() = status.into();
|
||||
workspace.failure_revision.store(revision, Ordering::SeqCst);
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
workspace.failure_revision.store(0, Ordering::SeqCst);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(workspace.job_results.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod backend_job;
|
||||
pub use backend_job::BackendJobExecutionBinding;
|
||||
pub mod bootstrap;
|
||||
pub mod checkout;
|
||||
pub mod compact;
|
||||
|
||||
@@ -2599,6 +2599,8 @@ struct PendingCompactionCleanup {
|
||||
/// `session-store` functions after each turn.
|
||||
pub struct Worker<C: LlmClient, St: Store> {
|
||||
manifest: WorkerManifest,
|
||||
/// Host-bound once before Feature installation; never inherited by child Workers.
|
||||
backend_job: std::sync::OnceLock<Option<crate::BackendJobExecutionBinding>>,
|
||||
/// Always `Some` outside of `run()`/`resume()`.
|
||||
engine: Option<Engine<C, Mutable, SessionHistoryMetadata>>,
|
||||
/// Sole live authority for committed model-visible history.
|
||||
@@ -2965,6 +2967,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let scope = SharedScope::new(scope);
|
||||
let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope);
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
@@ -3128,6 +3131,30 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
self.segment_state.session_id()
|
||||
}
|
||||
|
||||
/// Immutable host-owned Job authority, absent for ordinary and child Workers.
|
||||
pub fn backend_job(&self) -> Option<&crate::BackendJobExecutionBinding> {
|
||||
self.backend_job.get().and_then(Option::as_ref)
|
||||
}
|
||||
|
||||
/// Trusted host seam; immutable after binding and unavailable after Feature installation.
|
||||
pub fn bind_backend_job(
|
||||
&mut self,
|
||||
binding: crate::BackendJobExecutionBinding,
|
||||
) -> Result<(), &'static str> {
|
||||
binding.validate()?;
|
||||
if self.interceptor_installed || self.backend_job.get().is_some() {
|
||||
return Err("Backend Job capability is already sealed");
|
||||
}
|
||||
if !self.workspace_client().is_available()
|
||||
|| self.workspace_client().workspace_id().is_none()
|
||||
{
|
||||
return Err("Backend Job capability requires Backend Workspace API authority");
|
||||
}
|
||||
self.backend_job
|
||||
.set(Some(binding))
|
||||
.map_err(|_| "Backend Job capability is already bound")
|
||||
}
|
||||
|
||||
/// The Worker's manifest.
|
||||
pub fn manifest(&self) -> &WorkerManifest {
|
||||
&self.manifest
|
||||
@@ -3398,6 +3425,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
&mut self,
|
||||
registry: FeatureRegistryBuilder,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
self.backend_job.get_or_init(|| None);
|
||||
let worker = self.engine.as_mut().expect("worker taken during run");
|
||||
let report = registry.install_into_engine(worker, &mut self.hook_builder);
|
||||
if report.has_errors() {
|
||||
@@ -7249,6 +7277,7 @@ where
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
@@ -7350,6 +7379,18 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
if self
|
||||
.backend_job()
|
||||
.is_some_and(|binding| binding.subjektiv_consolidation)
|
||||
{
|
||||
if metadata.subjektiv_session_attribution.is_some() {
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
state: "job_attribution_forbidden",
|
||||
message: "consolidation Job execution must not carry subject-body Session attribution".into(),
|
||||
});
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !subjektiv_attached {
|
||||
if metadata.subjektiv_session_attribution.is_some() {
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
@@ -7528,6 +7569,7 @@ where
|
||||
let workdir_sessions =
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
engine: Some(engine),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
@@ -7655,6 +7697,7 @@ where
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, Vec::new()),
|
||||
@@ -8036,6 +8079,7 @@ where
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
session: WorkerSession::new(session_id, restored_history_entries),
|
||||
@@ -10485,6 +10529,56 @@ mod build_summary_prompt_tests {
|
||||
(worker, store, temp)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_worker_cannot_gain_job_authority_after_feature_installation() {
|
||||
let client = Arc::new(AttributionLifecycleClient::new([]));
|
||||
let (mut worker, _store, _temp) = attribution_lifecycle_worker(client).await;
|
||||
worker.install_features(FeatureRegistryBuilder::new());
|
||||
assert!(
|
||||
worker
|
||||
.bind_backend_job(crate::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: None,
|
||||
subjektiv_consolidation: false
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(worker.backend_job().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consolidation_job_binding_is_immutable_and_has_no_subject_body_attribution() {
|
||||
let client = Arc::new(AttributionLifecycleClient::new([]));
|
||||
let (mut worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await;
|
||||
let binding = crate::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: Some("revision-1".into()),
|
||||
subjektiv_consolidation: true,
|
||||
};
|
||||
worker.bind_backend_job(binding.clone()).unwrap();
|
||||
assert_eq!(worker.backend_job(), Some(&binding));
|
||||
assert!(worker.bind_backend_job(binding).is_err());
|
||||
for lifecycle in [
|
||||
SubjektivSessionAttributionLifecycle::NewSession,
|
||||
SubjektivSessionAttributionLifecycle::RestoredSession,
|
||||
] {
|
||||
worker
|
||||
.finalize_subjektiv_session_attribution(true, lifecycle)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
store
|
||||
.read_by_name(&worker.manifest().worker.name)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.subjektiv_session_attribution
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_attribution_finalizes_once_before_runs_and_persists_subject() {
|
||||
let client = Arc::new(AttributionLifecycleClient::new([
|
||||
|
||||
@@ -838,36 +838,126 @@ async fn feature_flags_default_to_core_tool_surface_only() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_job_profile_exposes_only_structured_result_capability() {
|
||||
async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
|
||||
for bound in [false, true] {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut resolved = ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "backend-job"),
|
||||
ProfileResolveOptions::with_worker_name("backend-job-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
// A custom instruction and enabled feature must survive trusted Job binding.
|
||||
if bound {
|
||||
resolved.manifest.engine.instruction = "default".into();
|
||||
resolved.manifest.feature.task.enabled = true;
|
||||
}
|
||||
let instruction = resolved.manifest.engine.instruction.clone();
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
|
||||
client,
|
||||
resolved.manifest,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
|
||||
)
|
||||
.await;
|
||||
if bound {
|
||||
worker
|
||||
.bind_backend_job(worker::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: Some("input-1".into()),
|
||||
subjektiv_consolidation: false,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(worker.manifest().engine.instruction, instruction);
|
||||
let handle = spawn_controller(worker).await;
|
||||
handle
|
||||
.send(Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"Run the bounded Backend Job.",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
let names = request_tool_names(&request);
|
||||
assert_eq!(
|
||||
names.iter().any(|name| name == "SubmitBackendJobResult"),
|
||||
bound
|
||||
);
|
||||
assert_eq!(names.iter().any(|name| name == "TaskCreate"), bound);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
let mut manifest = ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "backend-job"),
|
||||
ProfileResolveOptions::with_worker_name("backend-job-worker"),
|
||||
&ProfileSelector::source_named(
|
||||
ProfileRegistrySource::Builtin,
|
||||
"subjektiv-memory-consolidation",
|
||||
),
|
||||
ProfileResolveOptions::with_worker_name("custom-consolidation-job"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest;
|
||||
manifest.engine.instruction = "default".into();
|
||||
manifest.feature.task.enabled = true;
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
|
||||
let captured = client.clone();
|
||||
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
|
||||
client,
|
||||
resolved.manifest,
|
||||
manifest,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
|
||||
)
|
||||
.await;
|
||||
worker
|
||||
.engine_mut()
|
||||
.set_system_prompt("chosen custom instruction");
|
||||
worker
|
||||
.bind_backend_job(worker::BackendJobExecutionBinding {
|
||||
job_id: "job-1".into(),
|
||||
attempt_id: "attempt-1".into(),
|
||||
input_revision: None,
|
||||
subjektiv_consolidation: true,
|
||||
})
|
||||
.unwrap();
|
||||
let handle = spawn_controller(worker).await;
|
||||
|
||||
handle
|
||||
.send(Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"Run the bounded Backend Job.",
|
||||
"Inspect the bounded batch.",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
assert_eq!(request_tool_names(&request), vec!["SubmitBackendJobResult"]);
|
||||
let request = wait_for_captured_request(&captured).await;
|
||||
assert_eq!(
|
||||
request.system_prompt.as_deref(),
|
||||
Some("chosen custom instruction")
|
||||
);
|
||||
let tools = request_tool_names(&request);
|
||||
for tool in [
|
||||
"SubmitBackendJobResult",
|
||||
"MemoryApplyCandidate",
|
||||
"TaskCreate",
|
||||
] {
|
||||
assert!(tools.iter().any(|name| name == tool));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user