feat: execute durable standalone Jobs through Host-owned Internal Workers (T-708)
This commit is contained in:
@@ -16,6 +16,7 @@ agen = { workspace = true, features = ["codex"] }
|
||||
session-store = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
job = { workspace = true }
|
||||
mcp = { workspace = true }
|
||||
protocol = { workspace = true, features = ["json-schema"] }
|
||||
client = { workspace = true }
|
||||
|
||||
@@ -205,6 +205,9 @@ where
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
worker
|
||||
.stop_feature_runtime("internal Worker feature installation failed")
|
||||
.await;
|
||||
return Err(InternalWorkerError {
|
||||
source: WorkerError::FeatureInstall(format!(
|
||||
"internal Worker feature installation failed: {diagnostics}; missing tools: {missing}"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Host-facing, result-only Jobs executed by the private Internal Worker substrate.
|
||||
//!
|
||||
//! The host owns persistence, concurrency, timeout, retries and result acceptance. Normal
|
||||
//! timeout/shutdown routes through the cancellation sender and awaits `run`, including Worker
|
||||
//! resource cleanup. An emergency drop/abort does not confirm asynchronous cleanup; the Host
|
||||
//! must retain its lease and reconcile any unknown outcome before allowing another execution.
|
||||
//! The result-only Feature owns no background tasks, but an in-flight synchronous Host sink
|
||||
//! cannot be preempted by dropping its tool future and must itself be bounded.
|
||||
|
||||
mod result;
|
||||
pub use result::{JobResultFeature, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::llm_client::LlmClient;
|
||||
use manifest::{ProfileDiscovery, ProfileResolveOptions, ProfileResolver, Scope, WorkerManifest};
|
||||
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::internal_worker::{
|
||||
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
|
||||
run_internal_worker_with_cancel_sender,
|
||||
};
|
||||
use crate::{
|
||||
PromptCatalog, PromptCatalogSource, SystemPromptContext, SystemPromptTemplate,
|
||||
WorkerFilesystemAuthority, WorkerPrompt, WorkerRunResult, WorkerWorkspaceContext,
|
||||
};
|
||||
|
||||
/// Host adapter ceilings, applied even if the selected Profile leaves budgets unbounded.
|
||||
pub const MAX_JOB_TURNS: u32 = 32;
|
||||
pub const MAX_JOB_TOKENS: u32 = 8192;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JobExecutionError {
|
||||
#[error(transparent)]
|
||||
Request(#[from] ::job::JobError),
|
||||
#[error("Job Profile resolution failed: {0}")]
|
||||
Profile(#[from] manifest::ProfileError),
|
||||
#[error("result-only Job cannot satisfy Profile requirements: {0}")]
|
||||
UnsupportedProfile(String),
|
||||
#[error("Job instruction resolution failed: {0}")]
|
||||
Instruction(#[from] crate::SystemPromptError),
|
||||
#[error("Job Prompt catalog resolution failed: {0}")]
|
||||
Catalog(#[from] crate::CatalogError),
|
||||
#[error("Job model configuration failed: {0}")]
|
||||
Model(#[from] crate::ProviderError),
|
||||
#[error("Job execution failed: {0}")]
|
||||
Worker(String),
|
||||
/// A sink error can follow durable acceptance. Hosts must reconcile their store and must
|
||||
/// not classify this as a known retryable failure unless they can prove non-acceptance.
|
||||
#[error(
|
||||
"Host Job result acceptance is unconfirmed; reconcile the Host store before retrying: {0}"
|
||||
)]
|
||||
ResultUnconfirmed(String),
|
||||
#[error("Job execution was cancelled without an accepted result")]
|
||||
Cancelled,
|
||||
#[error("Job ended without an accepted SubmitJobResult; final prose is not success")]
|
||||
MissingResult,
|
||||
}
|
||||
|
||||
/// Confirms only a synchronous, successful Host acceptance. Lifecycle/prose cannot create this.
|
||||
#[derive(Debug)]
|
||||
pub struct InternalJobResult {
|
||||
pub submission: ::job::JobResultSubmission,
|
||||
pub usage: Option<agen::timeline::event::UsageEvent>,
|
||||
}
|
||||
|
||||
/// Resolved policy/model/prompt, with no running resources and no inherited authority.
|
||||
pub struct PreparedInternalJob {
|
||||
request: ::job::JobRequest,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
system_prompt: String,
|
||||
}
|
||||
|
||||
/// Resolve exactly the requested registry Profile using the existing user settings registry.
|
||||
/// `cwd` is a resolution base only; it never becomes filesystem or Workspace authority.
|
||||
/// An injected client is useful for scripted executions and does not bypass policy validation.
|
||||
pub fn prepare_job(
|
||||
request: &::job::JobRequest,
|
||||
cwd: &Path,
|
||||
client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<PreparedInternalJob, JobExecutionError> {
|
||||
let registry = ProfileDiscovery::user_settings().discover()?;
|
||||
prepare_job_from_registry(request, cwd, client, ®istry)
|
||||
}
|
||||
|
||||
fn prepare_job_from_registry(
|
||||
request: &::job::JobRequest,
|
||||
cwd: &Path,
|
||||
client: Option<Box<dyn LlmClient>>,
|
||||
registry: &manifest::ProfileRegistry,
|
||||
) -> Result<PreparedInternalJob, JobExecutionError> {
|
||||
request.validate()?;
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(cwd)
|
||||
.resolve_from_registry(
|
||||
&request.profile_selector()?,
|
||||
registry,
|
||||
ProfileResolveOptions::with_worker_name("internal-job"),
|
||||
)?;
|
||||
let mut manifest = resolved.manifest;
|
||||
validate_result_only_profile(&manifest)?;
|
||||
let scope = Scope::empty();
|
||||
let prompts = PromptCatalog::builtins_only()?;
|
||||
let template = SystemPromptTemplate::parse(
|
||||
&manifest.engine.instruction,
|
||||
PromptCatalogSource::builtins_only(),
|
||||
)?;
|
||||
let mut system_prompt = template.render(&SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: Cow::Borrowed("(no filesystem)"),
|
||||
language: &manifest.engine.language,
|
||||
scope: &scope,
|
||||
tool_names: vec![SUBMIT_JOB_RESULT_TOOL.into()],
|
||||
feature_instructions: &[],
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: &prompts,
|
||||
})?;
|
||||
system_prompt.push_str("\n\n");
|
||||
system_prompt.push_str(&prompts.render(WorkerPrompt::JobSystem, minijinja::context! {})?);
|
||||
// Host budgets attenuate Profile output/turn policy, never grant another capability.
|
||||
manifest.engine.max_turns = std::num::NonZeroU32::new(
|
||||
manifest
|
||||
.engine
|
||||
.max_turns
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(MAX_JOB_TURNS)
|
||||
.min(MAX_JOB_TURNS),
|
||||
);
|
||||
manifest.engine.max_tokens = Some(
|
||||
manifest
|
||||
.engine
|
||||
.max_tokens
|
||||
.unwrap_or(MAX_JOB_TOKENS)
|
||||
.min(MAX_JOB_TOKENS),
|
||||
);
|
||||
let model_config = manifest::model_catalog::resolve_model_manifest(&manifest.model)
|
||||
.map_err(crate::ProviderError::from)?;
|
||||
let client = match client {
|
||||
Some(client) => client,
|
||||
None => crate::model_client::build_client_from_config(&model_config)?,
|
||||
};
|
||||
Ok(PreparedInternalJob {
|
||||
request: request.clone(),
|
||||
manifest,
|
||||
client,
|
||||
system_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
impl PreparedInternalJob {
|
||||
/// Run one immutable attempt. The sink must perform fenced, durable acceptance before
|
||||
/// returning Ok. Timeout is Host-owned: cancel and then await this future for cleanup.
|
||||
/// A result already accepted by the sink remains success even if the model subsequently
|
||||
/// fails, reaches its turn limit or is cancelled; callers never rerun an accepted result.
|
||||
pub async fn run<F>(
|
||||
self,
|
||||
attempt_id: String,
|
||||
submit: Arc<dyn JobResultSink>,
|
||||
on_cancel_sender: F,
|
||||
) -> Result<InternalJobResult, JobExecutionError>
|
||||
where
|
||||
F: FnOnce(tokio::sync::mpsc::Sender<()>),
|
||||
{
|
||||
let feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?;
|
||||
let accepted = feature.accepted();
|
||||
let engine_policy = self.manifest.engine.clone();
|
||||
let spec = InternalWorkerSpec {
|
||||
identity: InternalWorkerIdentity {
|
||||
kind: "job",
|
||||
run_id: uuid::Uuid::now_v7(),
|
||||
},
|
||||
max_turns: engine_policy.max_turns.map(|n| n.get()),
|
||||
engine_configurator: Some(Box::new(move |engine| {
|
||||
crate::apply_worker_manifest(engine, &engine_policy);
|
||||
})),
|
||||
manifest: self.manifest,
|
||||
client: self.client,
|
||||
system_prompt: self.system_prompt,
|
||||
input: self.request.worker_input(&attempt_id)?,
|
||||
cache_key: None,
|
||||
features: FeatureRegistryBuilder::new().with_module(feature.clone()),
|
||||
required_tools: &[SUBMIT_JOB_RESULT_TOOL],
|
||||
authority: InternalWorkerAuthority {
|
||||
workspace: WorkerWorkspaceContext::no_workspace(),
|
||||
filesystem: WorkerFilesystemAuthority::None,
|
||||
scope: Scope::empty(),
|
||||
workdir_session: None,
|
||||
},
|
||||
};
|
||||
let outcome = run_internal_worker_with_cancel_sender(spec, on_cancel_sender).await;
|
||||
let usage = match &outcome {
|
||||
Ok(run) => run.usage.clone(),
|
||||
Err(error) => error.usage.clone(),
|
||||
};
|
||||
if let Some(submission) = accepted.lock().expect("Job result state poisoned").clone() {
|
||||
return Ok(InternalJobResult { submission, usage });
|
||||
}
|
||||
if let Some(error) = feature.unconfirmed() {
|
||||
return Err(JobExecutionError::ResultUnconfirmed(error));
|
||||
}
|
||||
match outcome {
|
||||
Err(error) => Err(JobExecutionError::Worker(error.source.to_string())),
|
||||
Ok(run)
|
||||
if matches!(
|
||||
run.lifecycle,
|
||||
WorkerRunResult::Cancelled | WorkerRunResult::RolledBack
|
||||
) =>
|
||||
{
|
||||
Err(JobExecutionError::Cancelled)
|
||||
}
|
||||
Ok(_) => Err(JobExecutionError::MissingResult),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate requirements, not Profile names. Nothing configured as active is silently stripped.
|
||||
fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExecutionError> {
|
||||
let manifest::FeatureConfig {
|
||||
task,
|
||||
memory,
|
||||
subjektiv,
|
||||
web,
|
||||
image,
|
||||
sub_worker,
|
||||
flow,
|
||||
worker,
|
||||
workspace_worker_discovery,
|
||||
objective,
|
||||
manage_workdir,
|
||||
workdir_catalog,
|
||||
workspace_config,
|
||||
ticket,
|
||||
merge_request,
|
||||
orchestration,
|
||||
} = &manifest.feature;
|
||||
let mut unavailable = Vec::new();
|
||||
for (name, enabled) in [
|
||||
("feature.task", task.enabled),
|
||||
(
|
||||
"feature.memory",
|
||||
memory.profile.enabled
|
||||
|| memory.profile.staging_tools
|
||||
|| memory.workspace_settings.is_some(),
|
||||
),
|
||||
(
|
||||
"feature.subjektiv",
|
||||
subjektiv.profile.enabled
|
||||
|| subjektiv.profile.consolidation_tools
|
||||
|| subjektiv.workspace_settings.is_some(),
|
||||
),
|
||||
("feature.web", web.enabled),
|
||||
("feature.image", image.enabled),
|
||||
("feature.sub_worker", sub_worker.enabled),
|
||||
("feature.flow", flow.enabled),
|
||||
("feature.worker", worker.enabled),
|
||||
(
|
||||
"feature.workspace_worker_discovery",
|
||||
workspace_worker_discovery.enabled,
|
||||
),
|
||||
("feature.objective", objective.enabled),
|
||||
("feature.manage_workdir", manage_workdir.enabled),
|
||||
("feature.workspace_config", workspace_config.enabled),
|
||||
(
|
||||
"feature.ticket",
|
||||
ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow,
|
||||
),
|
||||
(
|
||||
"feature.merge_request",
|
||||
merge_request.show
|
||||
|| merge_request.open
|
||||
|| merge_request.review
|
||||
|| merge_request.readiness_check
|
||||
|| merge_request.complete,
|
||||
),
|
||||
("feature.orchestration", orchestration.enabled),
|
||||
// The default WIP-only catalog is inert in Tools mode, not a tool requirement.
|
||||
(
|
||||
"feature.workdir_catalog",
|
||||
workdir_catalog.enabled && manifest.worker.mode == manifest::WorkerMode::Wip,
|
||||
),
|
||||
(
|
||||
"worker.mode=wip",
|
||||
manifest.worker.mode == manifest::WorkerMode::Wip,
|
||||
),
|
||||
("scope", !manifest.scope.allow.is_empty()),
|
||||
(
|
||||
"delegation_scope",
|
||||
!manifest.delegation_scope.allow.is_empty(),
|
||||
),
|
||||
("mcp", !manifest.mcp.stdio_servers.is_empty()),
|
||||
("skills", manifest.skills.is_some()),
|
||||
("compaction", manifest.compaction.is_some()),
|
||||
(
|
||||
"web",
|
||||
manifest
|
||||
.web
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.enabled == Some(true)),
|
||||
),
|
||||
(
|
||||
"session.record_event_trace",
|
||||
manifest.session.record_event_trace,
|
||||
),
|
||||
] {
|
||||
if enabled {
|
||||
unavailable.push(name);
|
||||
}
|
||||
}
|
||||
if unavailable.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JobExecutionError::UnsupportedProfile(
|
||||
unavailable.join(", "),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::JobExecutionError;
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
|
||||
pub const SUBMIT_JOB_RESULT_TOOL: &str = "SubmitJobResult";
|
||||
const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input revision are immutable Host bindings. Only result is accepted as input; final prose is not success.";
|
||||
|
||||
/// Host acceptance authority. Implementations must validate the immutable fences and commit
|
||||
/// acceptance durably before returning Ok. Err may mean an unknown outcome; identical retries
|
||||
/// must be idempotent. This synchronous callback must be bounded and must not wait for async work.
|
||||
pub trait JobResultSink: Send + Sync {
|
||||
fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String>;
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmitJobResultInput {
|
||||
result: Value,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SubmissionState {
|
||||
sealed: Option<Value>,
|
||||
unconfirmed: Option<String>,
|
||||
}
|
||||
|
||||
/// Single-purpose FeatureModule; Profile names never install it or confer result authority.
|
||||
#[derive(Clone)]
|
||||
pub struct JobResultFeature {
|
||||
job_id: String,
|
||||
attempt_id: String,
|
||||
input_revision: String,
|
||||
max_result_bytes: u32,
|
||||
sink: Arc<dyn JobResultSink>,
|
||||
state: Arc<Mutex<SubmissionState>>,
|
||||
accepted: Arc<Mutex<Option<::job::JobResultSubmission>>>,
|
||||
cancelled: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl JobResultFeature {
|
||||
pub fn new(
|
||||
request: &::job::JobRequest,
|
||||
attempt_id: String,
|
||||
sink: Arc<dyn JobResultSink>,
|
||||
) -> Result<Self, JobExecutionError> {
|
||||
request.validate()?;
|
||||
::job::validate_identifier("attempt_id", &attempt_id, 512)?;
|
||||
Ok(Self {
|
||||
job_id: request.job_id.clone(),
|
||||
attempt_id,
|
||||
input_revision: request.input_revision.clone(),
|
||||
max_result_bytes: request.limits.max_result_bytes,
|
||||
sink,
|
||||
state: Arc::default(),
|
||||
accepted: Arc::default(),
|
||||
cancelled: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn accepted(&self) -> Arc<Mutex<Option<::job::JobResultSubmission>>> {
|
||||
self.accepted.clone()
|
||||
}
|
||||
|
||||
pub(super) fn unconfirmed(&self) -> Option<String> {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("Job submission state poisoned")
|
||||
.unconfirmed
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn definition(&self) -> ToolDefinition {
|
||||
let feature = self.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(SUBMIT_JOB_RESULT_TOOL)
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(
|
||||
serde_json::to_value(schemars::schema_for!(SubmitJobResultInput))
|
||||
.expect("Job result schema serializes"),
|
||||
),
|
||||
Arc::new(feature.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for JobResultFeature {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SubmitJobResultInput = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
::job::result_digest(&input.result, self.max_result_bytes)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let mut state = self.state.lock().expect("Job submission state poisoned");
|
||||
if self
|
||||
.cancelled
|
||||
.lock()
|
||||
.expect("Job cancellation state poisoned")
|
||||
.contains(&context.execution_id())
|
||||
{
|
||||
return Err(ToolError::Cancelled(ToolOutput {
|
||||
summary: "Job result submission cancelled".into(),
|
||||
content: None,
|
||||
attachments: vec![],
|
||||
}));
|
||||
}
|
||||
if let Some(sealed) = &state.sealed {
|
||||
if sealed != &input.result {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Job result is sealed; retry only the identical result".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Seal before dispatch: a sink error can follow durable Host acceptance.
|
||||
state.sealed = Some(input.result.clone());
|
||||
}
|
||||
let submission = ::job::JobResultSubmission {
|
||||
job_id: self.job_id.clone(),
|
||||
attempt_id: self.attempt_id.clone(),
|
||||
input_revision: self.input_revision.clone(),
|
||||
result: input.result,
|
||||
};
|
||||
let mut accepted = self.accepted.lock().expect("Job result state poisoned");
|
||||
if accepted.is_none() {
|
||||
if let Err(error) = self.sink.submit(submission.clone()) {
|
||||
let error = ::job::bounded_failure_detail(&error);
|
||||
state.unconfirmed = Some(error.clone());
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Host Job result acceptance was not confirmed; retry only the identical result: {error}"
|
||||
)));
|
||||
}
|
||||
state.unconfirmed = None;
|
||||
*accepted = Some(submission);
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: "Host Job result accepted".into(),
|
||||
content: None,
|
||||
attachments: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.cancelled
|
||||
.lock()
|
||||
.expect("Job cancellation state poisoned")
|
||||
.insert(context.execution_id());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for JobResultFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("host-job-result", "Host Job result")
|
||||
.with_description("Result-only immutable Host Job submission capability.")
|
||||
.with_tool(ToolDeclaration::new(SUBMIT_JOB_RESULT_TOOL, DESCRIPTION))
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(ToolContribution::new(
|
||||
SUBMIT_JOB_RESULT_TOOL,
|
||||
self.definition(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
//! Scripted in-process model executions, not provider/process tests.
|
||||
use super::*;
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::{ClientError, Request};
|
||||
use agen::tool::{Tool, ToolExecutionContext};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::VecDeque;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ScriptedClient {
|
||||
scripts: Arc<Mutex<VecDeque<Vec<Event>>>>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
pending: bool,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
fail: bool,
|
||||
}
|
||||
impl ScriptedClient {
|
||||
fn new(scripts: Vec<Vec<Event>>) -> Self {
|
||||
Self {
|
||||
scripts: Arc::new(Mutex::new(scripts.into())),
|
||||
requests: Arc::default(),
|
||||
pending: false,
|
||||
entered: Arc::default(),
|
||||
fail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl LlmClient for ScriptedClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if self.fail && self.scripts.lock().unwrap().is_empty() {
|
||||
return Err(ClientError::Config("scripted failure".into()));
|
||||
}
|
||||
if self.pending && self.scripts.lock().unwrap().is_empty() {
|
||||
self.entered.notify_one();
|
||||
return Ok(Box::pin(futures::stream::pending()));
|
||||
}
|
||||
let events = self
|
||||
.scripts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra model turn");
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct RecordingSink(Mutex<Vec<::job::JobResultSubmission>>);
|
||||
impl JobResultSink for RecordingSink {
|
||||
fn submit(&self, result: ::job::JobResultSubmission) -> Result<(), String> {
|
||||
self.0.lock().unwrap().push(result);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn prose() -> Vec<Event> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "success"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
fn call(name: &str, input: Value) -> Vec<Event> {
|
||||
vec![
|
||||
Event::tool_use_start(0, "call", name),
|
||||
Event::tool_input_delta(0, input.to_string()),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
}
|
||||
fn request(profile: &str) -> ::job::JobRequest {
|
||||
::job::JobRequest {
|
||||
job_id: "job-a".into(),
|
||||
purpose: "scripted".into(),
|
||||
input_revision: "rev-1".into(),
|
||||
input_ref: "snapshot:one".into(),
|
||||
input: json!({"text":"input"}),
|
||||
instruction: "Summarize the input".into(),
|
||||
profile: profile.into(),
|
||||
serialization_key: None,
|
||||
limits: Default::default(),
|
||||
}
|
||||
}
|
||||
fn artifact(instruction: &str, tokens: u32, turns: u32) -> Value {
|
||||
json!({"model":{"scheme":"anthropic", "model_id":"scripted-model", "auth":{"kind":"none"}},
|
||||
"engine":{"instruction":instruction, "language":"English", "max_tokens":tokens, "max_turns":turns}})
|
||||
}
|
||||
fn registry(root: &Path, a: Value, b: Value) -> manifest::ProfileRegistry {
|
||||
std::fs::write(root.join("a.json"), a.to_string()).unwrap();
|
||||
std::fs::write(root.join("b.json"), b.to_string()).unwrap();
|
||||
let settings = root.join("profiles.toml");
|
||||
std::fs::write(
|
||||
&settings,
|
||||
"default = 'user:a'\n[profile]\na = 'a.json'\nb = 'b.json'\n",
|
||||
)
|
||||
.unwrap();
|
||||
ProfileDiscovery::with_sources(Some(settings), None)
|
||||
.discover()
|
||||
.unwrap()
|
||||
}
|
||||
fn prepared(
|
||||
root: &Path,
|
||||
request: &::job::JobRequest,
|
||||
client: ScriptedClient,
|
||||
) -> PreparedInternalJob {
|
||||
let registry = registry(
|
||||
root,
|
||||
artifact("default", 512, 3),
|
||||
artifact("internal.flow_verifier_system", 100000, 1000),
|
||||
);
|
||||
prepare_job_from_registry(request, root, Some(Box::new(client)), ®istry).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_selected_profiles_with_only_result_authority_and_bounded_policy() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let registry = registry(
|
||||
root.path(),
|
||||
artifact("default", 512, 3),
|
||||
artifact("internal.flow_verifier_system", 100000, 1000),
|
||||
);
|
||||
let contract = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.render(WorkerPrompt::JobSystem, minijinja::context! {})
|
||||
.unwrap();
|
||||
let mut prompts = vec![];
|
||||
for (profile, tokens) in [
|
||||
("user:a", 512),
|
||||
("user:b", MAX_JOB_TOKENS),
|
||||
("default", 512),
|
||||
] {
|
||||
let client = ScriptedClient::new(vec![
|
||||
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"ok":true}})),
|
||||
prose(),
|
||||
]);
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let job = prepare_job_from_registry(
|
||||
&request(profile),
|
||||
root.path(),
|
||||
Some(Box::new(client.clone())),
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job.manifest.engine.max_turns.unwrap().get(),
|
||||
if profile == "user:b" {
|
||||
MAX_JOB_TURNS
|
||||
} else {
|
||||
3
|
||||
}
|
||||
);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let result = job
|
||||
.run("attempt-7".into(), sink.clone(), move |sender| {
|
||||
let _ = tx.send(sender);
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
rx.await.unwrap().is_closed(),
|
||||
"Worker must be released before returning acceptance"
|
||||
);
|
||||
assert_eq!(
|
||||
result.submission,
|
||||
::job::JobResultSubmission {
|
||||
job_id: "job-a".into(),
|
||||
attempt_id: "attempt-7".into(),
|
||||
input_revision: "rev-1".into(),
|
||||
result: json!({"ok":true})
|
||||
}
|
||||
);
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 1);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert_eq!(
|
||||
request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[SUBMIT_JOB_RESULT_TOOL]
|
||||
);
|
||||
assert_eq!(request.config.max_tokens, Some(tokens));
|
||||
assert_eq!(
|
||||
request.tools[0].input_schema["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.collect::<Vec<_>>(),
|
||||
["result"]
|
||||
);
|
||||
assert!(request.system_prompt.as_ref().unwrap().ends_with(&contract));
|
||||
}
|
||||
prompts.push(requests[0].system_prompt.clone());
|
||||
}
|
||||
assert_ne!(prompts[0], prompts[1]);
|
||||
assert_eq!(prompts[0], prompts[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_builtin_job_executes_with_catalog_contract_and_only_result_tool() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
let request = request("builtin:job");
|
||||
let client = ScriptedClient::new(vec![
|
||||
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"ok":true}})),
|
||||
prose(),
|
||||
]);
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let job = prepare_job_from_registry(
|
||||
&request,
|
||||
root.path(),
|
||||
Some(Box::new(client.clone())),
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job.manifest.engine.instruction,
|
||||
WorkerPrompt::JobSystem.key()
|
||||
);
|
||||
assert_eq!(
|
||||
job.manifest.model.ref_.as_deref(),
|
||||
Some("codex-oauth/gpt-5.6-luna")
|
||||
);
|
||||
assert_eq!(job.manifest.engine.max_turns.unwrap().get(), MAX_JOB_TURNS);
|
||||
assert!(job.manifest.scope.allow.is_empty());
|
||||
assert!(job.manifest.delegation_scope.allow.is_empty());
|
||||
assert!(WorkerPrompt::ALL.contains(&WorkerPrompt::JobSystem));
|
||||
let contract = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.render(WorkerPrompt::JobSystem, minijinja::context! {})
|
||||
.unwrap();
|
||||
assert!(job.system_prompt.ends_with(&contract));
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let result = job
|
||||
.run("builtin-attempt".into(), sink.clone(), move |sender| {
|
||||
let _ = tx.send(sender);
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(rx.await.unwrap().is_closed());
|
||||
assert_eq!(result.submission.job_id, request.job_id);
|
||||
assert_eq!(result.submission.attempt_id, "builtin-attempt");
|
||||
assert_eq!(result.submission.input_revision, request.input_revision);
|
||||
assert_eq!(result.submission.result, json!({"ok":true}));
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 1);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert_eq!(
|
||||
request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[SUBMIT_JOB_RESULT_TOOL]
|
||||
);
|
||||
assert_eq!(request.config.max_tokens, Some(MAX_JOB_TOKENS));
|
||||
assert!(request.system_prompt.as_ref().unwrap().ends_with(&contract));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_prose_and_ungranted_tool_calls_are_not_success() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for scripts in [
|
||||
vec![prose()],
|
||||
vec![
|
||||
call("Bash", json!({"command":"touch should-not-exist"})),
|
||||
prose(),
|
||||
],
|
||||
vec![
|
||||
call("SubmitBackendJobResult", json!({"result":{"ok":true}})),
|
||||
prose(),
|
||||
],
|
||||
vec![
|
||||
call(
|
||||
SUBMIT_JOB_RESULT_TOOL,
|
||||
json!({"job_id":"forged","result":{"ok":true}}),
|
||||
),
|
||||
prose(),
|
||||
],
|
||||
] {
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let outcome = prepared(
|
||||
root.path(),
|
||||
&request("user:a"),
|
||||
ScriptedClient::new(scripts),
|
||||
)
|
||||
.run("attempt".into(), sink.clone(), |_| {})
|
||||
.await;
|
||||
assert!(
|
||||
matches!(outcome, Err(JobExecutionError::MissingResult)),
|
||||
"{outcome:?}"
|
||||
);
|
||||
assert!(sink.0.lock().unwrap().is_empty());
|
||||
assert!(!root.path().join("should-not-exist").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn result_size_and_identity_are_fenced_before_host_submission() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut request = request("user:a");
|
||||
request.limits.max_result_bytes = 5;
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let job = prepared(
|
||||
root.path(),
|
||||
&request,
|
||||
ScriptedClient::new(vec![
|
||||
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":"too big"})),
|
||||
prose(),
|
||||
]),
|
||||
);
|
||||
assert!(matches!(
|
||||
job.run("attempt".into(), sink.clone(), |_| {}).await,
|
||||
Err(JobExecutionError::MissingResult)
|
||||
));
|
||||
assert!(sink.0.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_and_errors_return_without_acceptance() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut client = ScriptedClient::new(vec![]);
|
||||
client.pending = true;
|
||||
let entered = client.entered.clone();
|
||||
let job = prepared(root.path(), &request("user:a"), client);
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let run = tokio::spawn(async move {
|
||||
job.run("attempt".into(), sink, move |sender| {
|
||||
let _ = tx.send(sender);
|
||||
})
|
||||
.await
|
||||
});
|
||||
let sender = rx.await.unwrap();
|
||||
entered.notified().await;
|
||||
sender.send(()).await.unwrap();
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), run)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(JobExecutionError::Cancelled)
|
||||
));
|
||||
assert!(
|
||||
sender.is_closed(),
|
||||
"normal Worker release must close cancellation receiver"
|
||||
);
|
||||
let mut client = ScriptedClient::new(vec![]);
|
||||
client.fail = true;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let outcome = prepared(root.path(), &request("user:a"), client)
|
||||
.run(
|
||||
"attempt".into(),
|
||||
Arc::new(RecordingSink::default()),
|
||||
move |sender| {
|
||||
let _ = tx.send(sender);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(outcome, Err(JobExecutionError::Worker(_))));
|
||||
assert!(
|
||||
rx.await.unwrap().is_closed(),
|
||||
"error must release the Worker"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_result_survives_later_provider_error_and_cancellation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for cancel in [false, true] {
|
||||
let mut client =
|
||||
ScriptedClient::new(vec![call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true}))]);
|
||||
client.pending = cancel;
|
||||
client.fail = !cancel;
|
||||
let entered = client.entered.clone();
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let job = prepared(root.path(), &request("user:a"), client);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let recording = sink.clone();
|
||||
let run = tokio::spawn(async move {
|
||||
job.run("attempt".into(), recording, move |sender| {
|
||||
let _ = tx.send(sender);
|
||||
})
|
||||
.await
|
||||
});
|
||||
let sender = rx.await.unwrap();
|
||||
if cancel {
|
||||
entered.notified().await;
|
||||
assert_eq!(
|
||||
sink.0.lock().unwrap().len(),
|
||||
1,
|
||||
"acceptance must precede cancellation"
|
||||
);
|
||||
sender.send(()).await.unwrap();
|
||||
}
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(2), run)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.submission.result, json!(true));
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 1);
|
||||
assert!(sender.is_closed());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_limit_is_bounded_and_accepted_result_survives_later_failure() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let registry = registry(
|
||||
root.path(),
|
||||
artifact("default", 512, 1),
|
||||
artifact("default", 512, 1),
|
||||
);
|
||||
for (tool, expected) in [("UnknownTool", false), (SUBMIT_JOB_RESULT_TOOL, true)] {
|
||||
let client = ScriptedClient::new(vec![call(tool, json!({"result":true}))]);
|
||||
let prepared = prepare_job_from_registry(
|
||||
&request("user:a"),
|
||||
root.path(),
|
||||
Some(Box::new(client.clone())),
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = prepared
|
||||
.run("attempt".into(), Arc::new(RecordingSink::default()), |_| {})
|
||||
.await;
|
||||
assert_eq!(outcome.is_ok(), expected);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_with_unavailable_requirements_are_rejected_without_name_allowlist_or_fallback() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let base = artifact("default", 512, 3);
|
||||
for (key, value, needle) in [
|
||||
("feature", json!({"task":{"enabled":true}}), "feature.task"),
|
||||
(
|
||||
"feature",
|
||||
json!({"workspace_config":{"enabled":true}}),
|
||||
"feature.workspace_config",
|
||||
),
|
||||
(
|
||||
"feature",
|
||||
json!({"subjektiv":{"consolidation_tools":true}}),
|
||||
"feature.subjektiv",
|
||||
),
|
||||
(
|
||||
"feature",
|
||||
json!({"memory":{"staging_tools":true}}),
|
||||
"feature.memory",
|
||||
),
|
||||
("scope", json!("workspace_read"), "scope"),
|
||||
(
|
||||
"delegation_scope",
|
||||
json!("workspace_write"),
|
||||
"delegation_scope",
|
||||
),
|
||||
(
|
||||
"session",
|
||||
json!({"record_event_trace":true}),
|
||||
"session.record_event_trace",
|
||||
),
|
||||
("web", json!({"enabled":true}), "web"),
|
||||
(
|
||||
"compaction",
|
||||
json!({"kind":"tokens","threshold":10000}),
|
||||
"compaction",
|
||||
),
|
||||
("worker", json!({"mode":"wip"}), "worker.mode=wip"),
|
||||
("skills", json!({"directories":["skills"]}), "skills"),
|
||||
(
|
||||
"mcp",
|
||||
json!({"stdio_server":[{"name":"test","command":"fake"}]}),
|
||||
"mcp",
|
||||
),
|
||||
] {
|
||||
let mut profile = base.clone();
|
||||
profile[key] = value;
|
||||
let registry = registry(root.path(), profile, base.clone());
|
||||
let error = prepare_job_from_registry(
|
||||
&request("user:a"),
|
||||
root.path(),
|
||||
Some(Box::new(ScriptedClient::new(vec![]))),
|
||||
®istry,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(error, JobExecutionError::UnsupportedProfile(_)),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.to_string().contains(needle), "{error}");
|
||||
}
|
||||
for name in [
|
||||
"web",
|
||||
"image",
|
||||
"sub_worker",
|
||||
"flow",
|
||||
"worker",
|
||||
"workspace_worker_discovery",
|
||||
"objective",
|
||||
"manage_workdir",
|
||||
"ticket",
|
||||
"orchestration",
|
||||
] {
|
||||
let mut profile = base.clone();
|
||||
profile["feature"] = json!({name:{"enabled":true}});
|
||||
let registry = registry(root.path(), profile, base.clone());
|
||||
assert!(matches!(
|
||||
prepare_job_from_registry(
|
||||
&request("user:a"),
|
||||
root.path(),
|
||||
Some(Box::new(ScriptedClient::new(vec![]))),
|
||||
®istry
|
||||
),
|
||||
Err(JobExecutionError::UnsupportedProfile(_))
|
||||
));
|
||||
}
|
||||
let registry = registry(root.path(), base.clone(), base);
|
||||
for selector in ["user:missing", "project:a", "./a.json", "inherit"] {
|
||||
assert!(
|
||||
prepare_job_from_registry(
|
||||
&request(selector),
|
||||
root.path(),
|
||||
Some(Box::new(ScriptedClient::new(vec![]))),
|
||||
®istry
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let builtin = request("builtin:backend-job");
|
||||
assert!(matches!(
|
||||
prepare_job_from_registry(
|
||||
&builtin,
|
||||
root.path(),
|
||||
Some(Box::new(ScriptedClient::new(vec![]))),
|
||||
®istry
|
||||
),
|
||||
Err(JobExecutionError::UnsupportedProfile(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prompt_and_model_are_not_substituted_even_with_injected_client() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut unknown_model = artifact("default", 512, 3);
|
||||
unknown_model["model"] = json!({"ref":"not-a-provider/not-a-model"});
|
||||
let registry = registry(
|
||||
root.path(),
|
||||
artifact("internal.missing", 512, 3),
|
||||
unknown_model,
|
||||
);
|
||||
for (profile, prompt_error) in [("user:a", true), ("user:b", false)] {
|
||||
let error = prepare_job_from_registry(
|
||||
&request(profile),
|
||||
root.path(),
|
||||
Some(Box::new(ScriptedClient::new(vec![]))),
|
||||
®istry,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
if prompt_error {
|
||||
assert!(
|
||||
matches!(error, JobExecutionError::Instruction(_)),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(matches!(error, JobExecutionError::Model(_)), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnknownSink(Mutex<Vec<::job::JobResultSubmission>>);
|
||||
impl JobResultSink for UnknownSink {
|
||||
fn submit(&self, value: ::job::JobResultSubmission) -> Result<(), String> {
|
||||
self.0.lock().unwrap().push(value);
|
||||
Err("unknown".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unconfirmed_sink_acceptance_is_not_classified_as_missing_result_or_known_failure() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let sink = Arc::new(UnknownSink(Mutex::new(vec![])));
|
||||
let job = prepared(
|
||||
root.path(),
|
||||
&request("user:a"),
|
||||
ScriptedClient::new(vec![
|
||||
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})),
|
||||
prose(),
|
||||
]),
|
||||
);
|
||||
assert!(matches!(
|
||||
job.run("attempt".into(), sink.clone(), |_| {}).await,
|
||||
Err(JobExecutionError::ResultUnconfirmed(_))
|
||||
));
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feature_registration_rejects_forged_fences_seals_unknown_outcomes_and_honors_cancel() {
|
||||
let sink = Arc::new(UnknownSink(Mutex::new(vec![])));
|
||||
let feature =
|
||||
JobResultFeature::new(&request("user:a"), "attempt".into(), sink.clone()).unwrap();
|
||||
let mut pending = vec![];
|
||||
let mut hooks = crate::HookRegistryBuilder::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(feature.clone())
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
assert_eq!(report.installed_tool_names(), [SUBMIT_JOB_RESULT_TOOL]);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
assert!(
|
||||
tool.execute(
|
||||
r#"{"attempt_id":"forged","result":true}"#,
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(sink.0.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
tool.execute(r#"{"result":true}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(r#"{"result":false}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(r#"{"result":true}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 2);
|
||||
let context = ToolExecutionContext::new("cancelled", "batch", 0);
|
||||
feature.cancel_execution(&context).await.unwrap();
|
||||
assert!(matches!(
|
||||
tool.execute(r#"{"result":true}"#, context).await,
|
||||
Err(agen::tool::ToolError::Cancelled(_))
|
||||
));
|
||||
assert_eq!(sink.0.lock().unwrap().len(), 2);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod fs_view;
|
||||
pub mod hook;
|
||||
pub(crate) mod in_flight;
|
||||
pub mod ipc;
|
||||
pub mod job;
|
||||
pub mod model_client;
|
||||
mod paste_artifact_tool;
|
||||
pub mod prompt;
|
||||
|
||||
@@ -97,6 +97,7 @@ pub enum WorkerPrompt {
|
||||
SubjektivMemoryConsolidationSystem,
|
||||
SubjektivMemorySurfaceSystem,
|
||||
FlowVerifierSystem,
|
||||
JobSystem,
|
||||
BackendJobSystem,
|
||||
NotifyWrapper,
|
||||
InterruptToolResultSummary,
|
||||
@@ -121,6 +122,7 @@ impl WorkerPrompt {
|
||||
}
|
||||
Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system",
|
||||
Self::FlowVerifierSystem => "internal.flow_verifier_system",
|
||||
Self::JobSystem => "internal.job_system",
|
||||
Self::BackendJobSystem => "internal.backend_job_system",
|
||||
Self::NotifyWrapper => "internal.notify_wrapper",
|
||||
Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary",
|
||||
@@ -144,6 +146,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::SubjektivMemoryConsolidationSystem,
|
||||
WorkerPrompt::SubjektivMemorySurfaceSystem,
|
||||
WorkerPrompt::FlowVerifierSystem,
|
||||
WorkerPrompt::JobSystem,
|
||||
WorkerPrompt::BackendJobSystem,
|
||||
WorkerPrompt::NotifyWrapper,
|
||||
WorkerPrompt::InterruptToolResultSummary,
|
||||
|
||||
Reference in New Issue
Block a user