feat: add durable backend job runner

This commit is contained in:
2026-10-01 10:06:55 +09:00
parent 6b2f40a2a2
commit ca838e4acf
27 changed files with 2823 additions and 13 deletions
+14
View File
@@ -1225,6 +1225,20 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
if worker.manifest().engine.instruction == "internal.backend_job_system" {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Backend Job result capability requires Backend Workspace API authority",
));
}
feature_registry.add_module(
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace_client,
),
);
}
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
+1
View File
@@ -4,6 +4,7 @@
//! same descriptor-approved registry path used by feature modules. They are not
//! an external plugin-loading surface.
pub mod backend_job_result;
pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
@@ -0,0 +1,175 @@
//! Structured completion capability for Backend-owned Job Workers.
//!
//! The tool intentionally accepts only the result value. Workspace and Worker
//! 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 agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDefinition,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const TOOL_NAME: &str = "SubmitBackendJobResult";
const TOOL_DESCRIPTION: &str = "Submit the bounded structured result for this Backend-owned Job attempt. Workspace, Job, attempt, input revision, and source Worker identity are resolved and fenced by Backend authority; normal final prose is not success.";
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SubmitResultInput {
result: Value,
}
#[derive(Clone)]
struct SubmitResultTool {
client: Arc<dyn WorkspaceClient>,
}
#[async_trait]
impl Tool for SubmitResultTool {
async fn execute(
&self,
input_json: &str,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SubmitResultInput = serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
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()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Backend Job result submission was rejected with HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: "Backend Job result accepted".to_string(),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
let schema = serde_json::to_value(schemars::schema_for!(SubmitResultInput))
.expect("Backend Job result schema must serialize");
Arc::new(move || {
(
ToolMeta::new(TOOL_NAME)
.description(TOOL_DESCRIPTION)
.input_schema(schema.clone()),
Arc::new(SubmitResultTool {
client: client.clone(),
}) as Arc<dyn Tool>,
)
})
}
#[derive(Clone)]
pub struct BackendJobResultFeature {
client: Arc<dyn WorkspaceClient>,
}
impl BackendJobResultFeature {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self { client }
}
}
impl FeatureModule for BackendJobResultFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("backend-job-result", "Backend Job result")
.with_description("Single-purpose structured result capability for Backend-owned Jobs.")
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
TOOL_NAME,
definition(self.client.clone()),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::hook::HookRegistryBuilder;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use std::sync::Mutex;
#[derive(Debug, Default)]
struct RecordingClient {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for RecordingClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: "{\"replayed\":false}".to_string(),
})
}
}
#[tokio::test]
async fn feature_exposes_only_identity_free_structured_result_input() {
let client = Arc::new(RecordingClient::default());
let mut pending = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(BackendJobResultFeature::new(client.clone()))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(report.installed_tool_names(), [TOOL_NAME]);
let (meta, tool) = pending.remove(0)();
assert_eq!(meta.input_schema["required"], serde_json::json!(["result"]));
assert!(meta.input_schema["properties"].get("job_id").is_none());
tool.execute(
r#"{"result":{"valid":true}}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
assert!(
requests[0]
.path
.ends_with("/workers/self/backend-job-result")
);
}
}
+3
View File
@@ -95,6 +95,7 @@ pub enum WorkerPrompt {
MemoryExtractSystem,
MemoryConsolidationSystem,
FlowVerifierSystem,
BackendJobSystem,
NotifyWrapper,
InterruptToolResultSummary,
InterruptSystemNote,
@@ -112,6 +113,7 @@ impl WorkerPrompt {
Self::MemoryExtractSystem => "internal.memory_extract_system",
Self::MemoryConsolidationSystem => "internal.memory_consolidation_system",
Self::FlowVerifierSystem => "internal.flow_verifier_system",
Self::BackendJobSystem => "internal.backend_job_system",
Self::NotifyWrapper => "internal.notify_wrapper",
Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary",
Self::InterruptSystemNote => "internal.interrupt_system_note",
@@ -130,6 +132,7 @@ impl WorkerPrompt {
WorkerPrompt::MemoryExtractSystem,
WorkerPrompt::MemoryConsolidationSystem,
WorkerPrompt::FlowVerifierSystem,
WorkerPrompt::BackendJobSystem,
WorkerPrompt::NotifyWrapper,
WorkerPrompt::InterruptToolResultSummary,
WorkerPrompt::InterruptSystemNote,