feat: caller-selected Backend Jobs and bounded subjektiv consolidation (T-704)

This commit is contained in:
2026-10-06 19:45:37 +09:00
parent 6f9621ca2e
commit d12cdc9106
36 changed files with 4085 additions and 291 deletions
+29
View File
@@ -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(())
}
}
+32 -19
View File
@@ -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(),
+1 -1
View File
@@ -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());
}
}
+2 -2
View File
@@ -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);
}
}
}
+2
View File
@@ -1,3 +1,5 @@
mod backend_job;
pub use backend_job::BackendJobExecutionBinding;
pub mod bootstrap;
pub mod checkout;
pub mod compact;
+94
View File
@@ -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([
+102 -12
View File
@@ -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]