feat: execute durable standalone Jobs through Host-owned Internal Workers (T-708)

This commit is contained in:
2026-10-07 00:08:01 +09:00
parent 3c424fd17d
commit 13055d7ee1
30 changed files with 5108 additions and 153 deletions
+1
View File
@@ -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 }
+3
View File
@@ -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}"
+323
View File
@@ -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, &registry)
}
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;
+179
View File
@@ -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(),
))
}
}
+666
View File
@@ -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)), &registry).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())),
&registry,
)
.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())),
&registry,
)
.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())),
&registry,
)
.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![]))),
&registry,
)
.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![]))),
&registry
),
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![]))),
&registry
)
.is_err()
);
}
let builtin = request("builtin:backend-job");
assert!(matches!(
prepare_job_from_registry(
&builtin,
root.path(),
Some(Box::new(ScriptedClient::new(vec![]))),
&registry
),
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![]))),
&registry,
)
.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);
}
+1
View File
@@ -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;
+3
View File
@@ -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,